diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 026f5a693..213bafb1f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -158,7 +158,14 @@ jobs: # object API directly (tests/e2e/workflows/_fixtures.ts), so testing # against `main` measures a different backend than the one this app is # written for. Pinned to `development` to match. - additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]' + # + # integriq is here because the Integrations page reads integriq's + # `app_connection` rows (adopt-connection-registry). Without it the page + # shows the missing-dependency screen and + # `tests/e2e/workflows/integrations-page.spec.ts` fails on every run. + # `app` is `integriq`, verified in its appinfo/info.xml on `development` + # on 2026-09-14. + additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"},{"repo":"ConductionNL/integriq","app":"integriq","ref":"development"}]' # Newman disabled: tests/magic-mapper-import.postman_collection.json was # written against a dev env with URL rewriting + a fixed disk layout — it # hits bare paths like `/configurations` and uploads files from diff --git a/appinfo/info.xml b/appinfo/info.xml index 15874ec6c..c296c0ece 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -50,7 +50,7 @@ Vrij en open source onder de EUPL-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. Voor een Service Level Agreement (SLA), neem contact op via sales@conduction.nl. ]]> - 0.2.2-unstable.20260910105110 + 0.2.3-unstable.20260912202721 EUPL-1.2 Conduction Stackiq diff --git a/appinfo/routes.php b/appinfo/routes.php index 5401a68b3..57ec9fbc0 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -104,8 +104,6 @@ ['name' => 'settings#killArchiMateImport', 'url' => '/api/archimate/import/kill', 'verb' => 'POST'], // deprecated ['name' => 'settings#clearArchiMateExportStatus', 'url' => '/api/archimate/status/export/clear', 'verb' => 'POST'], - ['name' => 'settings#testArchiMateRoundTrip', 'url' => '/api/archimate/test-round-trip', 'verb' => 'POST'], - // User Groups management routes ['name' => 'settings#getGenericUserGroups', 'url' => '/api/settings/user-groups/generic', 'verb' => 'GET'], ['name' => 'settings#setGenericUserGroups', 'url' => '/api/settings/user-groups/generic', 'verb' => 'POST'], @@ -126,7 +124,12 @@ // ArchiMate focused endpoints ['name' => 'settings#getArchiMateConfig', 'url' => '/api/archimate/config', 'verb' => 'GET'], ['name' => 'settings#updateArchiMateConfig', 'url' => '/api/archimate/config', 'verb' => 'POST'], - ['name' => 'settings#getArchiMateConfig', 'url' => '/api/archimate/status', 'verb' => 'GET'], + // A route name carries no URL, so without a 'postfix' this entry and the + // GET '/api/archimate/config' one above register under the same name and + // only the last declared survives. This one won on line order, which left + // the config read a 404 and made the store's polling endpoint depend on + // nothing but the order of these two lines. + ['name' => 'settings#getArchiMateConfig', 'url' => '/api/archimate/status', 'verb' => 'GET', 'postfix' => 'Status'], // Email focused endpoints ['name' => 'settings#getEmailConfig', 'url' => '/api/email/config', 'verb' => 'GET'], diff --git a/composer.json b/composer.json index 0d4773e31..42b141ca4 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/", "psalm": "if [ -f vendor/bin/psalm ]; then ./vendor/bin/psalm --threads=1 --no-cache --memory-limit=2G; else echo 'Psalm not installed, skipping...'; fi", "phpstan": "if [ -f vendor/bin/phpstan ]; then ./vendor/bin/phpstan analyse --memory-limit=1G; else echo 'PHPStan not installed, skipping...'; fi", - "test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky", + "test:unit": "phpunit -c phpunit-unit.xml --colors=always --fail-on-warning --fail-on-risky", "test:all": "if [ ! -f vendor/bin/phpunit ]; then echo 'SKIPPED: phpunit not installed - run composer install'; elif [ ! -f ../../lib/base.php ]; then echo 'SKIPPED: tests/bootstrap.php requires a Nextcloud server tree (../../lib/base.php not found) - run from inside a Nextcloud checkout or in CI'; else ./vendor/bin/phpunit --colors=always; fi", "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", diff --git a/docs/features/ai-systems.md b/docs/features/ai-systems.md new file mode 100644 index 000000000..55c948745 --- /dev/null +++ b/docs/features/ai-systems.md @@ -0,0 +1,45 @@ + + +# AI systems + +An AI system is an AI agent, an AI model or an AI feature that your organisation uses. You register it next to the application it runs in, classify it under the EU AI Act, and keep the documents the act asks for. + +Specification: [`openspec/specs/ai-system-inventory/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/ai-system-inventory/spec.md). + +## Registering an AI system + +Open **Applications** in the navigation menu, then **AI systems**, and click **Add**. Fill in: + +- **Name** and **Description**. +- **Kind**: an AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application. +- **Application**: the application it runs in or supports. +- **Supplier** and **Purpose**: who supplies it and what it decides, recommends or produces. + +The page of the application shows its AI systems in the **AI systems** section. + +## Classifying it under the AI Act + +Each AI system records: + +- **AI Act risk category**: prohibited, high risk, limited risk, minimal risk, or not yet assessed. A new system starts as not yet assessed. +- **Role under the AI Act**: provider or deployer. +- **Last assessed on** and the **Algorithm register entry**, the link to the system in the Dutch algorithm register. + +The category is your organisation's own classification. Stackiq records it; it does not decide it. + +## Evidence + +Attach documents to the AI system under **Documents** and tag each one: FRIA (fundamental rights impact assessment), Technical documentation, Human oversight or Logging. The **AI Act evidence** panel on the page lists the four tags and shows which have a document. + +Fill in **Fundamental rights impact assessment** with a reference to the FRIA. A high-risk AI system without one: + +- reads **FRIA missing** in the list, +- shows a warning on its page, +- and appears under the **High risk without FRIA** filter above the list. + +The other filters above the list select one risk category each. + +Screenshots follow once the feature runs on the demo instance. diff --git a/docs/features/application-page.md b/docs/features/application-page.md new file mode 100644 index 000000000..39581ff1c --- /dev/null +++ b/docs/features/application-page.md @@ -0,0 +1,29 @@ + + +# The application page + +One page per application shows what the catalogue knows about it: its data, the organisations that use it, its versions, its compliance claims and the contracts behind it. + +Specification: [`openspec/specs/application-page/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-page/spec.md). + +## Opening the page + +Open **Applications** from the navigation menu and click a row, or use the **View** action on it. The page opens at `/modules/`. The page also opens from an organisation's list of applications. + +## What the page shows + +- **Application**: the name, the short and long description, the website, the supplier, the supplier's contact person for this product, and the hosting, licence, BBN and DPIA fields. +- **Documentation**: files attached to the application. +- **Vendor and services**: the supplier and the services and connections linked to the application. +- **Application versions**: every registered version with its status. A row opens the version. +- **Usages**: every organisation that registered a usage of the application, with the version it uses and the status of that usage. You see the usages you may read: a municipality sees its own, a supplier sees the usages of its products. +- **Compliance claims**: the standards and BIO measures the application claims, with the evidence. A row opens the claim. +- **Contracts**: every contract on a usage of the application, and every contract on a service that offers it, each listed once, with its number, type, end date and status. A row opens the contract. You see only the contracts you may read. +- **Reviews**: ratings and reviews of the application. + +## Why a contract shows up here + +A contract in stackiq belongs to a usage and a service, not to the application directly. The page follows both links: a contract appears when its usage is a usage of this application, or when its service offers this application. diff --git a/docs/features/applications-in-use.md b/docs/features/applications-in-use.md new file mode 100644 index 000000000..0c4e63fb2 --- /dev/null +++ b/docs/features/applications-in-use.md @@ -0,0 +1,38 @@ + + +# Applications in use + +An application in use records that your organisation uses an application: which version it runs, where it stands in its lifecycle, and who owns it on the business side and on the technical side. The portfolio views, the lifecycle roadmap and the end-of-support warnings all start from these records. + +Specification: [`openspec/specs/application-usage-pages/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-usage-pages/spec.md). + +## Adding an application to your landscape + +Open the application's page under **Applications** and click **Add to our landscape** in the usages section. The application is already filled in. Pick: + +- **Consumer**: your organisation. +- **Version**: the version you run. The list only offers versions of this application. +- **Status**: Acquisition, Planned, In production, To be phased out or Phased out. +- **Business owner** and **Technical owner**: contact persons of your organisation. + +## Browsing what you use + +Open **Applications** in the navigation menu, then **Applications in use**. The list shows each application with its version, status, owners and TIME classification. The tabs above the list filter on status. Your organisation's page lists the same records under **Applications in use**. + +## Moving through the lifecycle + +Open an application in use. The actions at the top follow its status: + +- **Plan** moves Acquisition to Planned. +- **Go live** moves Planned to In production. +- **Phase out** moves In production to To be phased out. +- **Retire** moves To be phased out to Phased out. + +Every change is kept in the **History** tab. + +## Who sees the owners + +The owners are contact persons of the organisation that uses the application. A supplier can read the usages of its own products, but it cannot open the contact persons of its customers. diff --git a/docs/features/connections.md b/docs/features/connections.md new file mode 100644 index 000000000..e36180ec9 --- /dev/null +++ b/docs/features/connections.md @@ -0,0 +1,32 @@ + + +# Connections + +A connection records that one application exchanges data with another application, or with a national provision such as a basisregistratie: over which transport, in which direction, and in which state. + +Specification: [`openspec/specs/catalogue-connection-pages/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/catalogue-connection-pages/spec.md). + +## The connections list + +Open **Applications** in the navigation menu, then **Connections**. The list at `/koppelingen` shows every connection you may read, with its type, its status, both applications, the national provision and the direction. + +- The chips above the list filter on status: in use, in development, end of support and withdrawn. +- The filter menu in the table header filters on type (for example api or file transfer), status and direction. +- Click a row to open the connection. + +You see the connections of your own organisation, and the connections that are published. + +## A connection's page + +The page shows the connection's data, both applications, the national provision and the intermediary application if there is one, the lifecycle dates, attached documents, and its history. + +The actions at the top move a connection through its lifecycle: release (in development to in use), sunset (in use to end of support) and withdraw (to withdrawn). + +When you record a connection to a national provision, the picker lists the GEMMA elements of type Buitengemeentelijke voorziening. + +## Connections on the application page + +The page of an application has two lists: **Connections from this application**, where it is application A, and **Connections to this application**, where it is application B. Each row opens the connection, and View all opens the connections list filtered on that application. diff --git a/docs/features/licence-seats.md b/docs/features/licence-seats.md new file mode 100644 index 000000000..162522431 --- /dev/null +++ b/docs/features/licence-seats.md @@ -0,0 +1,39 @@ + + +# Licence seats + +A licence contract records how the licence is measured and how many licences were bought and are in use. Stackiq sets the two numbers against each other on the contract and on the License posture page, so you see where use runs over what was bought. + +Specification: [`openspec/specs/licence-seats/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/licence-seats/spec.md). + +## Recording the licence on a contract + +Open a contract and edit it. Three fields describe the licence: + +- **Licence metric**: per named user, per concurrent user, per device, per inhabitant, per organisation, or other. +- **Licences bought**: the number the contract pays for. +- **Licences in use**: the number in use today. + +All three are optional. A count cannot be negative. + +## The licences panel on a contract + +The contract page shows a **Licences** panel with the metric, both counts, a bar of in use against bought, and one of these states: + +- **Within licence**: in use is at or below bought. +- **Over licence by N**: in use is N above bought. The bar turns red. +- **Unknown**: one of the counts is empty. +- **Not counted**: the metric is per organisation or other, so there is nothing to count and no bar. + +The panel also shows the date the contract was last changed, so you can judge how current the count is. + +## The Seats section on the License posture page + +Open **License posture** in the navigation menu. The **Seats** section has one row per contract with a counted metric and a number of licences bought. Each row names the application, the organisation, the metric, bought, in use and the state. Contracts over their licence come first, the furthest over at the top. + +You see the contracts you may read; the section uses the same access rules as the rest of the page. + +Screenshots of the panel and the section follow once the feature runs on the demo instance. diff --git a/docs/features/maintenance-and-roadmap.md b/docs/features/maintenance-and-roadmap.md new file mode 100644 index 000000000..880f87e94 --- /dev/null +++ b/docs/features/maintenance-and-roadmap.md @@ -0,0 +1,35 @@ + + +# Maintenance and roadmap + +Suppliers announce planned maintenance on their applications and publish where each application is heading. Organisations that use an application see the maintenance on their dashboard and on the application's page, and read the roadmap before they plan an upgrade. + +Specification: [`openspec/specs/maintenance-and-supplier-roadmap/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/maintenance-and-supplier-roadmap/spec.md). + +## Announcing maintenance + +Open your application's page under **Applications** and click **Announce maintenance** in the **Planned maintenance** section. Fill in: + +- **Title** and **Description**: what happens and what users should do. +- **Version**: only when the maintenance concerns one version. +- **Starts at** and **Ends at**. +- **Impact**: No impact, Degraded or Unavailable. + +A new window starts as Planned. From its page you move it to In progress, Completed or Cancelled. + +## Who is told + +When you announce a window, stackiq looks up every organisation that uses the application and the business owner and technical owner of each usage. Those owners get a Nextcloud notification, and a reminder the day before the window starts while it is still planned. Owners are set on the usage, under **Applications in use**; a usage without owners still shows the window on its organisation's dashboard. + +## Following maintenance + +The dashboard lists **Planned maintenance** on the applications your organisation uses in the next 30 days, with the time window and the impact. Each application's page lists all its planned maintenance. + +## The roadmap + +A supplier writes the direction of an application in the **Roadmap** field of the application. The application's page shows it with the application's versions on a timeline, the planned versions first. A version is placed on its go-live date, or on the date development started when it has no go-live date yet. + +Under **Module versions**, the **Planned releases** tab lists the versions still in development, with the date development started. A supplier releases a planned version from its page with **Release**. diff --git a/docs/features/portfolio-value-assessment.md b/docs/features/portfolio-value-assessment.md new file mode 100644 index 000000000..e37335a17 --- /dev/null +++ b/docs/features/portfolio-value-assessment.md @@ -0,0 +1,44 @@ + + +# Value assessment + +An information manager scores each application the organisation uses on business value, technical fit and risk. The scores sit next to the cost stackiq already adds up from contracts, and they point to a TIME class. The TIME class the organisation records stays the decision: the scores back it up or question it, they never change it. + +Specification: [`openspec/specs/application-value-assessment/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-value-assessment/spec.md). + +## Scoring an application + +Open the application under **Applications in use** and edit it. Fill in: + +- **Business value**: how much the organisation depends on it, from 1 (little) to 5 (critical). +- **Technical fit**: how well it fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good). +- **Risk**: the risk the organisation sees in running it, from 1 (low) to 5 (high). +- **Scored on**: the date you set the scores. + +When you save, stackiq fills in **Suggested TIME classification**: + +| Business value | Technical fit | Suggested class | +|---|---|---| +| 3 or more | 3 or more | Invest | +| 3 or more | below 3 | Migrate | +| below 3 | 3 or more | Tolerate | +| below 3 | below 3 | Eliminate | + +While either score is missing there is no suggestion. The **Value assessment** section of the page shows the scores, the recorded TIME class and the suggestion side by side. + +## Risk signals + +Below the data of the page, **Risk signals** shows what backs a risk score: whether the version you run is past its end of support (or withdrawn), and how many known vulnerabilities are linked to the application. You set the risk score yourself; the signals only inform it. + +## The portfolio report + +The portfolio report (**Reports**, then **Portfolio rationalization**) adds, for the organisation you select: + +- **Business value against technical fit**: one circle per scored application, its size the annualised cost. The circle's colour is the suggested class; a dark ring marks a recorded class that differs from the scores. Applications without both scores are counted below the chart. +- Two columns in the table: **Suggested by scores** and **Value / fit / risk**. +- The switch **Recorded class differs from scores**, which lists only the applications whose recorded class and suggestion disagree. + +The CSV export carries the columns businessValue, technicalFit, riskScore, scoredOn, suggestedTimeClassification and timeMismatch. diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ebf283ebf..8fd4f0273 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -388,7 +388,7 @@ }, "src/store/modules/settings.js": { "no-console": { - "count": 18 + "count": 17 }, "no-unused-vars": { "count": 8 diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json index 85c694064..8332713f6 100644 --- a/l10n/.schema-l10n-baseline.json +++ b/l10n/.schema-l10n-baseline.json @@ -1,3 +1,3 @@ { - "uncovered": 480 + "uncovered": 479 } diff --git a/l10n/en.js b/l10n/en.js index 08b68094d..f81da82a9 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1,6 +1,28 @@ OC.L10N.register( "stackiq", { + "Licence metric": "Licence metric", + "What one licence covers, as the contract with the supplier states it.": "What one licence covers, as the contract with the supplier states it.", + "Licences bought": "Licences bought", + "The number of licences this contract buys.": "The number of licences this contract buys.", + "Licences in use": "Licences in use", + "The number of licences in use today, as the application owner counted them.": "The number of licences in use today, as the application owner counted them.", + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other", + "Licences": "Licences", + "Loading licences": "Loading licences", + "The licences could not be loaded.": "The licences could not be loaded.", + "Last changed on {date}": "Last changed on {date}", + "{inUse} of {bought} in use": "{inUse} of {bought} in use", + "Within licence": "Within licence", + "Over licence by {count}": "Over licence by {count}", + "Not counted": "Not counted", + "Seats": "Seats", + "No licence contracts with counts": "No licence contracts with counts", "AMEF elements": "AMEF elements", "AMEF standards": "AMEF standards", "ArchiMate element": "ArchiMate element", @@ -704,7 +726,182 @@ OC.L10N.register( "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?", + "Integrations": "Integrations", + "Status message": "Status message", + "Last checked": "Last checked", + "All connections": "All connections", + "Add integration": "Add integration", + "Open settings": "Open settings", + "Configured": "Configured", + "Limited": "Limited", + "Not configured": "Not configured", + "Simulated": "Simulated", + "Not available": "Not available", + "Error": "Error", + "Settings": "Settings", + "Applications supplied": "Applications supplied", + "Services supplied": "Services supplied", + "Applications managed": "Applications managed", + "Services managed": "Services managed", + "Cancel import": "Cancel import", + "Import cancelled. {count} objects were saved before it stopped.": "Import cancelled. {count} objects were saved before it stopped.", + "Checking the file": "Checking the file", + "Reading the model": "Reading the model", + "Converting the model": "Converting the model", + "Saving objects": "Saving objects", + "Finishing the import": "Finishing the import", + "Importing": "Importing", + "{processed} of {total} objects saved": "{processed} of {total} objects saved", + "Contract": "Contract", + "Loading contracts": "Loading contracts", + "No contracts found for this application": "No contracts found for this application", + "The contracts could not be loaded.": "The contracts could not be loaded.", + "Usages": "Usages", + "No organisation registered a usage yet": "No organisation registered a usage yet", + "Every connection between applications, and from an application to a national provision.": "Every connection between applications, and from an application to a national provision.", + "In use": "In use", + "In development": "In development", + "Applications and standards": "Applications and standards", + "Documents": "Documents", + "Connections from this application": "Connections from this application", + "Connections to this application": "Connections to this application", + "To application": "To application", + "To national provision": "To national provision", + "From application": "From application", + "No connections start at this application": "No connections start at this application", + "No connections end at this application": "No connections end at this application", + "AI system": "AI system", + "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.", + "The name the organisation uses for this AI system.": "The name the organisation uses for this AI system.", + "What the AI system is and how it is used.": "What the AI system is and how it is used.", + "Kind": "Kind", + "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.", + "AI agent": "AI agent", + "AI model": "AI model", + "AI feature": "AI feature", + "The application this AI system runs in or supports.": "The application this AI system runs in or supports.", + "The organisation that supplies the AI system.": "The organisation that supplies the AI system.", + "Purpose": "Purpose", + "What the AI system decides, recommends or produces.": "What the AI system decides, recommends or produces.", + "AI Act risk category": "AI Act risk category", + "The risk category under the EU AI Act, as the organisation classified it.": "The risk category under the EU AI Act, as the organisation classified it.", + "Prohibited": "Prohibited", + "High risk": "High risk", + "Limited risk": "Limited risk", + "Minimal risk": "Minimal risk", + "Not yet assessed": "Not yet assessed", + "Role under the AI Act": "Role under the AI Act", + "Whether the organisation provides the AI system or deploys it.": "Whether the organisation provides the AI system or deploys it.", + "Deployer": "Deployer", + "Algorithm register entry": "Algorithm register entry", + "The link to this system in the Dutch algorithm register.": "The link to this system in the Dutch algorithm register.", + "Last assessed on": "Last assessed on", + "The date the classification was last assessed.": "The date the classification was last assessed.", + "Fundamental rights impact assessment": "Fundamental rights impact assessment", + "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.", + "Where the AI system stands in its lifecycle.": "Where the AI system stands in its lifecycle.", + "AI Act evidence": "AI Act evidence", + "Attach a document under Documents and give it the matching tag.": "Attach a document under Documents and give it the matching tag.", + "FRIA missing": "FRIA missing", + "Fundamental rights impact assessment (FRIA)": "Fundamental rights impact assessment (FRIA)", + "Human oversight": "Human oversight", + "Loading the evidence": "Loading the evidence", + "Logging": "Logging", + "Missing": "Missing", + "Technical documentation": "Technical documentation", + "The evidence could not be loaded.": "The evidence could not be loaded.", + "This is a high-risk AI system without a fundamental rights impact assessment.": "This is a high-risk AI system without a fundamental rights impact assessment.", + "AI systems": "AI systems", + "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.", + "FRIA": "FRIA", + "High risk without FRIA": "High risk without FRIA", + "Application and supplier": "Application and supplier", + "History": "History", + "No AI systems registered for this application": "No AI systems registered for this application", + "Application in use": "Application in use", + "To be phased out": "To be phased out", + "Business owner": "Business owner", + "Technical owner": "Technical owner", + "The person in the organisation who is responsible for how the application is used.": "The person in the organisation who is responsible for how the application is used.", + "The person in the organisation who is responsible for running and maintaining the application.": "The person in the organisation who is responsible for running and maintaining the application.", + "Add to our landscape": "Add to our landscape", + "Connections and services": "Connections and services", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", + "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet", + "Maintenance window": "Maintenance window", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.", + "The product the maintenance is on.": "The product the maintenance is on.", + "The version the maintenance is on, when it concerns one version.": "The version the maintenance is on, when it concerns one version.", + "What the maintenance is, in a few words.": "What the maintenance is, in a few words.", + "What changes and what users should do.": "What changes and what users should do.", + "Starts at": "Starts at", + "When the maintenance starts.": "When the maintenance starts.", + "Ends at": "Ends at", + "When the maintenance ends.": "When the maintenance ends.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "What users notice while the maintenance runs.", + "Where the maintenance stands.": "Where the maintenance stands.", + "Owners to notify": "Owners to notify", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.", + "Owners resolved at": "Owners resolved at", + "When the owners to notify were resolved.": "When the owners to notify were resolved.", + "No impact": "No impact", + "Degraded": "Degraded", + "Unavailable": "Unavailable", + "In progress": "In progress", + "Completed": "Completed", + "Cancelled": "Cancelled", + "Start the maintenance.": "Start the maintenance.", + "Complete the maintenance.": "Complete the maintenance.", + "Cancel the maintenance.": "Cancel the maintenance.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "The direction the supplier takes with this application: what the next versions bring and when.", + "Planned maintenance": "Planned maintenance", + "Announce maintenance": "Announce maintenance", + "No maintenance planned on this application": "No maintenance planned on this application", + "Planned releases": "Planned releases", + "Loading planned maintenance": "Loading planned maintenance", + "The planned maintenance could not be loaded.": "The planned maintenance could not be loaded.", + "No maintenance planned on the applications you use in the next 30 days": "No maintenance planned on the applications you use in the next 30 days", + "{start} to {end}": "{start} to {end}", + "Loading the roadmap": "Loading the roadmap", + "The roadmap could not be loaded.": "The roadmap could not be loaded.", + "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application", + "No versions with a date yet": "No versions with a date yet", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: business value {value}, technical fit {fit}, annualised cost {cost}", + "%n application in use is not scored yet.": "%n application in use is not scored yet.", + "%n applications in use are not scored yet.": "%n applications in use are not scored yet.", + "%n vulnerability linked to this application": "%n vulnerability linked to this application", + "%n vulnerabilities linked to this application": "%n vulnerabilities linked to this application", + "Business value": "Business value", + "Business value against technical fit": "Business value against technical fit", + "Business value against technical fit for %n scored application in use": "Business value against technical fit for %n scored application in use", + "Business value against technical fit for %n scored applications in use": "Business value against technical fit for %n scored applications in use", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.", + "Known vulnerabilities": "Known vulnerabilities", + "Loading the risk signals": "Loading the risk signals", + "No end of support date known": "No end of support date known", + "Not scored": "Not scored", + "Passed on {date}": "Passed on {date}", + "Recorded class differs from scores": "Recorded class differs from scores", + "Risk score": "Risk score", + "Risk signals": "Risk signals", + "Suggested by scores": "Suggested by scores", + "Supported until {date}": "Supported until {date}", + "Technical fit": "Technical fit", + "The risk signals could not be loaded.": "The risk signals could not be loaded.", + "This version was withdrawn": "This version was withdrawn", + "Value / fit / risk": "Value / fit / risk", + "Value assessment": "Value assessment", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "How much the organisation depends on this application, from 1 (little) to 5 (critical).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).", + "Risk": "Risk", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.", + "Scored on": "Scored on", + "The date the scores were set.": "The date the scores were set.", + "Suggested TIME classification": "Suggested TIME classification", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 81aaa9c63..21f9325c5 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,5 +1,27 @@ { "translations": { + "Licence metric": "Licence metric", + "What one licence covers, as the contract with the supplier states it.": "What one licence covers, as the contract with the supplier states it.", + "Licences bought": "Licences bought", + "The number of licences this contract buys.": "The number of licences this contract buys.", + "Licences in use": "Licences in use", + "The number of licences in use today, as the application owner counted them.": "The number of licences in use today, as the application owner counted them.", + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other", + "Licences": "Licences", + "Loading licences": "Loading licences", + "The licences could not be loaded.": "The licences could not be loaded.", + "Last changed on {date}": "Last changed on {date}", + "{inUse} of {bought} in use": "{inUse} of {bought} in use", + "Within licence": "Within licence", + "Over licence by {count}": "Over licence by {count}", + "Not counted": "Not counted", + "Seats": "Seats", + "No licence contracts with counts": "No licence contracts with counts", "AMEF elements": "AMEF elements", "AMEF standards": "AMEF standards", "ArchiMate element": "ArchiMate element", @@ -703,6 +725,181 @@ "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?", + "Integrations": "Integrations", + "Status message": "Status message", + "Last checked": "Last checked", + "All connections": "All connections", + "Add integration": "Add integration", + "Open settings": "Open settings", + "Configured": "Configured", + "Limited": "Limited", + "Not configured": "Not configured", + "Simulated": "Simulated", + "Not available": "Not available", + "Error": "Error", + "Settings": "Settings", + "Applications supplied": "Applications supplied", + "Services supplied": "Services supplied", + "Applications managed": "Applications managed", + "Services managed": "Services managed", + "Cancel import": "Cancel import", + "Import cancelled. {count} objects were saved before it stopped.": "Import cancelled. {count} objects were saved before it stopped.", + "Checking the file": "Checking the file", + "Reading the model": "Reading the model", + "Converting the model": "Converting the model", + "Saving objects": "Saving objects", + "Finishing the import": "Finishing the import", + "Importing": "Importing", + "{processed} of {total} objects saved": "{processed} of {total} objects saved", + "Contract": "Contract", + "Loading contracts": "Loading contracts", + "No contracts found for this application": "No contracts found for this application", + "The contracts could not be loaded.": "The contracts could not be loaded.", + "Usages": "Usages", + "No organisation registered a usage yet": "No organisation registered a usage yet", + "Every connection between applications, and from an application to a national provision.": "Every connection between applications, and from an application to a national provision.", + "In use": "In use", + "In development": "In development", + "Applications and standards": "Applications and standards", + "Documents": "Documents", + "Connections from this application": "Connections from this application", + "Connections to this application": "Connections to this application", + "To application": "To application", + "To national provision": "To national provision", + "From application": "From application", + "No connections start at this application": "No connections start at this application", + "No connections end at this application": "No connections end at this application", + "AI system": "AI system", + "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.", + "The name the organisation uses for this AI system.": "The name the organisation uses for this AI system.", + "What the AI system is and how it is used.": "What the AI system is and how it is used.", + "Kind": "Kind", + "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.", + "AI agent": "AI agent", + "AI model": "AI model", + "AI feature": "AI feature", + "The application this AI system runs in or supports.": "The application this AI system runs in or supports.", + "The organisation that supplies the AI system.": "The organisation that supplies the AI system.", + "Purpose": "Purpose", + "What the AI system decides, recommends or produces.": "What the AI system decides, recommends or produces.", + "AI Act risk category": "AI Act risk category", + "The risk category under the EU AI Act, as the organisation classified it.": "The risk category under the EU AI Act, as the organisation classified it.", + "Prohibited": "Prohibited", + "High risk": "High risk", + "Limited risk": "Limited risk", + "Minimal risk": "Minimal risk", + "Not yet assessed": "Not yet assessed", + "Role under the AI Act": "Role under the AI Act", + "Whether the organisation provides the AI system or deploys it.": "Whether the organisation provides the AI system or deploys it.", + "Deployer": "Deployer", + "Algorithm register entry": "Algorithm register entry", + "The link to this system in the Dutch algorithm register.": "The link to this system in the Dutch algorithm register.", + "Last assessed on": "Last assessed on", + "The date the classification was last assessed.": "The date the classification was last assessed.", + "Fundamental rights impact assessment": "Fundamental rights impact assessment", + "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.", + "Where the AI system stands in its lifecycle.": "Where the AI system stands in its lifecycle.", + "AI Act evidence": "AI Act evidence", + "Attach a document under Documents and give it the matching tag.": "Attach a document under Documents and give it the matching tag.", + "FRIA missing": "FRIA missing", + "Fundamental rights impact assessment (FRIA)": "Fundamental rights impact assessment (FRIA)", + "Human oversight": "Human oversight", + "Loading the evidence": "Loading the evidence", + "Logging": "Logging", + "Missing": "Missing", + "Technical documentation": "Technical documentation", + "The evidence could not be loaded.": "The evidence could not be loaded.", + "This is a high-risk AI system without a fundamental rights impact assessment.": "This is a high-risk AI system without a fundamental rights impact assessment.", + "AI systems": "AI systems", + "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.", + "FRIA": "FRIA", + "High risk without FRIA": "High risk without FRIA", + "Application and supplier": "Application and supplier", + "History": "History", + "No AI systems registered for this application": "No AI systems registered for this application", + "Application in use": "Application in use", + "To be phased out": "To be phased out", + "Business owner": "Business owner", + "Technical owner": "Technical owner", + "The person in the organisation who is responsible for how the application is used.": "The person in the organisation who is responsible for how the application is used.", + "The person in the organisation who is responsible for running and maintaining the application.": "The person in the organisation who is responsible for running and maintaining the application.", + "Add to our landscape": "Add to our landscape", + "Connections and services": "Connections and services", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", + "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet", + "Maintenance window": "Maintenance window", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.", + "The product the maintenance is on.": "The product the maintenance is on.", + "The version the maintenance is on, when it concerns one version.": "The version the maintenance is on, when it concerns one version.", + "What the maintenance is, in a few words.": "What the maintenance is, in a few words.", + "What changes and what users should do.": "What changes and what users should do.", + "Starts at": "Starts at", + "When the maintenance starts.": "When the maintenance starts.", + "Ends at": "Ends at", + "When the maintenance ends.": "When the maintenance ends.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "What users notice while the maintenance runs.", + "Where the maintenance stands.": "Where the maintenance stands.", + "Owners to notify": "Owners to notify", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.", + "Owners resolved at": "Owners resolved at", + "When the owners to notify were resolved.": "When the owners to notify were resolved.", + "No impact": "No impact", + "Degraded": "Degraded", + "Unavailable": "Unavailable", + "In progress": "In progress", + "Completed": "Completed", + "Cancelled": "Cancelled", + "Start the maintenance.": "Start the maintenance.", + "Complete the maintenance.": "Complete the maintenance.", + "Cancel the maintenance.": "Cancel the maintenance.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "The direction the supplier takes with this application: what the next versions bring and when.", + "Planned maintenance": "Planned maintenance", + "Announce maintenance": "Announce maintenance", + "No maintenance planned on this application": "No maintenance planned on this application", + "Planned releases": "Planned releases", + "Loading planned maintenance": "Loading planned maintenance", + "The planned maintenance could not be loaded.": "The planned maintenance could not be loaded.", + "No maintenance planned on the applications you use in the next 30 days": "No maintenance planned on the applications you use in the next 30 days", + "{start} to {end}": "{start} to {end}", + "Loading the roadmap": "Loading the roadmap", + "The roadmap could not be loaded.": "The roadmap could not be loaded.", + "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application", + "No versions with a date yet": "No versions with a date yet", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: business value {value}, technical fit {fit}, annualised cost {cost}", + "%n application in use is not scored yet.": "%n application in use is not scored yet.", + "%n applications in use are not scored yet.": "%n applications in use are not scored yet.", + "%n vulnerability linked to this application": "%n vulnerability linked to this application", + "%n vulnerabilities linked to this application": "%n vulnerabilities linked to this application", + "Business value": "Business value", + "Business value against technical fit": "Business value against technical fit", + "Business value against technical fit for %n scored application in use": "Business value against technical fit for %n scored application in use", + "Business value against technical fit for %n scored applications in use": "Business value against technical fit for %n scored applications in use", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.", + "Known vulnerabilities": "Known vulnerabilities", + "Loading the risk signals": "Loading the risk signals", + "No end of support date known": "No end of support date known", + "Not scored": "Not scored", + "Passed on {date}": "Passed on {date}", + "Recorded class differs from scores": "Recorded class differs from scores", + "Risk score": "Risk score", + "Risk signals": "Risk signals", + "Suggested by scores": "Suggested by scores", + "Supported until {date}": "Supported until {date}", + "Technical fit": "Technical fit", + "The risk signals could not be loaded.": "The risk signals could not be loaded.", + "This version was withdrawn": "This version was withdrawn", + "Value / fit / risk": "Value / fit / risk", + "Value assessment": "Value assessment", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "How much the organisation depends on this application, from 1 (little) to 5 (critical).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).", + "Risk": "Risk", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.", + "Scored on": "Scored on", + "The date the scores were set.": "The date the scores were set.", + "Suggested TIME classification": "Suggested TIME classification", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision." } } diff --git a/l10n/nl.js b/l10n/nl.js index 6c52495c3..5d5db0a5b 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,28 @@ OC.L10N.register( "stackiq", { + "Licence metric": "Licentiemetriek", + "What one licence covers, as the contract with the supplier states it.": "Waar één licentie voor geldt, zoals het contract met de leverancier het vastlegt.", + "Licences bought": "Gekochte licenties", + "The number of licences this contract buys.": "Het aantal licenties dat dit contract koopt.", + "Licences in use": "Licenties in gebruik", + "The number of licences in use today, as the application owner counted them.": "Het aantal licenties dat vandaag in gebruik is, zoals de applicatie-eigenaar ze telde.", + "Per named user": "Per benoemde gebruiker", + "Per concurrent user": "Per gelijktijdige gebruiker", + "Per device": "Per apparaat", + "Per inhabitant": "Per inwoner", + "Per organisation": "Per organisatie", + "Other": "Overig", + "Licences": "Licenties", + "Loading licences": "Licenties laden", + "The licences could not be loaded.": "De licenties konden niet worden geladen.", + "Last changed on {date}": "Laatst gewijzigd op {date}", + "{inUse} of {bought} in use": "{inUse} van {bought} in gebruik", + "Within licence": "Binnen de licentie", + "Over licence by {count}": "{count} boven de licentie", + "Not counted": "Niet geteld", + "Seats": "Licentiegebruik", + "No licence contracts with counts": "Geen licentiecontracten met aantallen", "Load example data?": "Voorbeeldgegevens laden?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", @@ -777,7 +799,179 @@ OC.L10N.register( "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Vooraf ingevuld met de namen die de Integriq-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt, geen codewijziging nodig.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?", + "Integrations": "Koppelingen", + "Status message": "Statusbericht", + "Last checked": "Laatst gecontroleerd", + "All connections": "Alle verbindingen", + "Add integration": "Integratie toevoegen", + "Open settings": "Instellingen openen", + "Configured": "Ingericht", + "Limited": "Beperkt", + "Not configured": "Niet geconfigureerd", + "Simulated": "Gesimuleerd", + "Not available": "Niet beschikbaar", + "Error": "Fout", + "Settings": "Instellingen", + "Applications supplied": "Geleverde applicaties", + "Services supplied": "Geleverde diensten", + "Applications managed": "Beheerde applicaties", + "Services managed": "Beheerde diensten", + "Cancel import": "Import annuleren", + "Import cancelled. {count} objects were saved before it stopped.": "Import geannuleerd. {count} objecten waren al opgeslagen toen hij stopte.", + "Checking the file": "Bestand controleren", + "Reading the model": "Model inlezen", + "Converting the model": "Model omzetten", + "Saving objects": "Objecten opslaan", + "Finishing the import": "Import afronden", + "Importing": "Importeren", + "{processed} of {total} objects saved": "{processed} van {total} objecten opgeslagen", + "Loading contracts": "Contracten laden", + "No contracts found for this application": "Geen contracten gevonden voor deze applicatie", + "The contracts could not be loaded.": "De contracten konden niet worden geladen.", + "Usages": "Gebruik", + "No organisation registered a usage yet": "Nog geen organisatie heeft gebruik geregistreerd", + "Every connection between applications, and from an application to a national provision.": "Alle koppelingen tussen applicaties, en van een applicatie naar een landelijke voorziening.", + "In use": "In gebruik", + "In development": "In ontwikkeling", + "Applications and standards": "Applicaties en standaarden", + "Connections from this application": "Koppelingen vanuit deze applicatie", + "Connections to this application": "Koppelingen naar deze applicatie", + "To application": "Naar applicatie", + "To national provision": "Naar landelijke voorziening", + "From application": "Van applicatie", + "No connections start at this application": "Er beginnen geen koppelingen bij deze applicatie", + "No connections end at this application": "Er eindigen geen koppelingen bij deze applicatie", + "AI system": "AI-systeem", + "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "Een AI-agent, AI-model of AI-functie die de organisatie gebruikt, met de indeling onder de Europese AI-verordening.", + "The name the organisation uses for this AI system.": "De naam die de organisatie voor dit AI-systeem gebruikt.", + "What the AI system is and how it is used.": "Wat het AI-systeem is en hoe het wordt gebruikt.", + "Kind": "Soort", + "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "Een AI-agent handelt zelfstandig, een AI-model is een getraind model, een AI-functie is onderdeel van een applicatie.", + "AI agent": "AI-agent", + "AI model": "AI-model", + "AI feature": "AI-functie", + "The application this AI system runs in or supports.": "De applicatie waarin dit AI-systeem draait of die het ondersteunt.", + "The organisation that supplies the AI system.": "De organisatie die het AI-systeem levert.", + "Purpose": "Doel", + "What the AI system decides, recommends or produces.": "Wat het AI-systeem beslist, adviseert of maakt.", + "AI Act risk category": "Risicocategorie AI-verordening", + "The risk category under the EU AI Act, as the organisation classified it.": "De risicocategorie onder de Europese AI-verordening, zoals de organisatie die heeft vastgesteld.", + "Prohibited": "Verboden", + "High risk": "Hoog risico", + "Limited risk": "Beperkt risico", + "Minimal risk": "Minimaal risico", + "Not yet assessed": "Nog niet beoordeeld", + "Role under the AI Act": "Rol onder de AI-verordening", + "Whether the organisation provides the AI system or deploys it.": "Of de organisatie het AI-systeem aanbiedt of gebruikt.", + "Deployer": "Gebruiksverantwoordelijke", + "Algorithm register entry": "Vermelding in het algoritmeregister", + "The link to this system in the Dutch algorithm register.": "De link naar dit systeem in het Algoritmeregister van de Nederlandse overheid.", + "Last assessed on": "Laatst beoordeeld op", + "The date the classification was last assessed.": "De datum waarop de indeling voor het laatst is beoordeeld.", + "Fundamental rights impact assessment": "Grondrechteneffectbeoordeling", + "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "Een verwijzing naar de grondrechteneffectbeoordeling (FRIA). Een systeem met hoog risico zonder beoordeling krijgt een waarschuwing.", + "Where the AI system stands in its lifecycle.": "Waar het AI-systeem staat in zijn levenscyclus.", + "AI Act evidence": "Bewijs voor de AI-verordening", + "Attach a document under Documents and give it the matching tag.": "Voeg een document toe onder Documenten en geef het de passende tag.", + "FRIA missing": "FRIA ontbreekt", + "Fundamental rights impact assessment (FRIA)": "Grondrechteneffectbeoordeling (FRIA)", + "Human oversight": "Menselijk toezicht", + "Loading the evidence": "Het bewijs wordt geladen", + "Logging": "Logging", + "Missing": "Ontbreekt", + "Technical documentation": "Technische documentatie", + "The evidence could not be loaded.": "Het bewijs kon niet worden geladen.", + "This is a high-risk AI system without a fundamental rights impact assessment.": "Dit is een AI-systeem met hoog risico zonder grondrechteneffectbeoordeling.", + "AI systems": "AI-systemen", + "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "De AI-agents, AI-modellen en AI-functies die je organisatie gebruikt, met hun risicocategorie onder de Europese AI-verordening.", + "FRIA": "FRIA", + "High risk without FRIA": "Hoog risico zonder FRIA", + "Application and supplier": "Applicatie en leverancier", + "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie", + "Application in use": "Applicatie in gebruik", + "To be phased out": "Uit te faseren", + "Business owner": "Functioneel eigenaar", + "Technical owner": "Technisch eigenaar", + "The person in the organisation who is responsible for how the application is used.": "De persoon in de organisatie die verantwoordelijk is voor hoe de applicatie wordt gebruikt.", + "The person in the organisation who is responsible for running and maintaining the application.": "De persoon in de organisatie die verantwoordelijk is voor het draaien en onderhouden van de applicatie.", + "Add to our landscape": "Toevoegen aan ons landschap", + "Connections and services": "Koppelingen en diensten", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.", + "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie", + "Maintenance window": "Onderhoudsvenster", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Onderhoud dat een leverancier plant op een van zijn producten, met het tijdvenster en de verwachte impact.", + "The product the maintenance is on.": "Het product waarop het onderhoud plaatsvindt.", + "The version the maintenance is on, when it concerns one version.": "De versie waarop het onderhoud plaatsvindt, als het om één versie gaat.", + "What the maintenance is, in a few words.": "Wat het onderhoud is, in een paar woorden.", + "What changes and what users should do.": "Wat er verandert en wat gebruikers moeten doen.", + "Starts at": "Begint op", + "When the maintenance starts.": "Wanneer het onderhoud begint.", + "Ends at": "Eindigt op", + "When the maintenance ends.": "Wanneer het onderhoud eindigt.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "Wat gebruikers merken zolang het onderhoud loopt.", + "Where the maintenance stands.": "Waar het onderhoud staat.", + "Owners to notify": "Te informeren eigenaren", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "De Nextcloud-gebruikers die eigenaar zijn van een gebruik van het product, bepaald bij de aankondiging van het onderhoud.", + "Owners resolved at": "Eigenaren bepaald op", + "When the owners to notify were resolved.": "Wanneer de te informeren eigenaren zijn bepaald.", + "No impact": "Geen impact", + "Degraded": "Verminderd", + "Unavailable": "Niet beschikbaar", + "In progress": "Bezig", + "Completed": "Afgerond", + "Cancelled": "Geannuleerd", + "Start the maintenance.": "Start het onderhoud.", + "Complete the maintenance.": "Rond het onderhoud af.", + "Cancel the maintenance.": "Annuleer het onderhoud.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "De richting die de leverancier met deze applicatie kiest: wat de volgende versies brengen en wanneer.", + "Planned maintenance": "Gepland onderhoud", + "Announce maintenance": "Onderhoud aankondigen", + "No maintenance planned on this application": "Geen onderhoud gepland op deze applicatie", + "Planned releases": "Geplande releases", + "Loading planned maintenance": "Gepland onderhoud laden", + "The planned maintenance could not be loaded.": "Het geplande onderhoud kon niet worden geladen.", + "No maintenance planned on the applications you use in the next 30 days": "Geen onderhoud gepland op de applicaties die u gebruikt in de komende 30 dagen", + "{start} to {end}": "{start} tot {end}", + "Loading the roadmap": "Roadmap laden", + "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.", + "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd", + "No versions with a date yet": "Nog geen versies met een datum", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: bedrijfswaarde {value}, technische geschiktheid {fit}, jaarlijkse kosten {cost}", + "%n application in use is not scored yet.": "%n applicatie in gebruik heeft nog geen score.", + "%n applications in use are not scored yet.": "%n applicaties in gebruik hebben nog geen score.", + "%n vulnerability linked to this application": "%n kwetsbaarheid gekoppeld aan deze applicatie", + "%n vulnerabilities linked to this application": "%n kwetsbaarheden gekoppeld aan deze applicatie", + "Business value": "Bedrijfswaarde", + "Business value against technical fit": "Bedrijfswaarde tegenover technische geschiktheid", + "Business value against technical fit for %n scored application in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicatie in gebruik met een score", + "Business value against technical fit for %n scored applications in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicaties in gebruik met een score", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Elke cirkel is een applicatie in gebruik, de grootte staat voor de jaarlijkse kosten. Een donkere rand markeert een vastgelegde TIME-klasse die afwijkt van de scores.", + "Known vulnerabilities": "Bekende kwetsbaarheden", + "Loading the risk signals": "Risicosignalen laden", + "No end of support date known": "Geen datum voor einde ondersteuning bekend", + "Not scored": "Geen score", + "Passed on {date}": "Verlopen op {date}", + "Recorded class differs from scores": "Vastgelegde klasse wijkt af van de scores", + "Risk score": "Risicoscore", + "Risk signals": "Risicosignalen", + "Suggested by scores": "Voorgesteld door de scores", + "Supported until {date}": "Ondersteund tot {date}", + "Technical fit": "Technische geschiktheid", + "The risk signals could not be loaded.": "De risicosignalen konden niet worden geladen.", + "This version was withdrawn": "Deze versie is ingetrokken", + "Value / fit / risk": "Waarde / geschiktheid / risico", + "Value assessment": "Waardebeoordeling", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "Hoe sterk de organisatie van deze applicatie afhangt, van 1 (weinig) tot 5 (kritiek).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "Hoe goed de applicatie past bij de architectuur en de standaarden die de organisatie volgt, van 1 (slecht) tot 5 (goed).", + "Risk": "Risico", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "Het risico dat de organisatie ziet in het gebruik van deze applicatie, van 1 (laag) tot 5 (hoog). De pagina toont het einde van de ondersteuning en bekende kwetsbaarheden ernaast.", + "Scored on": "Gescoord op", + "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.", + "Suggested TIME classification": "Voorgestelde TIME-classificatie", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 0848195e0..f84eed6e2 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,27 @@ { "translations": { + "Licence metric": "Licentiemetriek", + "What one licence covers, as the contract with the supplier states it.": "Waar één licentie voor geldt, zoals het contract met de leverancier het vastlegt.", + "Licences bought": "Gekochte licenties", + "The number of licences this contract buys.": "Het aantal licenties dat dit contract koopt.", + "Licences in use": "Licenties in gebruik", + "The number of licences in use today, as the application owner counted them.": "Het aantal licenties dat vandaag in gebruik is, zoals de applicatie-eigenaar ze telde.", + "Per named user": "Per benoemde gebruiker", + "Per concurrent user": "Per gelijktijdige gebruiker", + "Per device": "Per apparaat", + "Per inhabitant": "Per inwoner", + "Per organisation": "Per organisatie", + "Other": "Overig", + "Licences": "Licenties", + "Loading licences": "Licenties laden", + "The licences could not be loaded.": "De licenties konden niet worden geladen.", + "Last changed on {date}": "Laatst gewijzigd op {date}", + "{inUse} of {bought} in use": "{inUse} van {bought} in gebruik", + "Within licence": "Binnen de licentie", + "Over licence by {count}": "{count} boven de licentie", + "Not counted": "Niet geteld", + "Seats": "Licentiegebruik", + "No licence contracts with counts": "Geen licentiecontracten met aantallen", "Load example data?": "Voorbeeldgegevens laden?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", @@ -776,6 +798,178 @@ "xmlns": "xmlns", "xsi": "xsi", "Pre-filled with the names the Integriq endoflife-date-source change provisions. Change them if your instance uses different names, no code change required.": "Vooraf ingevuld met de namen die de Integriq-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt, geen codewijziging nodig.", - "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?" + "the configured register or schema could not be found. Is the Integriq endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden. Is de Integriq-wijziging endoflife-date-source geïnstalleerd?", + "Integrations": "Koppelingen", + "Status message": "Statusbericht", + "Last checked": "Laatst gecontroleerd", + "All connections": "Alle verbindingen", + "Add integration": "Integratie toevoegen", + "Open settings": "Instellingen openen", + "Configured": "Ingericht", + "Limited": "Beperkt", + "Not configured": "Niet geconfigureerd", + "Simulated": "Gesimuleerd", + "Not available": "Niet beschikbaar", + "Error": "Fout", + "Settings": "Instellingen", + "Applications supplied": "Geleverde applicaties", + "Services supplied": "Geleverde diensten", + "Applications managed": "Beheerde applicaties", + "Services managed": "Beheerde diensten", + "Cancel import": "Import annuleren", + "Import cancelled. {count} objects were saved before it stopped.": "Import geannuleerd. {count} objecten waren al opgeslagen toen hij stopte.", + "Checking the file": "Bestand controleren", + "Reading the model": "Model inlezen", + "Converting the model": "Model omzetten", + "Saving objects": "Objecten opslaan", + "Finishing the import": "Import afronden", + "Importing": "Importeren", + "{processed} of {total} objects saved": "{processed} van {total} objecten opgeslagen", + "Loading contracts": "Contracten laden", + "No contracts found for this application": "Geen contracten gevonden voor deze applicatie", + "The contracts could not be loaded.": "De contracten konden niet worden geladen.", + "Usages": "Gebruik", + "No organisation registered a usage yet": "Nog geen organisatie heeft gebruik geregistreerd", + "Every connection between applications, and from an application to a national provision.": "Alle koppelingen tussen applicaties, en van een applicatie naar een landelijke voorziening.", + "In use": "In gebruik", + "In development": "In ontwikkeling", + "Applications and standards": "Applicaties en standaarden", + "Connections from this application": "Koppelingen vanuit deze applicatie", + "Connections to this application": "Koppelingen naar deze applicatie", + "To application": "Naar applicatie", + "To national provision": "Naar landelijke voorziening", + "From application": "Van applicatie", + "No connections start at this application": "Er beginnen geen koppelingen bij deze applicatie", + "No connections end at this application": "Er eindigen geen koppelingen bij deze applicatie", + "AI system": "AI-systeem", + "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.": "Een AI-agent, AI-model of AI-functie die de organisatie gebruikt, met de indeling onder de Europese AI-verordening.", + "The name the organisation uses for this AI system.": "De naam die de organisatie voor dit AI-systeem gebruikt.", + "What the AI system is and how it is used.": "Wat het AI-systeem is en hoe het wordt gebruikt.", + "Kind": "Soort", + "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.": "Een AI-agent handelt zelfstandig, een AI-model is een getraind model, een AI-functie is onderdeel van een applicatie.", + "AI agent": "AI-agent", + "AI model": "AI-model", + "AI feature": "AI-functie", + "The application this AI system runs in or supports.": "De applicatie waarin dit AI-systeem draait of die het ondersteunt.", + "The organisation that supplies the AI system.": "De organisatie die het AI-systeem levert.", + "Purpose": "Doel", + "What the AI system decides, recommends or produces.": "Wat het AI-systeem beslist, adviseert of maakt.", + "AI Act risk category": "Risicocategorie AI-verordening", + "The risk category under the EU AI Act, as the organisation classified it.": "De risicocategorie onder de Europese AI-verordening, zoals de organisatie die heeft vastgesteld.", + "Prohibited": "Verboden", + "High risk": "Hoog risico", + "Limited risk": "Beperkt risico", + "Minimal risk": "Minimaal risico", + "Not yet assessed": "Nog niet beoordeeld", + "Role under the AI Act": "Rol onder de AI-verordening", + "Whether the organisation provides the AI system or deploys it.": "Of de organisatie het AI-systeem aanbiedt of gebruikt.", + "Deployer": "Gebruiksverantwoordelijke", + "Algorithm register entry": "Vermelding in het algoritmeregister", + "The link to this system in the Dutch algorithm register.": "De link naar dit systeem in het Algoritmeregister van de Nederlandse overheid.", + "Last assessed on": "Laatst beoordeeld op", + "The date the classification was last assessed.": "De datum waarop de indeling voor het laatst is beoordeeld.", + "Fundamental rights impact assessment": "Grondrechteneffectbeoordeling", + "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.": "Een verwijzing naar de grondrechteneffectbeoordeling (FRIA). Een systeem met hoog risico zonder beoordeling krijgt een waarschuwing.", + "Where the AI system stands in its lifecycle.": "Waar het AI-systeem staat in zijn levenscyclus.", + "AI Act evidence": "Bewijs voor de AI-verordening", + "Attach a document under Documents and give it the matching tag.": "Voeg een document toe onder Documenten en geef het de passende tag.", + "FRIA missing": "FRIA ontbreekt", + "Fundamental rights impact assessment (FRIA)": "Grondrechteneffectbeoordeling (FRIA)", + "Human oversight": "Menselijk toezicht", + "Loading the evidence": "Het bewijs wordt geladen", + "Logging": "Logging", + "Missing": "Ontbreekt", + "Technical documentation": "Technische documentatie", + "The evidence could not be loaded.": "Het bewijs kon niet worden geladen.", + "This is a high-risk AI system without a fundamental rights impact assessment.": "Dit is een AI-systeem met hoog risico zonder grondrechteneffectbeoordeling.", + "AI systems": "AI-systemen", + "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.": "De AI-agents, AI-modellen en AI-functies die je organisatie gebruikt, met hun risicocategorie onder de Europese AI-verordening.", + "FRIA": "FRIA", + "High risk without FRIA": "Hoog risico zonder FRIA", + "Application and supplier": "Applicatie en leverancier", + "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie", + "Application in use": "Applicatie in gebruik", + "To be phased out": "Uit te faseren", + "Business owner": "Functioneel eigenaar", + "Technical owner": "Technisch eigenaar", + "The person in the organisation who is responsible for how the application is used.": "De persoon in de organisatie die verantwoordelijk is voor hoe de applicatie wordt gebruikt.", + "The person in the organisation who is responsible for running and maintaining the application.": "De persoon in de organisatie die verantwoordelijk is voor het draaien en onderhouden van de applicatie.", + "Add to our landscape": "Toevoegen aan ons landschap", + "Connections and services": "Koppelingen en diensten", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.", + "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie", + "Maintenance window": "Onderhoudsvenster", + "Maintenance a supplier plans on one of its products, with the time window and the expected impact.": "Onderhoud dat een leverancier plant op een van zijn producten, met het tijdvenster en de verwachte impact.", + "The product the maintenance is on.": "Het product waarop het onderhoud plaatsvindt.", + "The version the maintenance is on, when it concerns one version.": "De versie waarop het onderhoud plaatsvindt, als het om één versie gaat.", + "What the maintenance is, in a few words.": "Wat het onderhoud is, in een paar woorden.", + "What changes and what users should do.": "Wat er verandert en wat gebruikers moeten doen.", + "Starts at": "Begint op", + "When the maintenance starts.": "Wanneer het onderhoud begint.", + "Ends at": "Eindigt op", + "When the maintenance ends.": "Wanneer het onderhoud eindigt.", + "Impact": "Impact", + "What users notice while the maintenance runs.": "Wat gebruikers merken zolang het onderhoud loopt.", + "Where the maintenance stands.": "Waar het onderhoud staat.", + "Owners to notify": "Te informeren eigenaren", + "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.": "De Nextcloud-gebruikers die eigenaar zijn van een gebruik van het product, bepaald bij de aankondiging van het onderhoud.", + "Owners resolved at": "Eigenaren bepaald op", + "When the owners to notify were resolved.": "Wanneer de te informeren eigenaren zijn bepaald.", + "No impact": "Geen impact", + "Degraded": "Verminderd", + "Unavailable": "Niet beschikbaar", + "In progress": "Bezig", + "Completed": "Afgerond", + "Cancelled": "Geannuleerd", + "Start the maintenance.": "Start het onderhoud.", + "Complete the maintenance.": "Rond het onderhoud af.", + "Cancel the maintenance.": "Annuleer het onderhoud.", + "Roadmap": "Roadmap", + "The direction the supplier takes with this application: what the next versions bring and when.": "De richting die de leverancier met deze applicatie kiest: wat de volgende versies brengen en wanneer.", + "Planned maintenance": "Gepland onderhoud", + "Announce maintenance": "Onderhoud aankondigen", + "No maintenance planned on this application": "Geen onderhoud gepland op deze applicatie", + "Planned releases": "Geplande releases", + "Loading planned maintenance": "Gepland onderhoud laden", + "The planned maintenance could not be loaded.": "Het geplande onderhoud kon niet worden geladen.", + "No maintenance planned on the applications you use in the next 30 days": "Geen onderhoud gepland op de applicaties die u gebruikt in de komende 30 dagen", + "{start} to {end}": "{start} tot {end}", + "Loading the roadmap": "Roadmap laden", + "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.", + "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd", + "No versions with a date yet": "Nog geen versies met een datum", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: bedrijfswaarde {value}, technische geschiktheid {fit}, jaarlijkse kosten {cost}", + "%n application in use is not scored yet.": "%n applicatie in gebruik heeft nog geen score.", + "%n applications in use are not scored yet.": "%n applicaties in gebruik hebben nog geen score.", + "%n vulnerability linked to this application": "%n kwetsbaarheid gekoppeld aan deze applicatie", + "%n vulnerabilities linked to this application": "%n kwetsbaarheden gekoppeld aan deze applicatie", + "Business value": "Bedrijfswaarde", + "Business value against technical fit": "Bedrijfswaarde tegenover technische geschiktheid", + "Business value against technical fit for %n scored application in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicatie in gebruik met een score", + "Business value against technical fit for %n scored applications in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicaties in gebruik met een score", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Elke cirkel is een applicatie in gebruik, de grootte staat voor de jaarlijkse kosten. Een donkere rand markeert een vastgelegde TIME-klasse die afwijkt van de scores.", + "Known vulnerabilities": "Bekende kwetsbaarheden", + "Loading the risk signals": "Risicosignalen laden", + "No end of support date known": "Geen datum voor einde ondersteuning bekend", + "Not scored": "Geen score", + "Passed on {date}": "Verlopen op {date}", + "Recorded class differs from scores": "Vastgelegde klasse wijkt af van de scores", + "Risk score": "Risicoscore", + "Risk signals": "Risicosignalen", + "Suggested by scores": "Voorgesteld door de scores", + "Supported until {date}": "Ondersteund tot {date}", + "Technical fit": "Technische geschiktheid", + "The risk signals could not be loaded.": "De risicosignalen konden niet worden geladen.", + "This version was withdrawn": "Deze versie is ingetrokken", + "Value / fit / risk": "Waarde / geschiktheid / risico", + "Value assessment": "Waardebeoordeling", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "Hoe sterk de organisatie van deze applicatie afhangt, van 1 (weinig) tot 5 (kritiek).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "Hoe goed de applicatie past bij de architectuur en de standaarden die de organisatie volgt, van 1 (slecht) tot 5 (goed).", + "Risk": "Risico", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "Het risico dat de organisatie ziet in het gebruik van deze applicatie, van 1 (laag) tot 5 (hoog). De pagina toont het einde van de ondersteuning en bekende kwetsbaarheden ernaast.", + "Scored on": "Gescoord op", + "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.", + "Suggested TIME classification": "Voorgestelde TIME-classificatie", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit." } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 672b1cdec..57e9303b2 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -32,6 +32,7 @@ use OCA\Stackiq\Controller\ContactpersonenController; use OCA\Stackiq\Dashboard\ConceptOrganisatiesWidget; use OCA\Stackiq\EventListener\DecisionConcludedListener; +use OCA\Stackiq\EventListener\MaintenanceRecipientsListener; use OCA\Stackiq\EventListener\ModuleComplianceSubscriber; use OCA\Stackiq\EventListener\ModuleRegistrationSubscriber; use OCA\Stackiq\EventListener\TestEventListener; @@ -39,6 +40,7 @@ use OCA\Stackiq\Service\ArchiMateExportService; use OCA\Stackiq\Service\ArchiMateImportService; use OCA\Stackiq\Service\ArchiMateService; +use OCA\Stackiq\Service\ConnectionReportService; use OCA\Stackiq\Service\ContactpersoonService; use OCA\Stackiq\Service\ContractApprovalService; use OCA\Stackiq\Service\ContractStatusService; @@ -515,14 +517,14 @@ function ($container) { function ($container) { return new ArchiMateImportService( config: $container->get(IAppConfig::class), - rootFolder: $container->get('OCP\Files\IRootFolder'), userSession: $container->get('OCP\IUserSession'), appManager: $container->get('OCP\App\IAppManager'), container: $container, logger: $container->get('Psr\Log\LoggerInterface'), settingsService: $container->get(SettingsService::class), organisationService: $container->get(OpenRegisterOrganisationService::class), - dbConnection: $container->get(IDBConnection::class) + dbConnection: $container->get(IDBConnection::class), + progressTracker: $container->get(ProgressTracker::class) ); } ); @@ -543,14 +545,14 @@ function ($container) { function ($container) { return new ArchiMateService( config: $container->get(IAppConfig::class), - rootFolder: $container->get('OCP\Files\IRootFolder'), userSession: $container->get('OCP\IUserSession'), appManager: $container->get('OCP\App\IAppManager'), container: $container, logger: $container->get('Psr\Log\LoggerInterface'), settingsService: $container->get(SettingsService::class), importService: $container->get(ArchiMateImportService::class), - exportService: $container->get(ArchiMateExportService::class) + exportService: $container->get(ArchiMateExportService::class), + progressTracker: $container->get(ProgressTracker::class) ); } ); @@ -593,7 +595,8 @@ function ($container) { ProgressTracker::class, function ($container) { return new ProgressTracker( - session: $container->get('OCP\ISession'), + cacheFactory: $container->get(ICacheFactory::class), + userSession: $container->get('OCP\IUserSession'), logger: $container->get('Psr\Log\LoggerInterface') ); } @@ -674,7 +677,11 @@ function ($container) { config: $container->get(FederationConfig::class), merger: $container->get(FederationMerger::class), settingsService: $container->get(SettingsService::class), - logger: $container->get(LoggerInterface::class) + logger: $container->get(LoggerInterface::class), + // The integriq connection report (adopt-connection-registry). Passed by + // name: this factory is hand-built, so the constructor default of null + // would otherwise switch every federation report off without a sound. + connectionReports: $container->get(ConnectionReportService::class) ); } ); @@ -706,7 +713,9 @@ function ($container) { settingsService: $container->get(SettingsService::class), matcher: $container->get(EolMatcherService::class), timeFactory: $container->get('OCP\AppFramework\Utility\ITimeFactory'), - logger: $container->get(LoggerInterface::class) + logger: $container->get(LoggerInterface::class), + // Same reason as the FederationService factory above. + connectionReports: $container->get(ConnectionReportService::class) ); } ); @@ -799,6 +808,9 @@ private function registerEventListeners(IRegistrationContext $context): void { $context->registerEventListener(ObjectCreatedEvent::class, ModuleRegistrationSubscriber::class); $context->registerEventListener(ObjectUpdatedEvent::class, ModuleRegistrationSubscriber::class); + // Queue the owner resolution when a supplier announces maintenance (lifecycle-maintenance-and-supplier-roadmap). + $context->registerEventListener(ObjectCreatedEvent::class, MaintenanceRecipientsListener::class); + // Sync user profile updates into the contactpersoon mirror. $context->registerEventListener(UserProfileUpdatedEvent::class, UserProfileUpdatedEventListener::class); diff --git a/lib/BackgroundJob/MaintenanceRecipientsJob.php b/lib/BackgroundJob/MaintenanceRecipientsJob.php new file mode 100644 index 000000000..4798ea300 --- /dev/null +++ b/lib/BackgroundJob/MaintenanceRecipientsJob.php @@ -0,0 +1,69 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\BackgroundJob; + +use OCA\Stackiq\Service\MaintenanceRecipientService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; + +/** + * One owner resolution for one maintenance window. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ +class MaintenanceRecipientsJob extends QueuedJob { + + /** + * Constructor. + * + * @param ITimeFactory $time The time factory. + * @param MaintenanceRecipientService $recipients The owner resolution. + */ + public function __construct( + ITimeFactory $time, + private readonly MaintenanceRecipientService $recipients, + ) { + parent::__construct(time: $time); + }//end __construct() + + /** + * Resolve and record the owners for the window in the argument. + * + * @param mixed $argument `{uuid, register, schema}` of the window. + * + * @return void + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + protected function run($argument): void { + if (is_array($argument) === false || is_string($argument['uuid'] ?? null) === false) { + return; + } + + $this->recipients->recordRecipientsFor( + uuid: $argument['uuid'], + register: ($argument['register'] ?? null), + schema: ($argument['schema'] ?? null) + ); + }//end run() +}//end class diff --git a/lib/BackgroundJob/OrganizationContactSyncJob.php b/lib/BackgroundJob/OrganizationContactSyncJob.php index 575107af6..880a34679 100644 --- a/lib/BackgroundJob/OrganizationContactSyncJob.php +++ b/lib/BackgroundJob/OrganizationContactSyncJob.php @@ -46,6 +46,13 @@ */ class OrganizationContactSyncJob extends TimedJob { + /** + * The id this job's switch is stored under in `cronjob_config`. + * + * @var string + */ + public const JOB_ID = 'organization_contact_sync'; + /** * Organization synchronization service * @@ -102,6 +109,13 @@ protected function run($argument): void { return; } + // An admin can switch the job off in the cronjob settings (for example + // during a migration); then neither the sync nor the link refresh runs. + if ($this->settingsService->isCronjobEnabled(jobId: self::JOB_ID) === false) { + $this->logger->info('[OrganizationContactSyncJob] Switched off in the cronjob settings, skipping sync'); + return; + } + $this->logger->info('[OrganizationContactSyncJob] Starting scheduled organization sync'); try { $this->orgSyncService->performScheduledSync(); diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 2064ade21..9731ba69a 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -26,7 +26,9 @@ use OCA\OpenRegister\Contract\ObjectServiceInterface; use OCA\OpenRegister\Service\ConfigurationService; +use OCA\Stackiq\Service\ArchiMateImportService; use OCA\Stackiq\Service\ArchiMateService; +use OCA\Stackiq\Service\ConnectionReportService; use OCA\Stackiq\Service\EolSyncService; use OCA\Stackiq\Service\OrganizationSyncService; use OCA\Stackiq\Service\ProgressTracker; @@ -38,6 +40,7 @@ use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\StreamResponse; use OCP\IAppConfig; +use OCP\IConfig; use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -84,8 +87,11 @@ class SettingsController extends Controller { * @param ProgressTracker $progressTracker The progress tracking service. * @param EolSyncService $eolSyncService The EOL feed sync orchestration service. * @param LoggerInterface $logger The logger instance. + * @param ConnectionReportService|null $connectionReports Asks integriq to look again after an email settings save. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function __construct( $appName, @@ -101,6 +107,7 @@ public function __construct( private readonly ProgressTracker $progressTracker, private readonly EolSyncService $eolSyncService, private readonly LoggerInterface $logger, + private readonly ?ConnectionReportService $connectionReports = null, ) { parent::__construct(appName: $appName, request: $request); @@ -431,9 +438,14 @@ private function updateUserGroupSettings(array $data, array &$result): ?JSONResp * @param array $data The raw request params. * @param array $result The result accumulator (passed by reference). * + * After the write it asks integriq to resolve the email connection again + * (adopt-connection-registry). That never throws, does nothing without + * integriq, and never changes the response. + * * @return void * * @spec openspec/changes/method-decomposition/tasks.md#task-3 + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ private function applyEmailSettingsUpdate(array $data, array &$result): void { if (isset($data['emailSettings']) === false) { @@ -441,6 +453,7 @@ private function applyEmailSettingsUpdate(array $data, array &$result): void { } $result['emailSettings'] = $this->settingsService->updateEmailSettings($data['emailSettings']); + $this->connectionReports?->emailSettingsSaved(); }//end applyEmailSettingsUpdate() @@ -1273,7 +1286,7 @@ public function consolidatedAutoConfigure(): JSONResponse { * @NoAdminRequired * @NoCSRFRequired * - * @spec openspec/specs/method-decomposition/spec.md + * @spec openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it */ public function getProgress(string $operationId): JSONResponse { $currentUser = $this->userSession->getUser(); @@ -1284,19 +1297,11 @@ public function getProgress(string $operationId): JSONResponse { try { $progress = $this->progressTracker->getProgress($operationId); - if ($progress === null) { - return new JSONResponse( - [ - 'success' => false, - 'message' => 'Operation not found', - 'error' => 'OPERATION_NOT_FOUND', - ], - 404 - ); - } - - // Verify the caller owns this operation. - if (isset($progress['owner_uid']) === true && $progress['owner_uid'] !== $currentUser->getUID()) { + // An operation the caller may not read answers like an unknown + // one, so an operation id alone reveals nothing. + if ($progress === null + || $this->mayReadProgress(progress: $progress, uid: $currentUser->getUID()) === false + ) { return new JSONResponse( [ 'success' => false, @@ -1333,6 +1338,30 @@ public function getProgress(string $operationId): JSONResponse { }//end try }//end getProgress() + /** + * Whether a user may read the progress of an operation. + * + * Progress lives in the distributed cache, so any request can load an + * operation by its id. Its owner and Nextcloud admins may read it. An + * operation without an owner, such as one a background job started, is + * for admins only. + * + * @param array $progress The stored progress snapshot. + * @param string $uid The signed-in caller. + * + * @return bool True when the caller may read the operation. + * + * @spec openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it + */ + private function mayReadProgress(array $progress, string $uid): bool { + $ownerUid = $progress['owner_uid'] ?? null; + if ($ownerUid !== null && $ownerUid === $uid) { + return true; + } + + return $this->groupManager->isAdmin($uid) === true; + }//end mayReadProgress() + /** * Stream progress updates using Server-Sent Events. * @@ -1352,9 +1381,12 @@ public function streamProgress(string $operationId): Response { return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); } - // Verify the caller owns this operation before streaming. + // Verify the caller may read this operation before streaming. An + // unknown operation streams one error event and closes. $progress = $this->progressTracker->getProgress($operationId); - if ($progress !== null && isset($progress['owner_uid']) === true && $progress['owner_uid'] !== $currentUser->getUID()) { + if ($progress !== null + && $this->mayReadProgress(progress: $progress, uid: $currentUser->getUID()) === false + ) { return new JSONResponse(['message' => 'Operation not found', 'error' => 'OPERATION_NOT_FOUND'], 404); } @@ -1563,6 +1595,7 @@ private function parseArchiMateFileUpload(): ?array { 'fileName' => $fileData['name'], 'fileSize' => $fileData['size'] ?? filesize($fileData['tmp_name']), 'mimeType' => $fileData['type'] ?? 'text/xml', + 'operationId' => $this->request->getParam('operationId'), ]; }//end parseArchiMateFileUpload() @@ -1610,10 +1643,12 @@ public function exportArchiMate(): Response { // Same guard as the sibling exportOrgArchiMate(), which has carried it // all along. This endpoint exports the WHOLE register while the sibling // exports one organisation, so it was the broader of the two and the - // only one unguarded. @NoAdminRequired is kept deliberately: the helper - // grants organisation-admins as well as admins, which is the tier the - // admin UI relies on and which the annotation's removal would drop. - $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser); + // only one unguarded. It passes no organisation, so only a Nextcloud + // admin gets through: a member of an organisation admin group may + // export their own organisation only, through the sibling route + // (stackiq#1136). The `organization` body value is not a filter the + // export applies, so it cannot scope this route. + $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser, organizationUuid: null); if ($permissionError !== null) { return $permissionError; } @@ -1677,7 +1712,7 @@ public function exportOrgArchiMate(string $organizationUuid): Response { return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); } - $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser); + $permissionError = $this->verifyOrgExportPermission(currentUser: $currentUser, organizationUuid: $organizationUuid); if ($permissionError !== null) { return $permissionError; } @@ -1715,27 +1750,58 @@ public function exportOrgArchiMate(string $organizationUuid): Response { }//end exportOrgArchiMate() /** - * Verify that the current user has permission to export organisation ArchiMate files. + * Verify that the current user has permission to export an organisation's ArchiMate file. + * + * A Nextcloud admin may export anything. A member of one of the saved + * organisation admin groups may export only their own active organisation + * (user value `core`/`organisation`, the rule + * PortfolioReportController::isAuthorisedForOrganisation() applies), so a + * call without an organisation is admin-only. Before stackiq#1136 the group + * list always read empty and only admins got through; restoring the read + * without this scope would have let a group member export any + * organisation by uuid. * * @param \OCP\IUser $currentUser The currently authenticated user. + * @param string|null $organizationUuid The organisation asked for, or null for the whole register. * * @return JSONResponse|null Forbidden response, or null when permitted. * * @spec openspec/changes/method-decomposition/tasks.md#task-3 */ - private function verifyOrgExportPermission(\OCP\IUser $currentUser): ?JSONResponse { + private function verifyOrgExportPermission(\OCP\IUser $currentUser, ?string $organizationUuid): ?JSONResponse { if ($this->groupManager->isAdmin($currentUser->getUID()) === true) { return null; } - $orgAdminGroups = $this->settingsService->getOrganizationAdminGroups(); - foreach ($orgAdminGroups as $groupName) { + $forbidden = new JSONResponse(['message' => 'Admin or organisation-admin privileges required'], Http::STATUS_FORBIDDEN); + if ($organizationUuid === null || $organizationUuid === '') { + return $forbidden; + } + + $isOrgAdmin = false; + foreach ($this->settingsService->getOrganizationAdminGroups() as $groupName) { if ($this->groupManager->isInGroup($currentUser->getUID(), $groupName) === true) { - return null; + $isOrgAdmin = true; + break; } } - return new JSONResponse(['message' => 'Admin or organisation-admin privileges required'], Http::STATUS_FORBIDDEN); + if ($isOrgAdmin === false) { + return $forbidden; + } + + $ownOrganisation = (string)$this->container->get(IConfig::class)->getUserValue( + userId: $currentUser->getUID(), + appName: 'core', + key: 'organisation', + default: '' + ); + + if ($ownOrganisation !== $organizationUuid) { + return $forbidden; + } + + return null; }//end verifyOrgExportPermission() /** @@ -2025,6 +2091,7 @@ public function getEmailSettings(): JSONResponse { * * @return JSONResponse Update result * @spec openspec/specs/settings-admin-controller/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function updateEmailSettings(): JSONResponse { $currentUser = $this->userSession->getUser(); @@ -2041,6 +2108,7 @@ public function updateEmailSettings(): JSONResponse { $emailSettings = $data['emailSettings'] ?? $data; $updatedSettings = $this->settingsService->updateEmailSettings($emailSettings); + $this->connectionReports?->emailSettingsSaved(); return new JSONResponse( [ @@ -2786,8 +2854,16 @@ public function cancelArchiMateImport(): JSONResponse { return new JSONResponse(['message' => 'Admin privileges required'], Http::STATUS_FORBIDDEN); } + $operationId = $this->request->getParam('operationId'); + if ($operationId !== null + && (is_string($operationId) === false + || preg_match(ArchiMateImportService::OPERATION_ID_PATTERN, $operationId) !== 1) + ) { + return new JSONResponse(['success' => false, 'message' => 'Invalid operation id'], Http::STATUS_BAD_REQUEST); + } + try { - $result = $this->settingsService->cancelArchiMateImport(); + $result = $this->settingsService->cancelArchiMateImport(operationId: $operationId); $message = 'ArchiMate import cancellation failed'; if ($result['cancelled'] === true) { $message = 'ArchiMate import cancellation succeeded'; @@ -2857,64 +2933,6 @@ public function clearArchiMateExportStatus(): JSONResponse { }//end try }//end clearArchiMateExportStatus() - // ===. - // ARCHIMATE TESTING METHODS. - // ===. - - /** - * Test ArchiMate round-trip functionality - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return JSONResponse Round-trip test result - * @spec openspec/specs/settings-admin-controller/spec.md - */ - public function testArchiMateRoundTrip(): JSONResponse { - if ($this->userSession->getUser() === null) { - return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->logger->info('Stackiq: ArchiMate round-trip test started'); - - // Call the ArchiMate service to perform round-trip test. - $result = $this->archiMateService->testRoundTrip(); - - $this->logger->info( - 'Stackiq: ArchiMate round-trip test completed', - [ - 'success' => $result['success'], - 'message' => $result['message'] ?? 'no message', - ] - ); - - return new JSONResponse( - [ - 'success' => $result['success'], - 'message' => $result['message'], - 'details' => $result['details'] ?? null, - 'statistics' => $result['statistics'] ?? null, - ] - ); - } catch (\Exception $e) { - $this->logger->error( - 'Stackiq: ArchiMate round-trip test failed', - [ - 'exception_class' => get_class($e), - 'exception_message' => $e->getMessage(), - ] - ); - return new JSONResponse( - [ - 'success' => false, - 'message' => 'Round-trip test failed: ' . $e->getMessage(), - ], - 500 - ); - }//end try - }//end testArchiMateRoundTrip() - /** * Get ArchiMate settings and status (without object counts for performance) * diff --git a/lib/EventListener/MaintenanceRecipientsListener.php b/lib/EventListener/MaintenanceRecipientsListener.php new file mode 100644 index 000000000..c9c05a859 --- /dev/null +++ b/lib/EventListener/MaintenanceRecipientsListener.php @@ -0,0 +1,93 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\EventListener; + +use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\Stackiq\BackgroundJob\MaintenanceRecipientsJob; +use OCA\Stackiq\Service\MaintenanceRecipientService; +use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * Queues the owner resolution for a newly created maintenance window. + * + * @template-implements IEventListener + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ +class MaintenanceRecipientsListener implements IEventListener { + + /** + * Constructor. + * + * @param MaintenanceRecipientService $recipients The schema check. + * @param IJobList $jobList The background job list. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly MaintenanceRecipientService $recipients, + private readonly IJobList $jobList, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle an object created event. + * + * @param Event $event The event. + * + * @return void + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function handle(Event $event): void { + if (($event instanceof ObjectCreatedEvent) === false) { + return; + } + + $object = $event->getObject(); + try { + if ($this->recipients->isMaintenanceWindow(object: $object) === false) { + return; + } + + $this->jobList->add( + MaintenanceRecipientsJob::class, + [ + 'uuid' => $object->getUuid(), + 'register' => $object->getRegister(), + 'schema' => $object->getSchema(), + ] + ); + } catch (\Throwable $e) { + $this->logger->error( + 'MaintenanceRecipientsListener: could not queue the owner resolution', + ['uuid' => $object->getUuid(), 'error' => $e->getMessage()] + ); + } + }//end handle() +}//end class diff --git a/lib/Service/ArchiMateImportService.php b/lib/Service/ArchiMateImportService.php index a1a4a1576..f3cc5e218 100644 --- a/lib/Service/ArchiMateImportService.php +++ b/lib/Service/ArchiMateImportService.php @@ -23,8 +23,8 @@ use OCA\OpenRegister\Contract\ObjectServiceInterface; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\OrganisationService; +use OCA\Stackiq\Service\ProgressTracker; use OCP\App\IAppManager; -use OCP\Files\IRootFolder; use OCP\IAppConfig; use OCP\IDBConnection; use OCP\IUserSession; @@ -148,6 +148,25 @@ class ArchiMateImportService { */ private ?array $lastSaveResult = null; + /** + * Operation ids a page may name for an import it follows. + */ + public const OPERATION_ID_PATTERN = '/^archimate_import_[A-Za-z0-9]{8,64}$/'; + + /** + * The progress operation of the running import, or null when it is not tracked. + * + * @var string|null + */ + private ?string $operationId = null; + + /** + * Whether the running import stopped on a cancel. + * + * @var boolean + */ + private bool $cancelled = false; + /** * Cached configuration values for performance optimization. * @@ -159,7 +178,6 @@ class ArchiMateImportService { * Constructor for ArchiMateImportService * * @param IAppConfig $config Nextcloud app configuration service - * @param IRootFolder $rootFolder Root folder service * @param IUserSession $userSession User session service * @param IAppManager $appManager App manager service * @param ContainerInterface $container PSR-11 container interface @@ -167,10 +185,10 @@ class ArchiMateImportService { * @param SettingsService $settingsService Settings service for AMEF configuration. * @param OrganisationService $organisationService Organisation service. * @param IDBConnection $dbConnection Database connection interface. + * @param ProgressTracker $progressTracker Progress store the page follows and cancels the import through. */ public function __construct( private readonly IAppConfig $config, - private readonly IRootFolder $rootFolder, private readonly IUserSession $userSession, private readonly IAppManager $appManager, private readonly ContainerInterface $container, @@ -178,6 +196,7 @@ public function __construct( private readonly SettingsService $settingsService, private readonly OrganisationService $organisationService, private readonly IDBConnection $dbConnection, + private readonly ProgressTracker $progressTracker, ) { }//end __construct() @@ -352,6 +371,8 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array $this->logger->info('GEMMA IMPORT DEBUG: Starting optimized import', $options); // Starting OPTIMIZED ArchiMate XML import. + $this->startTracking(options: $options); + try { // OPTIMIZATION: Cache all configuration once at start. $cacheStartTime = microtime(true); @@ -360,12 +381,19 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array // Cache initialization completed. // STEP 1: Parse XML to array (same as before). + $this->trackPhase(phase: 'validating'); $filePath = $this->validateArchiMateFile(options: $options); + $this->trackPhase(phase: 'parsing'); $parseStartTime = microtime(true); $xmlData = $this->parseArchiMateXml(filePath: $filePath); $parseTime = microtime(true) - $parseStartTime; + if ($this->cancelRequested() === true) { + $this->progressTracker->cancelOperation(); + return $this->cancelledResult(); + } + // PERFORMANCE OPTIMIZATION: Clean up memory after XML parsing. $memoryCleanupTime = 0; // PERFORMANCE_OPTIMIZATIONS['memory_cleanup'] is a class constant set @@ -380,6 +408,7 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array $modelIdentifierTime = microtime(true) - $modelIdStartTime; // STEP 3: Parse ALL objects in one go (like CSV import). + $this->trackPhase(phase: 'analyzing'); $transformStartTime = microtime(true); $allObjects = $this->transformArchiMateXmlToObjectsBatch( xmlData: $xmlData, @@ -387,6 +416,11 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array ); $transformTime = microtime(true) - $transformStartTime; + if ($this->cancelRequested() === true) { + $this->progressTracker->cancelOperation(); + return $this->cancelledResult(); + } + // Parsed and transformed all objects. // STEP 4: Single saveObjects() call (like CSV import). $saveStartTime = microtime(true); @@ -399,6 +433,12 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array $savedObjects = $this->saveObjectsToDatabase(objects: $allObjects); $saveTime = microtime(true) - $saveStartTime; + if ($this->cancelled === true) { + return $this->cancelledResult(); + } + + $this->trackPhase(phase: 'finalizing'); + // Capture detailed save timing from internal tracking. $saveBreakdown = $this->lastSaveTiming; @@ -410,6 +450,10 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array $statistics = $this->buildStatisticsFromSaveResult(); $detailedErrors = $this->extractDetailedErrors(statistics: $statistics); + if ($this->operationId !== null) { + $this->progressTracker->completeOperation(); + } + // OPTIMIZED import completed successfully. return [ 'success' => true, @@ -453,6 +497,11 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array ] ); + if ($this->operationId !== null) { + $this->progressTracker->addError(message: $e->getMessage()); + $this->progressTracker->completeOperation(); + } + return [ 'success' => false, 'error' => $e->getMessage(), @@ -1343,7 +1392,63 @@ private function saveObjectsToDatabase(array $objects): array { ] ); - // Process each schema group. + $groupResult = $this->saveSchemaGroups(schemaGroups: $schemaGroups, objectService: $objectService, registerId: $registerId); + $allResults = $groupResult['results']; + $aggregatedStats = $groupResult['stats']; + $countsBySchema = $aggregatedStats['countsBySchema']; + unset($aggregatedStats['countsBySchema']); + + // Store aggregated result for statistics, including per-schema counts. + $aggregatedStats['countsBySchema'] = $countsBySchema; + $this->lastSaveResult = $aggregatedStats; + $result = $allResults; + + $batchProcessingTime = microtime(true) - $batchStartTime; + + // POST-PROCESSING: Fix StandaardVersie standaard field UUIDs. + // The standaard field was set with ArchiMate identifiers, but we need database UUIDs. + // for the inversedBy lookup to work correctly. + $this->fixStandaardVersieUuids(registerId: $registerId); + + $totalSaveTime = microtime(true) - $saveStartTime; + + // Database save completed. + // Store timing breakdown for performance metrics. + // FIX: Use aggregatedStats counts instead of $result which may be empty from bulk operations. + $savedCount = count($aggregatedStats['saved'] ?? []); + $updatedCount = count($aggregatedStats['updated'] ?? []); + $unchangedCount = count($aggregatedStats['unchanged'] ?? []); + $totalSavedCount = $savedCount + $updatedCount + $unchangedCount; + if ($totalSavedCount > 0) { + $objectsSavedValue = $totalSavedCount; + } else { + $objectsSavedValue = count($objects); + } + + $this->lastSaveTiming = [ + 'total_save_seconds' => round($totalSaveTime, 3), + 'service_init_seconds' => round($serviceInitTime, 3), + 'gemma_processing_seconds' => round($gemmaProcessingTime, 3), + 'batch_processing_seconds' => round($batchProcessingTime, 3), + 'objects_saved' => $objectsSavedValue, + 'save_rate_objects_per_second' => round(count($objects) / max($totalSaveTime, 0.001), 1), + ]; + + return $result; + }//end saveObjectsToDatabase() + + /** + * Save the objects one schema group at a time, recording progress and honouring a cancel. + * + * @param array>> $schemaGroups Objects keyed by schema id + * @param ObjectServiceInterface $objectService OpenRegister's object service + * @param int $registerId The AMEF register id + * + * @return array{results: array, stats: array} The saved objects and the aggregated statistics + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-001-a-running-import-shall-record-its-phase-and-the-objects-saved-so-far + */ + private function saveSchemaGroups(array $schemaGroups, ObjectServiceInterface $objectService, int $registerId): array { $allResults = []; $aggregatedStats = [ 'saved' => [], @@ -1354,9 +1459,21 @@ private function saveObjectsToDatabase(array $objects): array { // Track counts per schema for accurate statistics (serialized objects lose the 'section' field). $countsBySchema = []; + $processed = 0; + $this->trackPhase( + phase: 'processing_elements', + data: ['total_items' => array_sum(array_map('count', $schemaGroups)), 'reset_progress' => true] + ); + foreach ($schemaGroups as $schemaId => $schemaObjects) { $schemaObjectCount = count($schemaObjects); + // A cancel stops the import before its next batch; what is saved stays saved. + if ($this->cancelRequested() === true) { + $this->progressTracker->cancelOperation(); + break; + } + try { // Save this schema group with the specific schema ID. // PERFORMANCE: Disabled validation and events for bulk import (like CSV import pattern). @@ -1416,46 +1533,105 @@ private function saveObjectsToDatabase(array $objects): array { ] ); }//end try + + $processed += $schemaObjectCount; + if ($this->operationId !== null) { + $this->progressTracker->updateProgress(processedItems: $processed, itemType: (string) $schemaId); + } }//end foreach - // Store aggregated result for statistics, including per-schema counts. $aggregatedStats['countsBySchema'] = $countsBySchema; - $this->lastSaveResult = $aggregatedStats; - $result = $allResults; - $batchProcessingTime = microtime(true) - $batchStartTime; + return ['results' => $allResults, 'stats' => $aggregatedStats]; + }//end saveSchemaGroups() - // POST-PROCESSING: Fix StandaardVersie standaard field UUIDs. - // The standaard field was set with ArchiMate identifiers, but we need database UUIDs. - // for the inversedBy lookup to work correctly. - $this->fixStandaardVersieUuids(registerId: $registerId); + /** + * Start recording progress when the upload names a well-formed operation id. + * + * An id outside the pattern is ignored, so a caller cannot write into another + * operation's keys; the import then runs without progress. + * + * @param array $options Import options, optionally with operationId + * + * @return void + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-001-a-running-import-shall-record-its-phase-and-the-objects-saved-so-far + */ + public function startTracking(array $options): void { + $this->operationId = null; + $this->cancelled = false; - $totalSaveTime = microtime(true) - $saveStartTime; + $operationId = $options['operationId'] ?? null; + if (is_string($operationId) === false || preg_match(self::OPERATION_ID_PATTERN, $operationId) !== 1) { + return; + } - // Database save completed. - // Store timing breakdown for performance metrics. - // FIX: Use aggregatedStats counts instead of $result which may be empty from bulk operations. - $savedCount = count($aggregatedStats['saved'] ?? []); - $updatedCount = count($aggregatedStats['updated'] ?? []); - $unchangedCount = count($aggregatedStats['unchanged'] ?? []); - $totalSavedCount = $savedCount + $updatedCount + $unchangedCount; - if ($totalSavedCount > 0) { - $objectsSavedValue = $totalSavedCount; - } else { - $objectsSavedValue = count($objects); + $this->operationId = $this->progressTracker->startOperation( + operationType: 'archimate_import', + operationId: $operationId + ); + }//end startTracking() + + /** + * Whether the last import stopped on a cancel. + * + * @return bool True when it was cancelled + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import + */ + public function wasCancelled(): bool { + return $this->cancelled; + }//end wasCancelled() + + /** + * Record the phase the import is in, when it is tracked. + * + * @param string $phase The ProgressTracker phase + * @param array $data Phase data such as total_items + * + * @return void + */ + private function trackPhase(string $phase, array $data = []): void { + if ($this->operationId !== null) { + $this->progressTracker->setPhase(phase: $phase, data: $data); } + }//end trackPhase() - $this->lastSaveTiming = [ - 'total_save_seconds' => round($totalSaveTime, 3), - 'service_init_seconds' => round($serviceInitTime, 3), - 'gemma_processing_seconds' => round($gemmaProcessingTime, 3), - 'batch_processing_seconds' => round($batchProcessingTime, 3), - 'objects_saved' => $objectsSavedValue, - 'save_rate_objects_per_second' => round(count($objects) / max($totalSaveTime, 0.001), 1), + /** + * Whether a cancel was requested for the tracked import; remembers a yes. + * + * @return bool True when the import must stop + */ + private function cancelRequested(): bool { + if ($this->operationId !== null && $this->progressTracker->isCancelRequested($this->operationId) === true) { + $this->cancelled = true; + } + + return $this->cancelled; + }//end cancelRequested() + + /** + * The result of an import that stopped on a cancel, with what it had saved. + * + * @return array The cancelled result + */ + private function cancelledResult(): array { + $stats = $this->lastSaveResult ?? []; + $counts = [ + 'saved' => count($stats['saved'] ?? []), + 'updated' => count($stats['updated'] ?? []), + 'unchanged' => count($stats['unchanged'] ?? []), + 'invalid' => count($stats['invalid'] ?? []), ]; - return $result; - }//end saveObjectsToDatabase() + return [ + 'success' => false, + 'cancelled' => true, + 'message' => 'Import cancelled', + 'counts' => $counts, + 'objects_saved' => $counts['saved'] + $counts['updated'] + $counts['unchanged'], + ]; + }//end cancelledResult() /** * Fix StandaardVersie standaard field UUIDs after import diff --git a/lib/Service/ArchiMateService.php b/lib/Service/ArchiMateService.php index e08c4617f..b65f1ff8a 100644 --- a/lib/Service/ArchiMateService.php +++ b/lib/Service/ArchiMateService.php @@ -24,7 +24,6 @@ use OCA\OpenRegister\Contract\ObjectServiceInterface; use OCA\OpenRegister\Service\ObjectService; use OCP\App\IAppManager; -use OCP\Files\IRootFolder; use OCP\IAppConfig; use OCP\IUserSession; use Psr\Container\ContainerInterface; @@ -157,7 +156,6 @@ class ArchiMateService { * Constructor for ArchiMateService * * @param IAppConfig $config Nextcloud app configuration service - * @param IRootFolder $rootFolder Root folder service * @param IUserSession $userSession User session service * @param IAppManager $appManager App manager service * @param ContainerInterface $container PSR-11 container interface @@ -165,10 +163,10 @@ class ArchiMateService { * @param SettingsService $settingsService Settings service for schema and organization configuration * @param ArchiMateImportService $importService Import service for XML parsing * @param ArchiMateExportService $exportService Export service for XML generation + * @param ProgressTracker $progressTracker Progress store a running import reads its cancel from */ public function __construct( private readonly IAppConfig $config, - private readonly IRootFolder $rootFolder, private readonly IUserSession $userSession, private readonly IAppManager $appManager, private readonly ContainerInterface $container, @@ -176,9 +174,49 @@ public function __construct( private readonly SettingsService $settingsService, private readonly ArchiMateImportService $importService, private readonly ArchiMateExportService $exportService, + private readonly ProgressTracker $progressTracker, ) { }//end __construct() + /** + * Cancel a running ArchiMate import. + * + * The import runs in another request, so this records a cancel that the import + * reads before its next save batch, and clears the stored import status. + * + * @param string|null $operationId The import's operation id, as the page named it + * + * @return array The cancellation result + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import + */ + public function cancelArchiMateImport(?string $operationId = null): array { + if ($operationId !== null && preg_match(ArchiMateImportService::OPERATION_ID_PATTERN, $operationId) !== 1) { + return [ + 'cancelled' => false, + 'operation_id' => null, + 'messages' => ['The operation id is not an ArchiMate import id'], + ]; + } + + $messages = []; + if ($operationId !== null) { + $this->progressTracker->setCancelRequested($operationId); + $messages[] = 'The import stops before its next save batch'; + } + + $this->config->deleteKey('stackiq', 'archimate_import_status'); + $messages[] = 'Import status cleared'; + + return [ + 'cancelled' => true, + 'operation_id' => $operationId, + 'status_cleared' => true, + 'cancellation_time' => date('Y-m-d H:i:s'), + 'messages' => $messages, + ]; + }//end cancelArchiMateImport() + /** * OPTIMIZED: Import ArchiMate XML file using OpenRegister-style performance optimization * @@ -1487,107 +1525,6 @@ private function getSchemaIdForSection(string $section): int { return $schemaId; }//end getSchemaIdForSection() - /** - * Test round-trip functionality - * - * @return array Test results - * @spec openspec/specs/archimate-import/spec.md - */ - public function testRoundTrip(): array { - $this->logger->info('Testing ArchiMate round-trip functionality'); - - try { - // Create test XML. - $testXml = $this->createTestArchiMateXml(); - - // Import. - $importResult = $this->importArchiMateFileFromPath( - options: [ - 'file_path' => $this->createTempFile(content: $testXml), - ] - ); - - if ($importResult['success'] === false) { - return [ - 'success' => false, - 'error' => 'Import failed: ' . $importResult['error'], - ]; - } - - // Export. - $exportResult = $this->exportToArchiMate(); - - if ($exportResult['success'] === false) { - return [ - 'success' => false, - 'error' => 'Export failed: ' . $exportResult['error'], - ]; - } - - // Compare (simplified comparison). - $importedCount = $importResult['imported_count']; - $exportedCount = $exportResult['exported_count']; - - $success = $importedCount === $exportedCount; - - return [ - 'success' => $success, - 'imported_count' => $importedCount, - 'exported_count' => $exportedCount, - 'round_trip_successful' => $success, - ]; - } catch (\Exception $e) { - $this->logger->error( - 'Round-trip test failed', - [ - 'error' => $e->getMessage(), - ] - ); - - return [ - 'success' => false, - 'error' => $e->getMessage(), - ]; - }//end try - }//end testRoundTrip() - - /** - * Create test ArchiMate XML - * - * @return string Test XML content - */ - private function createTestArchiMateXml(): string { - return ' - - Test Model - Test model for round-trip verification - - - Test Actor - - - - - test-element-1 - test-element-2 - - -'; - }//end createTestArchiMateXml() - - /** - * Create temporary file with content - * - * @param string $content File content - * - * @return string Temporary file path - */ - private function createTempFile(string $content): string { - $tempFile = tempnam(sys_get_temp_dir(), 'archimate_test_'); - file_put_contents($tempFile, $content); - return $tempFile; - }//end createTempFile() - /** * Read a configured id, failing closed on the empty default. * diff --git a/lib/Service/ConnectionReportService.php b/lib/Service/ConnectionReportService.php new file mode 100644 index 000000000..dc598f4ed --- /dev/null +++ b/lib/Service/ConnectionReportService.php @@ -0,0 +1,529 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use OCA\Stackiq\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Sends connection refresh requests and reports to integriq. + * + * A save refreshes before it reports. Under hydra#674 a refresh retires the + * observations older than itself, so a report sent before the refresh would + * be retired by it. A pull or a sync run reports without a refresh: it + * changes no settings. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ +class ConnectionReportService { + + /** + * Integriq's report event (ADR-041). Named by string so stackiq stays + * installable without integriq: the class is only there when integriq is. + * + * @var string + */ + public const STATUS_EVENT = 'OCA\Integriq\Event\ConnectionStatusReportedEvent'; + + /** + * Integriq's refresh event. Same reason for the string as above. + * + * @var string + */ + public const REFRESH_EVENT = 'OCA\Integriq\Event\ConnectionRefreshRequestedEvent'; + + /** + * The email connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_EMAIL = 'email'; + + /** + * The catalog federation connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_FEDERATION = 'federation'; + + /** + * The end-of-life feed connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_EOL = 'eol-feed'; + + /** + * The pull reason FederationService records for switched-off federation, which is never reported. + * + * The `federation` switch in lib/Settings/connections.json reads + * `federation_enabled`, so integriq shows `disabled` without a report + * (hydra connection-registry D4 rule 2b). + * + * @var string + */ + public const PULL_REASON_SWITCHED_OFF = 'federation disabled'; + + /** + * The EOL sync degrade reason for a switched-off sync, which is never reported. + * + * The `eol-feed` switch reads `enabled` inside `eol_sync_config` itself. + * + * @var string + */ + public const EOL_REASON_SWITCHED_OFF = 'disabled'; + + /** + * The longest failure reason a message carries. + * + * @var int + */ + public const REASON_LIMIT = 160; + + /** + * What each EOL sync degrade reason means for the row, as status and message. + * + * The reasons are the ones EolSyncService::degrade() records, apart from + * `disabled`: the `eol-feed` switch in lib/Settings/connections.json reads + * a switched-off sync itself, so that reason reports nothing. + * + * @var array + */ + public const EOL_REASONS = [ + 'openregister-not-installed' => [ + 'unavailable', + 'The end-of-life sync needs OpenRegister, and it is not installed.', + ], + 'object-service-unavailable' => [ + 'error', + 'OpenRegister did not answer the last end-of-life sync.', + ], + 'module-schema-not-configured' => [ + 'unconfigured', + 'Stackiq has no module or module version schema configured, so the sync has nothing to stamp.', + ], + 'eol-register-or-schema-not-found' => [ + 'unconfigured', + 'The end-of-life register or schemas are missing. Install the endoflife.date source in integriq, ' + . 'or fix the names in the End-of-life feed sync section.', + ], + ]; + + /** + * Constructor. + * + * @param IEventDispatcher $eventDispatcher Sends the integriq events (ADR-041). + * @param SymfonyEmailService $emailService Tells whether the saved email settings are complete. + * @param LoggerInterface $logger Records what could not be sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function __construct( + private readonly IEventDispatcher $eventDispatcher, + private readonly SymfonyEmailService $emailService, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * After an email settings save: refresh, then report what the saved settings say. + * + * The `null` transport is left to integriq's rule 3, which outranks any + * report. Never throws, and does nothing without integriq. + * + * @return bool True when the report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function emailSettingsSaved(): bool { + if ($this->refresh(key: self::KEY_EMAIL) === false) { + return false; + } + + try { + [$status, $message] = $this->describeEmail( + configStatus: $this->emailService->isEmailSystemConfigured(), + transportLabels: $this->emailService->getAvailableTransports() + ); + } catch (Throwable $e) { + $this->logger->warning( + 'Stackiq: could not read the email settings for a connection report', + ['key' => self::KEY_EMAIL, 'exception' => $e->getMessage()] + ); + return false; + } + + return $this->report(key: self::KEY_EMAIL, status: $status, message: $message); + }//end emailSettingsSaved() + + /** + * What the email configuration status says about the connection. + * + * @param array $configStatus The result of SymfonyEmailService::isEmailSystemConfigured(). + * @param array $transportLabels Transport type to its label. + * + * @return array{0: string, 1: string} The status and the message. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function describeEmail(array $configStatus, array $transportLabels): array { + if (array_key_exists('transportType', $configStatus) === false) { + // SymfonyEmailService answers without a transport only when email is off. + return ['unconfigured', 'Email is switched off, so stackiq sends no mail.']; + } + + $transport = (string) $configStatus['transportType']; + $label = ($transportLabels[$transport] ?? $transport); + + if (($configStatus['hasCredentials'] ?? false) !== true) { + return ['unconfigured', 'Email is on, and the ' . $label . ' transport misses a setting it needs.']; + } + + if (($configStatus['hasTemplates'] ?? false) !== true) { + return ['unconfigured', 'Email is on, and a required mail template is empty.']; + } + + return ['configured', 'Email is on and the ' . $label . ' transport settings are filled. No test mail was sent.']; + }//end describeEmail() + + /** + * After a peer was added or removed: refresh, then report a state that blocks federation. + * + * A ready federation gets no report: only a pull can tell whether the peers + * answer, so the row reads the declared "Not checked yet" until then. + * Switched-off federation gets none either: the `federation` switch in + * lib/Settings/connections.json reads `federation_enabled` itself, and + * integriq shows `disabled`. + * + * @param array $status The result of FederationService::getStatus(). + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function federationPeersChanged(array $status): bool { + if ($this->refresh(key: self::KEY_FEDERATION) === false) { + return false; + } + + $available = ($status['available'] ?? false) === true; + if ($available === true && ($status['enabled'] ?? false) !== true) { + return false; + } + + $blocked = $this->federationBlocker( + available: $available, + peerCount: count((array) ($status['peers'] ?? [])) + ); + if ($blocked === null) { + return false; + } + + return $this->report(key: self::KEY_FEDERATION, status: $blocked[0], message: $blocked[1]); + }//end federationPeersChanged() + + /** + * After a federation pull: report what the peers answered. + * + * @param array $pull The result of FederationService::pullAllPeers(). + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function federationPulled(array $pull): bool { + $described = $this->describePull(pull: $pull); + if ($described === null) { + return false; + } + + [$status, $message] = $described; + + return $this->report(key: self::KEY_FEDERATION, status: $status, message: $message); + }//end federationPulled() + + /** + * What a federation pull says about the connection. + * + * @param array $pull The result of FederationService::pullAllPeers(). + * + * @return array{0: string, 1: string}|null The status and the message, or null when federation is switched off. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function describePull(array $pull): ?array { + $reason = (string) ($pull['reason'] ?? ''); + if (($pull['ok'] ?? false) !== true) { + if ($reason === self::PULL_REASON_SWITCHED_OFF) { + return null; + } + + $blocked = $this->federationBlocker( + available: $reason !== 'OpenCatalogi unavailable', + peerCount: 1 + ); + + return ($blocked ?? ['error', 'The last federation pull failed: ' . $this->shorten(text: $reason)]); + } + + $peers = array_values((array) ($pull['peers'] ?? [])); + if ($peers === []) { + return ['unconfigured', 'Federation is on, and no peer catalog is added yet.']; + } + + $failed = array_values( + array_filter($peers, static fn (mixed $peer): bool => is_array($peer) === true && ($peer['ok'] ?? false) !== true) + ); + if ($failed === [] && count($peers) === 1) { + return ['configured', 'The peer catalog answered the last pull.']; + } + + if ($failed === []) { + return ['configured', 'All ' . count($peers) . ' peer catalogs answered the last pull.']; + } + + $first = $this->peerHost(url: (string) ($failed[0]['peer'] ?? '')) + . ' did not: ' . $this->shorten(text: (string) ($failed[0]['reason'] ?? '')); + if (count($failed) === count($peers)) { + return ['error', 'No peer catalog answered the last pull. ' . $first]; + } + + $answered = (count($peers) - count($failed)); + + return ['limited', $answered . ' of ' . count($peers) . ' peer catalogs answered the last pull. ' . $first]; + }//end describePull() + + /** + * After an EOL sync settings save: refresh, and send no report. + * + * The save may have switched the sync on or off, and the `eol-feed` switch + * in lib/Settings/connections.json reads `enabled` inside + * `eol_sync_config` itself. A run reports what the sync met. + * + * @return bool True when the refresh was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function eolSyncConfigSaved(): bool { + return $this->refresh(key: self::KEY_EOL); + }//end eolSyncConfigSaved() + + /** + * After an EOL sync run: report the outcome the run recorded. + * + * @param array $runStatus The status EolSyncService::run() recorded. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function eolSyncRan(array $runStatus): bool { + $described = $this->describeEolRun(runStatus: $runStatus); + if ($described === null) { + return false; + } + + [$status, $message] = $described; + + return $this->report(key: self::KEY_EOL, status: $status, message: $message); + }//end eolSyncRan() + + /** + * What an EOL sync run says about the connection. + * + * @param array $runStatus The status EolSyncService::run() recorded. + * + * @return array{0: string, 1: string}|null The status and the message, or null when the sync is switched off. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + public function describeEolRun(array $runStatus): ?array { + if (($runStatus['available'] ?? false) === true) { + return [ + 'configured', + 'The last sync stamped ' . (int) ($runStatus['matched'] ?? 0) . ' module versions and skipped ' + . (int) ($runStatus['skipped'] ?? 0) . '.', + ]; + } + + $reason = (string) ($runStatus['reason'] ?? ''); + if ($reason === self::EOL_REASON_SWITCHED_OFF) { + return null; + } + + return (self::EOL_REASONS[$reason] ?? ['error', 'The last end-of-life sync stopped: ' . $this->shorten(text: $reason)]); + }//end describeEolRun() + + /** + * Ask integriq to resolve one connection again. + * + * @param string $key The connection key. + * + * @return bool True when the event was dispatched. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + private function refresh(string $key): bool { + $eventClass = $this->resolveEventClass(eventClass: self::REFRESH_EVENT); + if ($eventClass === null) { + return false; + } + + return $this->send( + key: $key, + build: static fn (): object => new $eventClass(app: Application::APP_ID, key: $key) + ); + }//end refresh() + + /** + * Report one status for one connection. + * + * @param string $key The connection key. + * @param string $status One of the six registry statuses. + * @param string $message What stackiq observed. + * + * @return bool True when the event was dispatched. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + private function report(string $key, string $status, string $message): bool { + $eventClass = $this->resolveEventClass(eventClass: self::STATUS_EVENT); + if ($eventClass === null) { + return false; + } + + return $this->send( + key: $key, + build: static fn (): object => new $eventClass(app: Application::APP_ID, key: $key, status: $status, message: $message) + ); + }//end report() + + /** + * The event class to instantiate, or null when integriq does not ship it. + * + * @param string $eventClass The fully qualified class name, without a leading backslash. + * + * @return string|null The class name to instantiate, or null when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + protected function resolveEventClass(string $eventClass): ?string { + $qualified = '\\' . $eventClass; + if (class_exists($qualified) === false) { + return null; + } + + return $qualified; + }//end resolveEventClass() + + /** + * The state that keeps federation from pulling at all, or null when none does. + * + * Switched-off federation is not a blocker here: the callers leave it to the + * row's switch, which integriq reads itself. + * + * @param bool $available Whether OpenCatalogi is installed. + * @param int $peerCount How many peers are configured. + * + * @return array{0: string, 1: string}|null The status and the message, or null. + */ + private function federationBlocker(bool $available, int $peerCount): ?array { + if ($available === false) { + return ['unavailable', 'Federation needs the OpenCatalogi app, and it is not installed.']; + } + + if ($peerCount === 0) { + return ['unconfigured', 'Federation is on, and no peer catalog is added yet.']; + } + + return null; + }//end federationBlocker() + + /** + * The host of a peer URL, so a message never carries its path, query or credentials. + * + * @param string $url The peer URL. + * + * @return string The host, or "A peer" when the URL has none. + */ + private function peerHost(string $url): string { + $host = parse_url($url, PHP_URL_HOST); + if (is_string($host) === false || $host === '') { + return 'A peer'; + } + + return $host; + }//end peerHost() + + /** + * A reason cut to REASON_LIMIT characters. + * + * @param string $text The reason. + * + * @return string The reason, cut and trimmed. + */ + private function shorten(string $text): string { + $text = trim($text); + if (mb_strlen($text) <= self::REASON_LIMIT) { + return $text; + } + + return rtrim(mb_substr($text, 0, self::REASON_LIMIT)) . '...'; + }//end shorten() + + /** + * Build and dispatch one event, swallowing anything a listener throws. + * + * @param string $key The connection the event is about, for the log. + * @param callable(): object $build Builds the event. + * + * @return bool True when the event was dispatched without an exception. + */ + private function send(string $key, callable $build): bool { + try { + $event = $build(); + if (($event instanceof Event) === false) { + return false; + } + + $this->eventDispatcher->dispatchTyped($event); + return true; + } catch (Throwable $e) { + $this->logger->warning( + 'Stackiq: could not send a connection event to integriq', + ['key' => $key, 'exception' => $e->getMessage()] + ); + return false; + } + }//end send() +}//end class diff --git a/lib/Service/EolSyncService.php b/lib/Service/EolSyncService.php index 7a138f9bb..86be572b3 100644 --- a/lib/Service/EolSyncService.php +++ b/lib/Service/EolSyncService.php @@ -57,12 +57,16 @@ class EolSyncService { * @param EolMatcherService $matcher The pure matching/stamping logic. * @param ITimeFactory $timeFactory The time factory (sync-run timestamp). * @param LoggerInterface $logger The logger. + * @param ConnectionReportService|null $connectionReports Tells integriq what a save or a run met. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function __construct( private readonly SettingsService $settingsService, private readonly EolMatcherService $matcher, private readonly ITimeFactory $timeFactory, private readonly LoggerInterface $logger, + private readonly ?ConnectionReportService $connectionReports = null, ) { }//end __construct() @@ -82,12 +86,19 @@ public function getConfig(): array { * * @param array $data The submitted configuration fields. * + * The save asks integriq to resolve the end-of-life feed connection again + * (adopt-connection-registry). + * * @return array The persisted configuration result. * * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function updateConfig(array $data): array { - return $this->settingsService->updateEolSyncConfig($data); + $result = $this->settingsService->updateEolSyncConfig($data); + $this->connectionReports?->eolSyncConfigSaved(); + + return $result; }//end updateConfig() /** @@ -226,7 +237,7 @@ public function run(): array { 'skipped' => $totalSkipped, 'lastRunAt' => $fetchedAt, ]; - $this->settingsService->setEolSyncStatus($status); + $this->recordStatus(status: $status); return $status; }//end run() @@ -476,8 +487,22 @@ private function degrade(string $reason): array { 'skipped' => 0, 'lastRunAt' => $this->timeFactory->getDateTime()->format(\DateTimeInterface::ATOM), ]; - $this->settingsService->setEolSyncStatus($status); + $this->recordStatus(status: $status); return $status; }//end degrade() + + /** + * Record a run's status, and tell integriq what the run met. + * + * @param array{available: bool, reason: string|null, matched: int, skipped: int, lastRunAt: string|null} $status The run status. + * + * @return void + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + */ + private function recordStatus(array $status): void { + $this->settingsService->setEolSyncStatus($status); + $this->connectionReports?->eolSyncRan(runStatus: $status); + }//end recordStatus() }//end class diff --git a/lib/Service/FacetService.php b/lib/Service/FacetService.php index fc8094afe..a2af905d3 100644 --- a/lib/Service/FacetService.php +++ b/lib/Service/FacetService.php @@ -108,6 +108,15 @@ class FacetService { */ private const DIMENSIONS = ['referenceComponent', 'standard', 'applicationService', 'domain']; + /** + * The element property the domain dimension reads: the register's + * `element` schema declares it as `domein`, and the GEMMA import stores + * the "Domein" property under that lower-cased name. + * + * @var string + */ + public const ELEMENT_DOMAIN_PROPERTY = 'domein'; + /** * Constructor for FacetService. * @@ -701,7 +710,7 @@ private function buildDimensionValueMap(array $modulesByObjectId): array { fallbackIdentifier: $refCompId ); - $domain = $element['domain'] ?? null; + $domain = $element[self::ELEMENT_DOMAIN_PROPERTY] ?? null; if (is_string($domain) === true && trim($domain) !== '') { $domeinValues[] = trim($domain); } diff --git a/lib/Service/Federation/FederationService.php b/lib/Service/Federation/FederationService.php index 2bd60c109..3bb5174b7 100644 --- a/lib/Service/Federation/FederationService.php +++ b/lib/Service/Federation/FederationService.php @@ -27,6 +27,7 @@ namespace OCA\Stackiq\Service\Federation; +use OCA\Stackiq\Service\ConnectionReportService; use OCA\Stackiq\Service\SettingsService; use OCP\App\IAppManager; use Psr\Container\ContainerInterface; @@ -63,6 +64,9 @@ class FederationService { * @param FederationMerger $merger The merge/staleness reconciler. * @param SettingsService|null $settingsService Resolves the mirror register/schema (lazy/optional). * @param LoggerInterface $logger Logger. + * @param ConnectionReportService|null $connectionReports Tells integriq what a peer change or a pull met. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function __construct( private readonly ContainerInterface $container, @@ -71,6 +75,7 @@ public function __construct( private readonly FederationMerger $merger, private readonly ?SettingsService $settingsService, private readonly LoggerInterface $logger, + private readonly ?ConnectionReportService $connectionReports = null, ) { }//end __construct() @@ -141,9 +146,13 @@ public function getStatus(): array { * * @param string $peerUrl The peer base URL. * + * A new peer asks integriq to resolve the federation connection again + * (adopt-connection-registry). + * * @return array{ok:bool, reason:string} Result for the settings UI. * * @spec openspec/specs/federated-catalog-sync/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function addPeer(string $peerUrl): array { $peerUrl = trim($peerUrl); @@ -162,6 +171,7 @@ public function addPeer(string $peerUrl): array { $peers[] = $peerUrl; $this->config->setPeers(array_values($peers)); + $this->connectionReports?->federationPeersChanged(status: $this->getStatus()); return ['ok' => true, 'reason' => 'peer added']; }//end addPeer() @@ -170,9 +180,13 @@ public function addPeer(string $peerUrl): array { * * @param string $peerUrl The peer base URL. * + * A removed peer asks integriq to resolve the federation connection again + * (adopt-connection-registry). + * * @return array{ok:bool, reason:string} Result for the settings UI. * * @spec openspec/specs/federated-catalog-sync/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function removePeer(string $peerUrl): array { $peerUrl = trim($peerUrl); @@ -184,6 +198,7 @@ public function removePeer(string $peerUrl): array { $this->config->setPeers($filtered); $this->config->setPeerFailures($peerUrl, 0); + $this->connectionReports?->federationPeersChanged(status: $this->getStatus()); return ['ok' => true, 'reason' => 'peer removed']; }//end removePeer() @@ -278,11 +293,29 @@ public function discoverPeers(): array { * independently so one unreachable peer cannot block the rest. Returns a * per-peer result summary for logging / the admin UI. * + * Tells integriq what the pull met, from the Pull now button and from + * FederationSyncJob alike (adopt-connection-registry). + * * @return array{ok:bool, reason:string, peers:array>} * * @spec openspec/specs/federated-catalog-sync/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function pullAllPeers(): array { + $result = $this->pullEveryPeer(); + $this->connectionReports?->federationPulled(pull: $result); + + return $result; + }//end pullAllPeers() + + /** + * Pull every subscribed peer, one at a time. + * + * @return array{ok:bool, reason:string, peers:array>} + * + * @spec openspec/specs/federated-catalog-sync/spec.md + */ + private function pullEveryPeer(): array { if ($this->config->isEnabled() === false) { return ['ok' => false, 'reason' => 'federation disabled', 'peers' => []]; } @@ -297,7 +330,7 @@ public function pullAllPeers(): array { } return ['ok' => true, 'reason' => 'ok', 'peers' => $results]; - }//end pullAllPeers() + }//end pullEveryPeer() /** * Pull one peer's published catalog and reconcile it into local mirrors. diff --git a/lib/Service/MaintenanceRecipientService.php b/lib/Service/MaintenanceRecipientService.php new file mode 100644 index 000000000..7006e8820 --- /dev/null +++ b/lib/Service/MaintenanceRecipientService.php @@ -0,0 +1,338 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use DateTimeImmutable; +use DateTimeInterface; +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCP\IUserManager; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Resolves the owners of every usage of a product to Nextcloud user ids and + * records them on a maintenance window. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ +class MaintenanceRecipientService { + + /** + * The usage fields that name an owner. + * + * @var array + */ + public const OWNER_FIELDS = ['businessOwner', 'technicalOwner']; + + /** + * Upper bound on the usages read for one product. + */ + private const USAGE_LIMIT = 1000; + + /** + * Constructor. + * + * @param SettingsService $settingsService The register and schema lookups. + * @param StackiqContactSyncService $contacts The Nextcloud Contacts lookups. + * @param IUserManager $userManager The Nextcloud user manager. + * @param ContainerInterface $container The DI container, for OpenRegister's ObjectService. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly StackiqContactSyncService $contacts, + private readonly IUserManager $userManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether an object belongs to the maintenanceWindow schema. + * + * @param ObjectEntityInterface $object The object from the event. + * + * @return boolean True for a maintenance window. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function isMaintenanceWindow(ObjectEntityInterface $object): bool { + $schemaId = $this->settingsService->getSchemaIdForObjectType('maintenanceWindow'); + return $schemaId !== null && (string) $schemaId === (string) $object->getSchema(); + }//end isMaintenanceWindow() + + /** + * Load a maintenance window and record the owners to notify on it. + * + * @param string $uuid The window's id. + * @param string|int|null $register The register it lives in. + * @param string|int|null $schema Its schema. + * + * @return array|null The user ids written, or null when nothing was written. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function recordRecipientsFor(string $uuid, string|int|null $register, string|int|null $schema): ?array { + $objectService = $this->getObjectService(); + if ($objectService === null) { + return null; + } + + try { + $window = $objectService->find(id: $uuid, register: $register, schema: $schema, _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + $this->logger->error( + 'MaintenanceRecipientService: could not read the maintenance window', + ['uuid' => $uuid, 'error' => $e->getMessage()] + ); + return null; + } + + if ($window === null) { + return null; + } + + return $this->recordRecipients(window: $window); + }//end recordRecipientsFor() + + /** + * Record the owners to notify on a newly announced maintenance window. + * + * Writes `notifyUserIds` and `recipientsResolvedAt`; the rule + * `maintenance-announced` fires on the change of the latter. A window that + * already carries a resolved time is left alone, so the write this method + * makes cannot start it again. + * + * @param ObjectEntityInterface $window The maintenance window. + * @param DateTimeImmutable|null $now The moment of resolution (defaults to now). + * + * @return array|null The user ids written, or null when nothing was written. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function recordRecipients(ObjectEntityInterface $window, ?DateTimeImmutable $now=null): ?array { + $data = $window->getObject(); + if (empty($data['recipientsResolvedAt']) === false) { + return null; + } + + $moduleId = self::referenceId(value: ($data['module'] ?? null)); + $objectService = $this->getObjectService(); + if ($moduleId === null || $objectService === null) { + return null; + } + + $userIds = $this->ownerUserIds(objectService: $objectService, moduleId: $moduleId); + + $data['notifyUserIds'] = $userIds; + $data['recipientsResolvedAt'] = ($now ?? new DateTimeImmutable())->format(DateTimeInterface::ATOM); + + try { + $objectService->saveObject( + object: $data, + extend: [], + register: $window->getRegister(), + schema: $window->getSchema(), + uuid: $window->getUuid(), + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $this->logger->error( + 'MaintenanceRecipientService: could not record the owners to notify', + ['uuid' => $window->getUuid(), 'error' => $e->getMessage()] + ); + return null; + } + + return $userIds; + }//end recordRecipients() + + /** + * The Nextcloud user ids of the owners of every usage of a product. + * + * @param ObjectServiceInterface $objectService OpenRegister's object service. + * @param string $moduleId The product's id. + * + * @return array Unique user ids, in the order found. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function ownerUserIds(ObjectServiceInterface $objectService, string $moduleId): array { + $contactIds = $this->ownerContactIds(objectService: $objectService, moduleId: $moduleId); + if ($contactIds === []) { + return []; + } + + $register = $this->settingsService->getRegisterIdForObjectType('contactPerson'); + $schema = $this->settingsService->getSchemaIdForObjectType('contactPerson'); + if ($register === null || $schema === null) { + return []; + } + + try { + $people = $objectService->searchObjects( + query: ['register' => $register, 'schema' => $schema, '_limit' => count($contactIds)], + _rbac: false, + _multitenancy: false, + ids: $contactIds + ); + } catch (\Throwable $e) { + $this->logger->error('MaintenanceRecipientService: could not read the owners', ['error' => $e->getMessage()]); + return []; + } + + $userIds = []; + foreach ((array) $people as $person) { + $uid = $this->userIdForContactPerson(person: $person->getObject()); + if ($uid !== null && in_array($uid, $userIds, true) === false) { + $userIds[] = $uid; + } + } + + return $userIds; + }//end ownerUserIds() + + /** + * The contact person ids named as owner on the usages of a product. + * + * @param ObjectServiceInterface $objectService OpenRegister's object service. + * @param string $moduleId The product's id. + * + * @return array Unique contact person ids. + */ + private function ownerContactIds(ObjectServiceInterface $objectService, string $moduleId): array { + $register = $this->settingsService->getRegisterIdForObjectType('usage'); + $schema = $this->settingsService->getSchemaIdForObjectType('usage'); + if ($register === null || $schema === null) { + return []; + } + + try { + $usages = $objectService->searchObjects( + query: ['register' => $register, 'schema' => $schema, 'module' => $moduleId, '_limit' => self::USAGE_LIMIT], + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $this->logger->error('MaintenanceRecipientService: could not read the usages', ['error' => $e->getMessage()]); + return []; + } + + $ids = []; + foreach ((array) $usages as $usage) { + $data = $usage->getObject(); + foreach (self::OWNER_FIELDS as $field) { + $id = self::referenceId(value: ($data[$field] ?? null)); + if ($id !== null && in_array($id, $ids, true) === false) { + $ids[] = $id; + } + } + } + + return $ids; + }//end ownerContactIds() + + /** + * The Nextcloud user a contact person stands for. + * + * A contact in the system address book carries the user id as its UID; + * any other contact is matched to a user by e-mail address. + * + * @param array $person The contact person object. + * + * @return string|null The user id, or null when no user matches. + */ + private function userIdForContactPerson(array $person): ?string { + $contact = $this->contacts->findContactByUid((string) ($person['contactsUid'] ?? '')); + if ($contact === null) { + return null; + } + + if (($contact['isLocalSystemBook'] ?? false) === true && $this->userManager->userExists((string) ($contact['UID'] ?? '')) === true) { + return (string) $contact['UID']; + } + + foreach ((array) ($contact['EMAIL'] ?? []) as $email) { + if (is_array($email) === true) { + $email = ($email['value'] ?? ''); + } + + if (is_string($email) === false || $email === '') { + continue; + } + + $users = $this->userManager->getByEmail($email); + if (count($users) === 1) { + return $users[0]->getUID(); + } + } + + return null; + }//end userIdForContactPerson() + + /** + * The id a relation value points at: a plain id, or an object carrying one. + * + * @param mixed $value The stored relation value. + * + * @return string|null The id, or null when the value names none. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public static function referenceId(mixed $value): ?string { + if (is_string($value) === true && $value !== '') { + return $value; + } + + if (is_array($value) === true) { + foreach (['id', 'uuid'] as $key) { + if (is_string($value[$key] ?? null) === true && $value[$key] !== '') { + return $value[$key]; + } + } + } + + return null; + }//end referenceId() + + /** + * Lazily resolve OpenRegister's ObjectService. + * + * @return ObjectServiceInterface|null The service, or null when OpenRegister is absent. + */ + private function getObjectService(): ?ObjectServiceInterface { + try { + $service = $this->container->get(ObjectServiceInterface::class); + if ($service instanceof ObjectServiceInterface) { + return $service; + } + } catch (\Throwable $e) { + $this->logger->debug('MaintenanceRecipientService: ObjectService not resolvable', ['error' => $e->getMessage()]); + } + + return null; + }//end getObjectService() +}//end class diff --git a/lib/Service/MergeOrganisatieService.php b/lib/Service/MergeOrganisatieService.php index 2f4e6f16d..e6ff006ea 100644 --- a/lib/Service/MergeOrganisatieService.php +++ b/lib/Service/MergeOrganisatieService.php @@ -112,14 +112,25 @@ class MergeOrganisatieService { 'usage' => ['field' => 'consumer', 'arrayField' => 'participants'], 'contactPerson' => ['field' => 'organization', 'arrayField' => null], 'aanbod' => ['field' => 'provider', 'arrayField' => null, 'schema' => 'connection'], + // The supplier's own applications and services (`provider`, a Vendor + // $ref to organization): after a takeover they belong to the target. + 'module' => ['field' => 'provider', 'arrayField' => null], + 'catalogService' => ['field' => 'provider', 'arrayField' => null], ]; /** - * Relation types re-pointed via the OpenRegister system-level `@self.organisation` field. + * Relation types re-pointed via the OpenRegister system-level `@self.organisation` field, + * as count key => schema slug. Modules and services carry a `provider` + * field too, so their ownership counts under its own key. * - * @var string[] + * @var array */ - private const SELF_ORGANISATION_RELATION_TYPES = ['catalogContract', 'compliancy']; + private const SELF_ORGANISATION_RELATION_TYPES = [ + 'catalogContract' => 'catalogContract', + 'compliancy' => 'compliancy', + 'moduleOwnership' => 'module', + 'catalogServiceOwnership' => 'catalogService', + ]; /** * MergeOrganisatieService constructor. @@ -261,7 +272,7 @@ public function execute(string $sourceUuid, string $targetUuid, ?string $actorUi $this->progressTracker->completeOperation(finalStatistics: ['counts' => $counts]); $relationSum = 0; - foreach (array_merge(array_keys(self::FIELD_RELATION_TYPES), self::SELF_ORGANISATION_RELATION_TYPES) as $type) { + foreach (array_merge(array_keys(self::FIELD_RELATION_TYPES), array_keys(self::SELF_ORGANISATION_RELATION_TYPES)) as $type) { $relationSum += ($counts[$type] ?? 0); } @@ -324,9 +335,9 @@ private function walkRelations(string $sourceUuid, string $targetUuid, bool $com $this->reportTypeProgress(type: $type, count: $counts[$type], commit: $commit); } - foreach (self::SELF_ORGANISATION_RELATION_TYPES as $type) { + foreach (self::SELF_ORGANISATION_RELATION_TYPES as $type => $schemaType) { $counts[$type] = $this->repointBySelfOrganisation( - objectType: $type, + objectType: $schemaType, source: $sourceUuid, target: $targetUuid, commit: $commit @@ -832,7 +843,7 @@ private function emptyCounts(): array { $counts[$type] = 0; } - foreach (self::SELF_ORGANISATION_RELATION_TYPES as $type) { + foreach (array_keys(self::SELF_ORGANISATION_RELATION_TYPES) as $type) { $counts[$type] = 0; } diff --git a/lib/Service/PortfolioReportDerivation.php b/lib/Service/PortfolioReportDerivation.php index 324079fe3..19b79d12d 100644 --- a/lib/Service/PortfolioReportDerivation.php +++ b/lib/Service/PortfolioReportDerivation.php @@ -252,4 +252,112 @@ static function ($object) { $results ); }//end normalizeResults() + /** + * Read a 1 to 5 score, or null when it is absent or out of range. + * + * @param mixed $value The stored value. + * + * @return int|null The score. + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + */ + private function score(mixed $value): ?int { + if (is_numeric($value) === false) { + return null; + } + + $score = (int)$value; + if ($score < 1 || $score > 5) { + return null; + } + + return $score; + }//end score() + + /** + * The TIME class business value and technical fit point to. The same rule as + * the usage schema's `suggestedTimeClassification` calculation + * (`lib/Settings/register.d/value-assessment.json`), used when a usage was + * saved before that calculation existed. + * + * @param int|null $businessValue The business value, 1 to 5. + * @param int|null $technicalFit The technical fit, 1 to 5. + * + * @return string|null Invest, Migrate, Tolerate or Eliminate; null while a score is missing. + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + */ + public function suggestTimeClassification(?int $businessValue, ?int $technicalFit): ?string { + if ($businessValue === null || $technicalFit === null) { + return null; + } + + if ($businessValue >= 3) { + if ($technicalFit >= 3) { + return 'Invest'; + } + + return 'Migrate'; + } + + if ($technicalFit >= 3) { + return 'Tolerate'; + } + + return 'Eliminate'; + }//end suggestTimeClassification() + + /** + * The value assessment of one gebruik: its scores, the suggested TIME class + * (the stored calculation, else the same rule over the scores) and whether + * the recorded class differs from that suggestion. + * + * @param array $usage The gebruik data bag. + * @param string|null $classification The recorded TIME class. + * @param string|null $storedSuggestion The materialised suggestion, normalised. + * + * @return array The score fields of the row. + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + public function valueAssessment(array $usage, ?string $classification, ?string $storedSuggestion): array { + $value = $this->score(value: $usage['businessValue'] ?? null); + $fit = $this->score(value: $usage['technicalFit'] ?? null); + + $suggested = $storedSuggestion; + if ($suggested === null) { + $suggested = $this->suggestTimeClassification(businessValue: $value, technicalFit: $fit); + } + + $scoredOn = null; + if (is_string($usage['scoredOn'] ?? null) === true && $usage['scoredOn'] !== '') { + $scoredOn = $usage['scoredOn']; + } + + return [ + 'businessValue' => $value, + 'technicalFit' => $fit, + 'riskScore' => $this->score(value: $usage['riskScore'] ?? null), + 'scoredOn' => $scoredOn, + 'suggestedTimeClassification' => $suggested, + 'timeMismatch' => $classification !== null && $suggested !== null && $classification !== $suggested, + ]; + }//end valueAssessment() + + /** + * The CSV cell for the mismatch flag. + * + * @param array $row A report row. + * + * @return string "yes" or "no". + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + public function mismatchLabel(array $row): string { + if (($row['timeMismatch'] ?? false) === true) { + return 'yes'; + } + + return 'no'; + }//end mismatchLabel() }//end class diff --git a/lib/Service/PortfolioReportService.php b/lib/Service/PortfolioReportService.php index 56187fdb1..18401a959 100644 --- a/lib/Service/PortfolioReportService.php +++ b/lib/Service/PortfolioReportService.php @@ -181,6 +181,12 @@ public function buildCsv(string $organisationUuid): string { 'hostingModel', 'annualisedCost', 'oneOffCost', + 'businessValue', + 'technicalFit', + 'riskScore', + 'scoredOn', + 'suggestedTimeClassification', + 'timeMismatch', ] ); @@ -198,6 +204,12 @@ public function buildCsv(string $organisationUuid): string { implode('|', $row['hostingModel']), (string)$row['annualisedCost'], (string)$row['oneOffCost'], + (string)($row['businessValue'] ?? ''), + (string)($row['technicalFit'] ?? ''), + (string)($row['riskScore'] ?? ''), + $row['scoredOn'] ?? '', + $row['suggestedTimeClassification'] ?? '', + $this->derivation->mismatchLabel(row: $row), ] ); } @@ -309,8 +321,13 @@ private function buildRow(array $usage, array $cfg, array $contractsByGebruik, D } $classification = $this->normalizeClassification(value: $usage['timeClassification'] ?? null); + $scores = $this->derivation->valueAssessment( + usage: $usage, + classification: $classification, + storedSuggestion: $this->normalizeClassification(value: $usage['suggestedTimeClassification'] ?? null) + ); - return [ + return $scores + [ 'uuid' => $gebruikId, 'moduleId' => $moduleId, 'moduleName' => $module['name'] ?? $module['title'] ?? $moduleId, diff --git a/lib/Service/ProgressTracker.php b/lib/Service/ProgressTracker.php index 3e90f6422..8bc62e4e6 100644 --- a/lib/Service/ProgressTracker.php +++ b/lib/Service/ProgressTracker.php @@ -19,12 +19,19 @@ namespace OCA\Stackiq\Service; -use OCP\ISession; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IUserSession; use Psr\Log\LoggerInterface; /** * Service for tracking and reporting progress of long-running operations * + * Progress lives in Nextcloud's distributed cache, not in the user's session, + * so a background job can write it and any other request (another login, an + * admin, the request after a cron run) can read it. Who may read an operation + * is decided by SettingsController::getProgress(), not by where it is stored. + * * @category Service * @package OCA\Stackiq\Service * @author Conduction b.v. @@ -73,16 +80,33 @@ class ProgressTracker { 'statistics' => [], ]; + /** + * How long a stored snapshot lives after its last write, in seconds. + */ + private const STORE_TTL = 3600; + + /** + * The shared store for progress snapshots. + * + * @var ICache + */ + private ICache $store; + /** * Constructor for ProgressTracker * - * @param ISession $session The session service for storing progress + * @param ICacheFactory $cacheFactory Cache factory; progress goes into its distributed cache + * @param IUserSession $userSession The signed-in user, the default owner of a new operation * @param LoggerInterface $logger The logger interface + * + * @spec openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it */ public function __construct( - private readonly ISession $session, + ICacheFactory $cacheFactory, + private readonly IUserSession $userSession, private readonly LoggerInterface $logger, ) { + $this->store = $cacheFactory->createDistributed(prefix: 'stackiq_progress'); }//end __construct() /** @@ -90,19 +114,35 @@ public function __construct( * * @param string $operationType Type of operation (import, export) * @param array $options Operation options and metadata - * @param string|null $ownerUid UID of the user who owns this operation + * @param string|null $ownerUid UID of the user who owns this operation; when null, the + * signed-in user of this request, or no owner in a background job + * @param string|null $operationId Id the caller chose, so a page can follow an operation + * whose request has not returned yet; when null, a new id * * @return string Unique operation ID * - * @spec openspec/specs/progress-tracking/spec.md + * @spec openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-001-a-running-import-shall-record-its-phase-and-the-objects-saved-so-far */ - public function startOperation(string $operationType, array $options = [], ?string $ownerUid = null): string { - $operationId = uniqid(prefix: $operationType . '_', more_entropy: true); + public function startOperation( + string $operationType, + array $options = [], + ?string $ownerUid = null, + ?string $operationId = null + ): string { + if ($operationId === null) { + $operationId = uniqid(prefix: $operationType . '_', more_entropy: true); + } + + if ($ownerUid === null) { + $ownerUid = $this->userSession->getUser()?->getUID(); + } $this->progress = [ 'operation_id' => $operationId, 'operation_type' => $operationType, 'owner_uid' => $ownerUid, + 'status' => 'running', 'phase' => 'initializing', 'phase_description' => self::PHASES['initializing']['description'], 'total_items' => $options['total_items'] ?? 0, @@ -298,6 +338,7 @@ public function updateStatistics(array $statistics): void { public function completeOperation(array $finalStatistics = []): void { $this->progress['phase'] = 'completed'; $this->progress['phase_description'] = self::PHASES['completed']['description']; + $this->progress['status'] = 'completed'; $this->progress['percentage'] = 100; $this->progress['processed_items'] = $this->progress['total_items']; $this->progress['estimated_completion'] = time(); @@ -320,6 +361,55 @@ public function completeOperation(array $finalStatistics = []): void { ); }//end completeOperation() + /** + * Ask a running operation to stop. + * + * The operation runs in another request, which PHP cannot interrupt, so the + * request is a flag in the shared store that the operation reads between + * its batches. + * + * @param string $operationId The operation to cancel + * + * @return void + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import + */ + public function setCancelRequested(string $operationId): void { + $this->store->set(key: 'cancel_' . $operationId, value: true, ttl: self::STORE_TTL); + }//end setCancelRequested() + + /** + * Whether a cancel was requested for an operation, from any request. + * + * @param string $operationId The operation to check + * + * @return bool True when a cancel was requested + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import + */ + public function isCancelRequested(string $operationId): bool { + return $this->store->get(key: 'cancel_' . $operationId) === true; + }//end isCancelRequested() + + /** + * Mark the current operation as stopped on a cancel, keeping the counts it reached. + * + * @return void + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import + */ + public function cancelOperation(): void { + $this->progress['phase'] = 'completed'; + $this->progress['phase_description'] = 'Cancelled'; + $this->progress['status'] = 'cancelled'; + $this->progress['estimated_completion'] = time(); + $this->saveProgress(); + + if ($this->progress['operation_id'] !== null) { + $this->store->remove(key: 'cancel_' . $this->progress['operation_id']); + } + }//end cancelOperation() + /** * Get current progress state * @@ -327,14 +417,13 @@ public function completeOperation(array $finalStatistics = []): void { * * @return array|null Progress data or null if not found * - * @spec openspec/specs/progress-tracking/spec.md + * @spec openspec/changes/operations-sync-status-and-progress/specs/sync-status-and-progress/spec.md#requirement-req-ssp-001-progress-of-a-long-operation-shall-be-readable-from-any-request-and-only-by-users-allowed-to-read-it */ public function getProgress(?string $operationId = null): ?array { if ($operationId !== null && $operationId !== $this->progress['operation_id']) { - // Load progress from session for different operation. - $sessionKey = 'progress_' . $operationId; - $storedProgress = $this->session->get($sessionKey); - if ($storedProgress !== null && $storedProgress !== false) { + // Load an operation another request or a background job wrote. + $storedProgress = $this->store->get(key: 'progress_' . $operationId); + if (is_array($storedProgress) === true) { return $storedProgress; } @@ -405,19 +494,27 @@ private function calculateEstimatedCompletion(): ?int { }//end calculateEstimatedCompletion() /** - * Save progress to session + * Save progress to the shared store. + * + * Each write renews the entry for STORE_TTL seconds. * * @return void */ private function saveProgress(): void { if ($this->progress['operation_id'] !== null) { - $sessionKey = 'progress_' . $this->progress['operation_id']; - $this->session->set($sessionKey, $this->progress); + $this->store->set( + key: 'progress_' . $this->progress['operation_id'], + value: $this->progress, + ttl: self::STORE_TTL + ); } }//end saveProgress() /** - * Clean up old progress entries from session + * Clean up old progress entries. + * + * Nothing to do: every entry in the shared store expires STORE_TTL seconds + * after its last write. * * @param int $maxAge Maximum age in seconds (default: 1 hour) * @@ -425,8 +522,6 @@ private function saveProgress(): void { * @spec openspec/specs/progress-tracking/spec.md */ public function cleanupOldProgress(int $maxAge = 3600): void { - // Note: This would need to iterate through session keys to find and clean old progress entries. - // Implementation depends on session storage capabilities. - $this->logger->debug('Progress cleanup requested', ['max_age' => $maxAge]); + $this->logger->debug('Progress cleanup requested; stored entries expire on their own', ['max_age' => $maxAge]); }//end cleanupOldProgress() }//end class diff --git a/lib/Service/Settings/OrganizationSettingsHandler.php b/lib/Service/Settings/OrganizationSettingsHandler.php index 905202636..ba7aab8da 100644 --- a/lib/Service/Settings/OrganizationSettingsHandler.php +++ b/lib/Service/Settings/OrganizationSettingsHandler.php @@ -104,9 +104,20 @@ public function setGenericUserGroups(array $groups): void { * @spec openspec/changes/method-decomposition/tasks.md#task-1 */ public function getOrganizationAdminGroups(): array { - // DISABLED: No automatic group assignment for organization admins. - // Users should be assigned groups explicitly via the admin UI. - return []; + // The saved list, or none. No default groups: first contacts are not + // added to these groups automatically (stackiq#1136). + $groupsJson = $this->config->getValueString(self::APP_NAME, 'organization_admin_groups', ''); + + if (empty($groupsJson) === true) { + return []; + } + + $groups = json_decode($groupsJson, true); + if (is_array($groups) === false) { + return []; + } + + return $groups; }//end getOrganizationAdminGroups() /** diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 65501aa7f..3195de0f1 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -912,6 +912,8 @@ public function getSchemaIdForObjectType(string $objectType): ?int { 'suite' => 'suite_schema', 'vulnerability' => 'kwetsbaarheid_schema', 'sector' => 'sector_schema', + // Planned maintenance on a product (lifecycle-maintenance-and-supplier-roadmap). + 'maintenanceWindow' => 'maintenanceWindow_schema', ]; // Only check voorzieningen config if object type exists in the key map. @@ -2099,13 +2101,29 @@ public function setGenericUserGroups(array $groups): void { /** * Gets the list of organization admin groups from configuration * + * Returns the list an admin saved under `organization_admin_groups`, or an + * empty list when nothing is saved. There is no default list: commit + * bc4dc9ea dropped the old default (organisaties-beheerder) on purpose, + * and first contacts are not added to these groups automatically (see + * ContactPersonHandler::assignUserGroups()). Until stackiq#1136 this getter + * returned an empty list unconditionally, which discarded the saved + * setting as well. + * * @return array Array of organization admin groups * @spec openspec/specs/settings-service/spec.md */ public function getOrganizationAdminGroups(): array { - // DISABLED: No automatic group assignment for organization admins. - // Users should be assigned groups explicitly via the admin UI. - // Previously this returned ['organisaties-beheerder', 'organisatie-beheerder'] by default. + $groupsJson = $this->config->getValueString($this->appName, 'organization_admin_groups', ''); + + if (empty($groupsJson) === true) { + return []; + } + + $groups = json_decode($groupsJson, true); + if (is_array($groups) === true) { + return $groups; + } + return []; }//end getOrganizationAdminGroups() @@ -4208,6 +4226,7 @@ private function configureVoorzieningen(): array { 'moduleVersion' => 'moduleVersie_schema', 'sector' => 'sector_schema', 'sbomComponent' => 'sbomComponent_schema', + 'maintenanceWindow' => 'maintenanceWindow_schema', ]; $config = [ 'register' => (string)($targetRegister['id'] ?? '') ]; @@ -4641,6 +4660,7 @@ private function normalizeVoorzieningenConfig(array $input): array { 'moduleVersie_schema', 'sector_schema', 'sbomComponent_schema', + 'maintenanceWindow_schema', ]; // Copy any present schema keys; ignore sources/registers. @@ -5189,15 +5209,18 @@ public function killArchiMateImport(): array { * This method combines force clearing and process killing for a complete * import cancellation. It delegates to ArchiMateService for the actual work. * + * @param string|null $operationId The running import's operation id, when the page knows it + * * @return array Cancellation result with detailed status * @spec openspec/specs/settings-service/spec.md + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import */ - public function cancelArchiMateImport(): array { + public function cancelArchiMateImport(?string $operationId = null): array { try { // Get ArchiMateService from container to avoid circular dependency. $archiMateService = $this->container->get(\OCA\Stackiq\Service\ArchiMateService::class); - return $archiMateService->cancelArchiMateImport(); + return $archiMateService->cancelArchiMateImport(operationId: $operationId); } catch (\Exception $e) { $this->logger->error( 'SettingsService: Failed to cancel ArchiMate import via ArchiMateService', @@ -6868,6 +6891,30 @@ public function getCronjobConfig(): array { }//end try }//end getCronjobConfig() + /** + * Whether an admin left a cronjob switched on in the cronjob settings. + * + * Reads the `enabled` flag `updateCronjobConfig()` stores under + * `cronjob_config`. A job that was never saved is on, as the settings + * screen shows it (`getCronjobConfig()` defaults `enabled` to true). + * + * @param string $jobId The cronjob identifier, e.g. `organization_contact_sync`. + * + * @return bool False only when the admin switched the job off. + * + * @spec openspec/changes/operations-sync-status-and-progress/tasks.md#task-3 + */ + public function isCronjobEnabled(string $jobId): bool { + $config = json_decode($this->config->getValueString($this->appName, 'cronjob_config', '{}'), true); + if (is_array($config) === false || is_array($config[$jobId] ?? null) === false) { + return true; + } + + $enabled = ($config[$jobId]['enabled'] ?? true); + + return in_array($enabled, [false, 0, '0', 'false'], true) === false; + }//end isCronjobEnabled() + /** * Get list of available cronjobs with their metadata. * diff --git a/lib/Service/Stackiq/ContactPersonHandler.php b/lib/Service/Stackiq/ContactPersonHandler.php index d07ecd809..13d312fad 100644 --- a/lib/Service/Stackiq/ContactPersonHandler.php +++ b/lib/Service/Stackiq/ContactPersonHandler.php @@ -639,25 +639,13 @@ private function assignUserGroups(\OCP\IUser $user, array $objectData, bool $isF $roles = [$roles]; } - // Get the settings service to access group configurations. - $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService'); - - // Add user to organization admin groups if this is the first contact. - if ($isFirstContact === true) { - $organizationAdminGroups = $settingsService->getOrganizationAdminGroups(); - foreach ($organizationAdminGroups as $groupName) { - $this->addUserToGroupWithCheck(user: $user, groupName: $groupName, type: 'organization-admin'); - } - - $this->_logger->info( - 'Assigned organization admin groups to first contact', - [ - 'username' => $user->getUID(), - 'organizationId' => $organizationId, - 'adminGroups' => $organizationAdminGroups, - ] - ); - } + // A first contact is NOT added to the organisation admin groups. + // That automatic assignment was switched off on purpose in commit + // bc4dc9ea ("users should be assigned groups explicitly via the + // admin UI"), by making SettingsService::getOrganizationAdminGroups() + // return an empty list. stackiq#1136 made that getter read the saved + // list again for the settings page and the export permission, so + // the assignment is left out here instead of being revived. // Assign role based on organization type. if (empty($organizationId) === false) { @@ -1616,15 +1604,15 @@ private function getRoleGroupByOrganizationType(string $organizationType): strin // Normalize the organization type to lowercase for comparison. $normalizedType = strtolower(trim($organizationType)); - // Define the mapping based on requirements:. - // "Municipality" -> "gebruik-beheerder". - // "Supplier" -> "aanbod-beheerder". - // "Collaboration" -> "gebruik-beheerder". - // "Community" -> "aanbod-beheerder". + // Keyed on the organization.type enum as stored (Municipality, + // Supplier, Collaboration, Community), lower-cased. #520 translated the + // enum and migrated the rows, but this map kept the Dutch keys + // (gemeente, leverancier, samenwerking), so only Community matched and + // municipal and supplier contacts got no role group (stackiq#1137). $typeToRoleMapping = [ - 'gemeente' => 'gebruik-beheerder', - 'leverancier' => 'aanbod-beheerder', - 'samenwerking' => 'gebruik-beheerder', + 'municipality' => 'gebruik-beheerder', + 'supplier' => 'aanbod-beheerder', + 'collaboration' => 'gebruik-beheerder', 'community' => 'aanbod-beheerder', ]; diff --git a/lib/Service/Stackiq/GroupHandler.php b/lib/Service/Stackiq/GroupHandler.php index b972aa257..580b0d216 100644 --- a/lib/Service/Stackiq/GroupHandler.php +++ b/lib/Service/Stackiq/GroupHandler.php @@ -297,14 +297,24 @@ public function updateRoleBasedGroups(IUser $user, array $objectData): void { ] ); + // Role names are compared without regard to case: the roles enum is + // capitalised (Aanbod-beheerder) while the role groups are lower case + // (aanbod-beheerder), stackiq#1137. + $userRolesLower = array_map( + static function ($role): string { + return strtolower(trim((string)$role)); + }, + $userRoles + ); + // Get the configured generic user groups. - $genericGroups = $genericGroups = $this->getGenericUserGroups(); + $genericGroups = $this->getGenericUserGroups(); foreach ($genericGroups as $groupName) { $group = $this->createGroupIfNotExists(groupName: $groupName); if ($group !== null) { - $hasRole = in_array(needle: $groupName, haystack: $userRoles); + $hasRole = in_array(needle: strtolower(trim($groupName)), haystack: $userRolesLower, strict: true); $inGroup = $group->inGroup($user); if ($hasRole === true && $inGroup === false) { diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 21c0db91c..96d6ab72d 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -225,13 +225,14 @@ public function getView(string $viewId, array $options = []): array { /** * Get views from OpenRegister system with response-level caching. * - * Results are cached for 30 minutes to prevent cold-start delays. + * Results are cached for 30 minutes to prevent cold-start delays, per + * caller (see getViewsCacheKey()). * * @return array Array of view objects */ private function getViewsFromRegister(): array { // Check cache first. - $cacheKey = 'views_list'; + $cacheKey = $this->getViewsCacheKey(); $cached = $this->viewsCache->get(key: $cacheKey); if ($cached !== null) { $this->logger->debug( @@ -297,6 +298,30 @@ private function getViewsFromRegister(): array { return $views; }//end getViewsFromRegister() + /** + * Build the views list cache key for the current caller. + * + * The list is read with OpenRegister's RBAC and multitenancy on, so what + * it holds depends on the user and on their active organisation. The key + * carries both: one caller's scoped list is never served to another, and a + * user who switches organisation does not keep the list of the old one. + * + * @return string The cache key. + * + * @spec openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-006-drawn-views-shall-stay-inside-the-organisation-that-drew-them + */ + private function getViewsCacheKey(): string { + $userId = ''; + $user = $this->userSession->getUser(); + if ($user !== null) { + $userId = $user->getUID(); + } + + $organisation = $this->getCurrentOrganisation() ?? ''; + + return 'views_list_'.hash(algo: 'sha256', data: $userId."\n".$organisation); + }//end getViewsCacheKey() + /** * Get a specific view from OpenRegister system. * @@ -320,13 +345,15 @@ private function getViewFromRegister(string $viewId): ?array { } try { - // Get specific view object by ID. + // Get specific view object by ID, with OpenRegister's RBAC and + // multitenancy checks on (the defaults), the same checks the list + // reads with. A view the caller may not read comes back as null + // (or throws), which getView() answers as not found, so a uuid is + // no way around the list's scope. $view = $objectService->find( id: $viewId, register: $registerId, - schema: $viewSchemaId, - _rbac: false, - _multitenancy: false + schema: $viewSchemaId ); // Serialise the entity to an array. No `is_array()` / `method_exists()` diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json new file mode 100644 index 000000000..2e8c10bd2 --- /dev/null +++ b/lib/Settings/connections.json @@ -0,0 +1,46 @@ +{ + "app": "stackiq", + "connections": [ + { + "key": "email", + "title": "Email", + "description": "Sends registration, activation and account mails to organisations and their users.", + "order": 10, + "settingsUrl": "/settings/admin/stackiq#section-email", + "adapter": { + "configKey": "email_transport_type", + "simulatedValues": ["null"], + "simulatedMessage": "The null transport is selected, so no mail leaves stackiq. Pick a real transport in the Email configuration section." + }, + "unconfiguredMessage": "Not checked yet. Save the Email configuration section, and stackiq checks the settings." + }, + { + "key": "federation", + "title": "Catalog federation", + "description": "Announces this catalog to directory.opencatalogi.nl and pulls published entries from peer catalogs, through OpenCatalogi.", + "order": 20, + "settingsUrl": "/settings/admin/stackiq#section-federation", + "reportedOnly": true, + "switch": { + "configKey": "federation_enabled" + }, + "disabledMessage": "Federation is switched off. Set the federation_enabled app setting to true with occ to switch it on.", + "unconfiguredMessage": "Not checked yet. Choose Pull now in the Catalog federation section to check the peers." + }, + { + "key": "eol-feed", + "title": "End-of-life feed", + "description": "Reads product cycles from endoflife.date through integriq, and stamps end-of-support dates on module versions.", + "order": 30, + "settingsUrl": "/settings/admin/stackiq#section-eol-sync", + "reportedOnly": true, + "switch": { + "configKey": "eol_sync_config", + "jsonPath": "enabled" + }, + "disabledMessage": "End-of-life sync is switched off. Switch it on in the End-of-life feed sync section.", + "sourceTemplate": "endoflife-date", + "unconfiguredMessage": "Not checked yet. Choose Sync now in the End-of-life feed sync section." + } + ] +} diff --git a/lib/Settings/register.d/contracts-licence-seats.json b/lib/Settings/register.d/contracts-licence-seats.json new file mode 100644 index 000000000..3f301991d --- /dev/null +++ b/lib/Settings/register.d/contracts-licence-seats.json @@ -0,0 +1,50 @@ +{ + "components": { + "schemas": { + "catalogContract": { + "version": "0.1.3", + "properties": { + "licenceMetric": { + "type": "string", + "enum": [ + "Per named user", + "Per concurrent user", + "Per device", + "Per inhabitant", + "Per organisation", + "Other" + ], + "x-enum-labels": { + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other" + }, + "title": "Licence metric", + "description": "What one licence covers, as the contract with the supplier states it.", + "facetable": true, + "order": 14 + }, + "licencesBought": { + "type": "integer", + "minimum": 0, + "title": "Licences bought", + "description": "The number of licences this contract buys.", + "facetable": false, + "order": 15 + }, + "licencesInUse": { + "type": "integer", + "minimum": 0, + "title": "Licences in use", + "description": "The number of licences in use today, as the application owner counted them.", + "facetable": false, + "order": 16 + } + } + } + } + } +} diff --git a/lib/Settings/register.d/maintenance-and-roadmap.json b/lib/Settings/register.d/maintenance-and-roadmap.json new file mode 100644 index 000000000..a974dc1cd --- /dev/null +++ b/lib/Settings/register.d/maintenance-and-roadmap.json @@ -0,0 +1,20 @@ +{ + "components": { + "schemas": { + "module": { + "version": "0.3.4", + "properties": { + "roadmapStatement": { + "type": "string", + "format": "markdown", + "title": "Roadmap", + "description": "The direction the supplier takes with this application: what the next versions bring and when.", + "maxLength": 5000, + "facetable": false, + "order": 4 + } + } + } + } + } +} diff --git a/lib/Settings/register.d/value-assessment.json b/lib/Settings/register.d/value-assessment.json new file mode 100644 index 000000000..29d4d4b91 --- /dev/null +++ b/lib/Settings/register.d/value-assessment.json @@ -0,0 +1,145 @@ +{ + "components": { + "schemas": { + "usage": { + "version": "1.5.3", + "properties": { + "businessValue": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "title": "Business value", + "description": "How much the organisation depends on this application, from 1 (little) to 5 (critical).", + "visible": true, + "facetable": true, + "order": 35, + "example": "4" + }, + "technicalFit": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "title": "Technical fit", + "description": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).", + "visible": true, + "facetable": true, + "order": 36, + "example": "4" + }, + "riskScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "title": "Risk", + "description": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.", + "visible": true, + "facetable": true, + "order": 37, + "example": "4" + }, + "scoredOn": { + "type": "string", + "format": "date", + "title": "Scored on", + "description": "The date the scores were set.", + "visible": true, + "facetable": false, + "order": 38, + "example": "2026-10-01" + }, + "suggestedTimeClassification": { + "type": "string", + "enum": [ + "Tolerate", + "Invest", + "Migrate", + "Eliminate" + ], + "title": "Suggested TIME classification", + "description": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.", + "visible": true, + "facetable": true, + "hideOnForm": true, + "order": 39 + } + }, + "configuration": { + "x-openregister-calculations": { + "suggestedTimeClassification": { + "type": "string", + "materialise": true, + "description": "Invest when business value and technical fit are both 3 or more, Migrate when only value is, Tolerate when only fit is, Eliminate when neither is; empty while either score is missing.", + "expression": { + "if": [ + { + "or": [ + { + "eq": [ + { + "prop": "businessValue" + }, + null + ] + }, + { + "eq": [ + { + "prop": "technicalFit" + }, + null + ] + } + ] + }, + { + "lit": null + }, + { + "if": [ + { + "gte": [ + { + "prop": "businessValue" + }, + 3 + ] + }, + { + "if": [ + { + "gte": [ + { + "prop": "technicalFit" + }, + 3 + ] + }, + "Invest", + "Migrate" + ] + }, + { + "if": [ + { + "gte": [ + { + "prop": "technicalFit" + }, + 3 + ] + }, + "Tolerate", + "Eliminate" + ] + } + ] + } + ] + } + } + } + } + } + } + } +} diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 7eb803a5a..85f71c906 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -3,8 +3,8 @@ "info": { "title": "Software Catalog Register", "description": "Register containing AMEF and Voorzieningen schemas for the VNG Software Catalog application. This configuration includes schemas for applications, services, organizations, and compliance tracking.", - "version": "2.5.0", - "changelog": "2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." + "version": "2.5.5", + "changelog": "2.5.5: the maintenanceWindow schema (0.1.0) joins the stackiq register, for maintenance a supplier plans on its products, with the owners of every usage notified (lifecycle-maintenance-and-supplier-roadmap). 2.5.4: usage (1.5.2) gains businessOwner and technicalOwner, contact persons of the consumer organisation; a usage is named after its application and organisation; status becomes facetable for the Applications in use filters (landscape-usage-registration). 2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." }, "x-openregister": { "type": "application", @@ -835,7 +835,9 @@ "compliancy", "moduleVersion", "sbomComponent", - "bioMeasure" + "bioMeasure", + "aiSystem", + "maintenanceWindow" ], "source": "internal", "tablePrefix": "", @@ -2656,7 +2658,7 @@ "slug": "usage", "title": "Usage", "description": "Het gebruik van applicaties, diensten en koppelingen door afnemers", - "version": "1.5.0", + "version": "1.5.2", "omschrijving": "", "icon": "Gauge", "x-openregister-notifications": { @@ -2715,6 +2717,8 @@ "title": "Contact person", "order": 3 }, + "businessOwner": {"type": "object", "title": "Business owner", "description": "The person in the organisation who is responsible for how the application is used.", "facetable": false, "objectConfiguration": {"handling": "related-object"}, "$ref": "#/components/schemas/contactPerson", "x-relation-filter": {"organization": "@object.consumer"}, "order": 18}, + "technicalOwner": {"type": "object", "title": "Technical owner", "description": "The person in the organisation who is responsible for running and maintaining the application.", "facetable": false, "objectConfiguration": {"handling": "related-object"}, "$ref": "#/components/schemas/contactPerson", "x-relation-filter": {"organization": "@object.consumer"}, "order": 19}, "participants": { "description": "De organisaties die deelnemen aan dit gebruik (voor samenwerkingen)", "type": "array", @@ -2894,7 +2898,7 @@ "To be phased out", "Phased out" ], - "facetable": false, + "facetable": true, "title": "Status", "example": "Bijvoorbeeld: Gepland" }, @@ -3199,7 +3203,7 @@ ] }, "configuration": { - "objectNameField": "consumer", + "objectNameField": "{{ module }} ({{ consumer }})", "objectDescriptionField": "module", "allowFiles": true, "allowedTags": [ @@ -3210,37 +3214,37 @@ "autoPublish": false, "x-openregister-lifecycle": { "field": "status", - "initial": "Verwerving", + "initial": "Acquisition", "final": [ - "Uitgefaseerd" + "Phased out" ], "transitions": { "plan": { "from": [ - "Verwerving" + "Acquisition" ], - "to": "Gepland", + "to": "Planned", "description": "Plan the usage." }, "goLive": { "from": [ - "Gepland" + "Planned" ], - "to": "In productie", + "to": "In production", "description": "Go live with the usage." }, "phaseOut": { "from": [ - "In productie" + "In production" ], - "to": "Uit te faseren", + "to": "To be phased out", "description": "Mark the usage to be phased out." }, "retire": { "from": [ - "Uit te faseren" + "To be phased out" ], - "to": "Uitgefaseerd", + "to": "Phased out", "description": "Retire the usage." } } @@ -3267,7 +3271,7 @@ }, "title": "Contract", "description": "Een formele overeenkomst voor het inzetten van een Dienst op een Gebruik. Beschrijft de inkooprelatie achter een Gebruik (welke organisatie welke module onder welke voorwaarden gebruikt): looptijd, kosten, contracttype en status.", - "version": "0.1.1", + "version": "0.1.2", "omschrijving": "", "icon": "FileSign", "required": [ @@ -3530,30 +3534,30 @@ ], "x-openregister-lifecycle": { "field": "status", - "initial": "In onderhandeling", + "initial": "In negotiation", "final": [ - "Verlopen" + "Expired" ], "transitions": { "sign": { "from": [ - "In onderhandeling" + "In negotiation" ], - "to": "Actief", + "to": "Active", "description": "Sign and activate the contract." }, "expire": { "from": [ - "Actief" + "Active" ], - "to": "Verlopen", + "to": "Expired", "description": "Mark the contract as expired." }, "renegotiate": { "from": [ - "Verlopen" + "Expired" ], - "to": "In onderhandeling", + "to": "In negotiation", "description": "Re-open negotiation on an expired contract." } } @@ -3565,7 +3569,7 @@ "slug": "connection", "title": "Connection", "description": "Schema voor koppelingen tussen applicaties en systemen. ApplicatieB is voor koppelingen met andere applicaties. BuitengemeentelijkVoorziening is voor koppelingen met externe voorzieningen.", - "version": "0.3.1", + "version": "0.3.3", "omschrijving": "", "icon": "TransitConnectionVariant", "required": [ @@ -3579,7 +3583,7 @@ "required": true, "facetable": false, "title": "Name", - "default": "{{ moduleA }} {{ gegevensuitwisselingRichting | map: AnaarB=→, BnaarA=←, bi-directioneel=↔ }} {{ moduleB | buitengemeentelijkVoorziening }}", + "default": "{{ moduleA }} {{ dataExchangeDirection | map: AtoB=→, BtoA=←, bi-directional=↔ }} {{ moduleB | nonMunicipalProvision }}", "defaultBehavior": "falsy", "table": { "default": true @@ -3610,7 +3614,7 @@ "description": "kies de techniek van de koppeling", "type": "string", "order": 1, - "facetable": false, + "facetable": true, "title": "Transport protocol", "enum": [ "n/a", @@ -3627,7 +3631,7 @@ "description": "Geef de status van de koppeling aan, default 'in gebruik'", "type": "string", "order": 3, - "facetable": false, + "facetable": true, "title": "Status", "default": "in use", "table": { @@ -3678,7 +3682,7 @@ "type": "string", "order": 8, "title": "Data exchange direction", - "facetable": false, + "facetable": true, "required": true, "enum": [ "AtoB", @@ -3728,7 +3732,7 @@ "title": "Inter-municipal facility", "objectConfiguration": { "handling": "related-object", - "queryParams": "gemmaType=Buitengemeentenlijke voorziening" + "queryParams": "gemmaType=Buitengemeentelijke voorziening" }, "$ref": "#/components/schemas/element" }, @@ -3920,36 +3924,36 @@ }, "configuration": { "autoPublish": false, - "objectNameField": "{{ moduleA }} {{ gegevensuitwisselingRichting | map: AnaarB=→, BnaarA=←, bi-directioneel=↔ }} {{ moduleB | buitengemeentelijkVoorziening }}", + "objectNameField": "{{ moduleA }} {{ dataExchangeDirection | map: AtoB=→, BtoA=←, bi-directional=↔ }} {{ moduleB | nonMunicipalProvision }}", "objectSummaryField": "shortDescription", "objectDescriptionField": "longDescription", "x-openregister-lifecycle": { "field": "status", - "initial": "in ontwikkeling", + "initial": "in development", "final": [ - "teruggetrokken" + "withdrawn" ], "transitions": { "release": { "from": [ - "in ontwikkeling" + "in development" ], - "to": "in gebruik", + "to": "in use", "description": "Release the koppeling." }, "sunset": { "from": [ - "in gebruik" + "in use" ], - "to": "einde ondersteuning", + "to": "end of support", "description": "Mark the koppeling as end-of-support." }, "withdraw": { "from": [ - "in gebruik", - "einde ondersteuning" + "in use", + "end of support" ], - "to": "teruggetrokken", + "to": "withdrawn", "description": "Withdraw the koppeling." } } @@ -6987,7 +6991,6 @@ "BSD License (Berkeley Software Distribution)", "European Union Public Licence (EUPL), version 1.2" ], - "licence": "License", "title": "License" }, "referenceComponents": { @@ -7680,7 +7683,7 @@ }, "title": "Application version", "description": "Schema voor applicatieversies", - "version": "0.1.4", + "version": "0.1.5", "omschrijving": "", "icon": "ViewModule", "required": [], @@ -7887,31 +7890,31 @@ "autoPublish": true, "x-openregister-lifecycle": { "field": "status", - "initial": "in ontwikkeling", + "initial": "in development", "final": [ - "teruggetrokken" + "withdrawn" ], "transitions": { "release": { "from": [ - "in ontwikkeling" + "in development" ], - "to": "in gebruik", + "to": "in use", "description": "Release the module version." }, "sunset": { "from": [ - "in gebruik" + "in use" ], - "to": "einde ondersteuning", + "to": "end of support", "description": "Mark the module version as end-of-support." }, "withdraw": { "from": [ - "in gebruik", - "einde ondersteuning" + "in use", + "end of support" ], - "to": "teruggetrokken", + "to": "withdrawn", "description": "Withdraw the module version." } } @@ -8055,6 +8058,596 @@ "objectDescriptionField": "purl", "autoPublish": true } + }, + "aiSystem": { + "uri": null, + "slug": "aiSystem", + "title": "AI system", + "x-schema-org": "schema:SoftwareApplication", + "description": "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.", + "version": "0.1.0", + "icon": "RobotOutline", + "required": [ + "name" + ], + "source": "internal", + "hardValidation": false, + "immutable": false, + "searchable": true, + "maxDepth": 0, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name the organisation uses for this AI system.", + "facetable": false, + "order": 1, + "table": { + "default": true + } + }, + "description": { + "type": "string", + "format": "markdown", + "title": "Description", + "description": "What the AI system is and how it is used.", + "facetable": false, + "order": 2 + }, + "kind": { + "type": "string", + "enum": [ + "AI agent", + "AI model", + "AI feature" + ], + "x-enum-labels": { + "AI agent": "AI agent", + "AI model": "AI model", + "AI feature": "AI feature" + }, + "title": "Kind", + "description": "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.", + "facetable": true, + "order": 3, + "table": { + "default": true + } + }, + "module": { + "type": "object", + "$ref": "#/components/schemas/module", + "objectConfiguration": { + "handling": "related-object" + }, + "inversedBy": "aiSystems", + "title": "Application", + "description": "The application this AI system runs in or supports.", + "facetable": true, + "order": 4, + "table": { + "default": true + } + }, + "provider": { + "type": "object", + "$ref": "#/components/schemas/organization", + "objectConfiguration": { + "handling": "related-object" + }, + "title": "Supplier", + "description": "The organisation that supplies the AI system.", + "facetable": true, + "order": 5 + }, + "purpose": { + "type": "string", + "title": "Purpose", + "description": "What the AI system decides, recommends or produces.", + "facetable": false, + "order": 6 + }, + "aiActRiskCategory": { + "type": "string", + "enum": [ + "prohibited", + "high risk", + "limited risk", + "minimal risk", + "not yet assessed" + ], + "x-enum-labels": { + "prohibited": "Prohibited", + "high risk": "High risk", + "limited risk": "Limited risk", + "minimal risk": "Minimal risk", + "not yet assessed": "Not yet assessed" + }, + "default": "not yet assessed", + "title": "AI Act risk category", + "description": "The risk category under the EU AI Act, as the organisation classified it.", + "facetable": true, + "order": 7, + "table": { + "default": true + } + }, + "aiActRole": { + "type": "string", + "enum": [ + "provider", + "deployer" + ], + "x-enum-labels": { + "provider": "Provider", + "deployer": "Deployer" + }, + "title": "Role under the AI Act", + "description": "Whether the organisation provides the AI system or deploys it.", + "facetable": true, + "order": 8 + }, + "algorithmRegisterUrl": { + "type": "string", + "format": "uri", + "title": "Algorithm register entry", + "description": "The link to this system in the Dutch algorithm register.", + "facetable": false, + "order": 9 + }, + "assessedOn": { + "type": "string", + "format": "date", + "title": "Last assessed on", + "description": "The date the classification was last assessed.", + "facetable": false, + "order": 10 + }, + "friaDocumentRef": { + "type": "string", + "title": "Fundamental rights impact assessment", + "description": "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.", + "facetable": false, + "order": 11 + }, + "status": { + "type": "string", + "enum": [ + "in development", + "in use", + "withdrawn" + ], + "x-enum-labels": { + "in development": "In development", + "in use": "In use", + "withdrawn": "Withdrawn" + }, + "default": "in use", + "title": "Status", + "description": "Where the AI system stands in its lifecycle.", + "facetable": true, + "order": 12, + "table": { + "default": true + } + } + }, + "configuration": { + "objectNameField": "name", + "objectDescriptionField": "purpose", + "allowFiles": true, + "allowedTags": [ + "FRIA", + "Technical documentation", + "Human oversight", + "Logging" + ], + "autoPublish": false, + "x-openregister-lifecycle": { + "field": "status", + "initial": "in development", + "final": [ + "withdrawn" + ], + "transitions": { + "release": { + "from": [ + "in development" + ], + "to": "in use", + "description": "Take the AI system into use." + }, + "withdraw": { + "from": [ + "in development", + "in use" + ], + "to": "withdrawn", + "description": "Withdraw the AI system." + } + } + } + }, + "authorization": { + "create": [ + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "functioneel-beheerder", + "gebruik-beheerder", + "aanbod-beheerder" + ], + "read": [ + "software-catalog-admins", + { + "group": "organisatie-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "organisaties-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "functioneel-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "gebruik-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "aanbod-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "aanbod-beheerder", + "match": { + "provider": "$organisation" + } + } + ], + "update": [ + "software-catalog-admins", + { + "group": "organisatie-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "organisaties-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "functioneel-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "gebruik-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ], + "delete": [ + "software-catalog-admins", + { + "group": "organisatie-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "organisaties-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "functioneel-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "gebruik-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ] + } + }, + "maintenanceWindow": { + "uri": null, + "slug": "maintenanceWindow", + "title": "Maintenance window", + "x-schema-org": "schema:Event", + "description": "Maintenance a supplier plans on one of its products, with the time window and the expected impact.", + "version": "0.1.0", + "icon": "Calendar", + "required": [ + "module", + "title", + "startsAt", + "endsAt" + ], + "source": "internal", + "hardValidation": false, + "immutable": false, + "searchable": true, + "maxDepth": 0, + "properties": { + "module": { + "type": "object", + "$ref": "#/components/schemas/module", + "objectConfiguration": { + "handling": "related-object" + }, + "title": "Application", + "description": "The product the maintenance is on.", + "facetable": true, + "order": 1, + "table": { + "default": true + } + }, + "moduleVersion": { + "type": "object", + "$ref": "#/components/schemas/moduleVersion", + "objectConfiguration": { + "handling": "related-object" + }, + "x-relation-filter": { + "module": "@object.module" + }, + "title": "Version", + "description": "The version the maintenance is on, when it concerns one version.", + "facetable": false, + "order": 2 + }, + "title": { + "type": "string", + "title": "Title", + "description": "What the maintenance is, in a few words.", + "maxLength": 255, + "facetable": false, + "order": 3, + "table": { + "default": true + } + }, + "description": { + "type": "string", + "format": "markdown", + "title": "Description", + "description": "What changes and what users should do.", + "maxLength": 5000, + "facetable": false, + "order": 4 + }, + "startsAt": { + "type": "string", + "format": "date-time", + "title": "Starts at", + "description": "When the maintenance starts.", + "facetable": false, + "order": 5, + "table": { + "default": true + } + }, + "endsAt": { + "type": "string", + "format": "date-time", + "title": "Ends at", + "description": "When the maintenance ends.", + "facetable": false, + "order": 6, + "table": { + "default": true + } + }, + "impact": { + "type": "string", + "enum": [ + "no impact", + "degraded", + "unavailable" + ], + "x-enum-labels": { + "no impact": "No impact", + "degraded": "Degraded", + "unavailable": "Unavailable" + }, + "default": "degraded", + "title": "Impact", + "description": "What users notice while the maintenance runs.", + "facetable": true, + "order": 7, + "table": { + "default": true + } + }, + "status": { + "type": "string", + "enum": [ + "planned", + "in progress", + "completed", + "cancelled" + ], + "x-enum-labels": { + "planned": "Planned", + "in progress": "In progress", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "default": "planned", + "title": "Status", + "description": "Where the maintenance stands.", + "facetable": true, + "order": 8, + "table": { + "default": true + } + }, + "notifyUserIds": { + "type": "array", + "items": { + "type": "string" + }, + "hideOnForm": true, + "visible": false, + "title": "Owners to notify", + "description": "The Nextcloud users who own a usage of the product, resolved when the maintenance is announced.", + "facetable": false, + "order": 9 + }, + "recipientsResolvedAt": { + "type": "string", + "format": "date-time", + "hideOnForm": true, + "visible": false, + "title": "Owners resolved at", + "description": "When the owners to notify were resolved.", + "facetable": false, + "order": 10 + } + }, + "configuration": { + "objectNameField": "title", + "objectDescriptionField": "description", + "autoPublish": true, + "x-openregister-lifecycle": { + "field": "status", + "initial": "planned", + "final": [ + "completed", + "cancelled" + ], + "transitions": { + "start": { + "from": [ + "planned" + ], + "to": "in progress", + "description": "Start the maintenance." + }, + "complete": { + "from": [ + "in progress" + ], + "to": "completed", + "description": "Complete the maintenance." + }, + "cancel": { + "from": [ + "planned", + "in progress" + ], + "to": "cancelled", + "description": "Cancel the maintenance." + } + } + } + }, + "x-openregister-notifications": { + "maintenance-announced": { + "trigger": { + "type": "updated", + "condition": { + "field": "recipientsResolvedAt", + "operator": "changed" + } + }, + "enabled": true, + "channels": [ + "nc-notification" + ], + "recipients": [ + { + "kind": "relation", + "relation": "notifyUserIds" + } + ], + "subject": { + "nl": "Gepland onderhoud: {{title}}", + "en": "Planned maintenance: {{title}}" + } + }, + "maintenance-starts-tomorrow": { + "trigger": { + "type": "scheduled", + "intervalSec": 86400, + "filter": { + "startsAt": { + "operator": "withinNext", + "value": "P1D" + }, + "status": { + "operator": "equals", + "value": "planned" + } + } + }, + "enabled": true, + "channels": [ + "nc-notification" + ], + "recipients": [ + { + "kind": "relation", + "relation": "notifyUserIds" + } + ], + "subject": { + "nl": "Onderhoud begint morgen: {{title}}", + "en": "Maintenance starts tomorrow: {{title}}" + } + } + }, + "authorization": { + "create": [ + "software-catalog-admins", + "aanbod-beheerder" + ], + "read": [ + "public" + ], + "update": [ + "software-catalog-admins", + { + "group": "aanbod-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ], + "delete": [ + "software-catalog-admins", + { + "group": "aanbod-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ] + } } }, "objects": [ @@ -8066,7 +8659,7 @@ "version": "0.0.1" }, "name": "Topdesk bij Servicecenter Rijnland (SSC)", - "status": "in-gebruik", + "status": "In production", "elementRef": "topdesk-ssc-rijnland", "consumer": { "name": "Servicecenter Rijnland", @@ -8095,7 +8688,7 @@ "version": "0.0.1" }, "name": "KEY2 Burgerzaken gedeeld via GBLT", - "status": "in-gebruik", + "status": "Planned", "elementRef": "key2-burgerzaken-gblt", "consumer": { "name": "Gemeentebelastingen Coevorden Hardenberg (GBLT)", @@ -8119,7 +8712,7 @@ "version": "0.0.1" }, "name": "Suite4 Schuldhulpverlening - eigenaar Gemeente Delft", - "status": "in-gebruik", + "status": "In production", "elementRef": "suite4-schuldhulp-delft", "consumer": { "name": "Gemeente Delft", diff --git a/lib/Settings/stackiq_mock_register.json b/lib/Settings/stackiq_mock_register.json index 392c5ac03..c13d6d0ba 100644 --- a/lib/Settings/stackiq_mock_register.json +++ b/lib/Settings/stackiq_mock_register.json @@ -511,7 +511,7 @@ "slug": "connection", "title": "Connection", "description": "Schema voor koppelingen tussen applicaties en systemen. ApplicatieB is voor koppelingen met andere applicaties. BuitengemeentelijkVoorziening is voor koppelingen met externe voorzieningen.", - "version": "0.3.1", + "version": "0.3.2", "omschrijving": "", "icon": "TransitConnectionVariant", "required": [ @@ -871,31 +871,31 @@ "objectDescriptionField": "longDescription", "x-openregister-lifecycle": { "field": "status", - "initial": "in ontwikkeling", + "initial": "in development", "final": [ - "teruggetrokken" + "withdrawn" ], "transitions": { "release": { "from": [ - "in ontwikkeling" + "in development" ], - "to": "in gebruik", + "to": "in use", "description": "Release the koppeling." }, "sunset": { "from": [ - "in gebruik" + "in use" ], - "to": "einde ondersteuning", + "to": "end of support", "description": "Mark the koppeling as end-of-support." }, "withdraw": { "from": [ - "in gebruik", - "einde ondersteuning" + "in use", + "end of support" ], - "to": "teruggetrokken", + "to": "withdrawn", "description": "Withdraw the koppeling." } } @@ -1334,6 +1334,45 @@ }, "referenceType": "decision", "x-allow-create": true + }, + "licenceMetric": { + "type": "string", + "enum": [ + "Per named user", + "Per concurrent user", + "Per device", + "Per inhabitant", + "Per organisation", + "Other" + ], + "x-enum-labels": { + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other" + }, + "title": "Licence metric", + "description": "What one licence covers, as the contract with the supplier states it.", + "facetable": true, + "order": 14 + }, + "licencesBought": { + "type": "integer", + "minimum": 0, + "title": "Licences bought", + "description": "The number of licences this contract buys.", + "facetable": false, + "order": 15 + }, + "licencesInUse": { + "type": "integer", + "minimum": 0, + "title": "Licences in use", + "description": "The number of licences in use today, as the application owner counted them.", + "facetable": false, + "order": 16 } }, "required": [ @@ -1387,7 +1426,7 @@ }, "title": "Contract", "description": "Een formele overeenkomst voor het inzetten van een Dienst op een Gebruik. Beschrijft de inkooprelatie achter een Gebruik (welke organisatie welke module onder welke voorwaarden gebruikt): looptijd, kosten, contracttype en status.", - "version": "0.1.1", + "version": "0.1.3", "omschrijving": "", "icon": "FileSign", "archive": [], @@ -1499,30 +1538,30 @@ ], "x-openregister-lifecycle": { "field": "status", - "initial": "In onderhandeling", + "initial": "In negotiation", "final": [ - "Verlopen" + "Expired" ], "transitions": { "sign": { "from": [ - "In onderhandeling" + "In negotiation" ], - "to": "Actief", + "to": "Active", "description": "Sign and activate the contract." }, "expire": { "from": [ - "Actief" + "Active" ], - "to": "Verlopen", + "to": "Expired", "description": "Mark the contract as expired." }, "renegotiate": { "from": [ - "Verlopen" + "Expired" ], - "to": "In onderhandeling", + "to": "In negotiation", "description": "Re-open negotiation on an expired contract." } } @@ -3388,7 +3427,6 @@ "BSD License (Berkeley Software Distribution)", "European Union Public Licence (EUPL), version 1.2" ], - "licence": "License", "title": "License" }, "referenceComponents": { @@ -3874,7 +3912,7 @@ }, "title": "Application version", "description": "Schema voor applicatieversies", - "version": "0.1.4", + "version": "0.1.5", "omschrijving": "", "icon": "ViewModule", "required": [], @@ -4085,31 +4123,31 @@ "autoPublish": true, "x-openregister-lifecycle": { "field": "status", - "initial": "in ontwikkeling", + "initial": "in development", "final": [ - "teruggetrokken" + "withdrawn" ], "transitions": { "release": { "from": [ - "in ontwikkeling" + "in development" ], - "to": "in gebruik", + "to": "in use", "description": "Release the module version." }, "sunset": { "from": [ - "in gebruik" + "in use" ], - "to": "einde ondersteuning", + "to": "end of support", "description": "Mark the module version as end-of-support." }, "withdraw": { "from": [ - "in gebruik", - "einde ondersteuning" + "in use", + "end of support" ], - "to": "teruggetrokken", + "to": "withdrawn", "description": "Withdraw the module version." } } @@ -6123,7 +6161,7 @@ "slug": "usage", "title": "Usage", "description": "Het gebruik van applicaties, diensten en koppelingen door afnemers", - "version": "1.5.0", + "version": "1.5.1", "omschrijving": "", "icon": "Gauge", "x-openregister-notifications": { @@ -6699,37 +6737,37 @@ "autoPublish": false, "x-openregister-lifecycle": { "field": "status", - "initial": "Verwerving", + "initial": "Acquisition", "final": [ - "Uitgefaseerd" + "Phased out" ], "transitions": { "plan": { "from": [ - "Verwerving" + "Acquisition" ], - "to": "Gepland", + "to": "Planned", "description": "Plan the usage." }, "goLive": { "from": [ - "Gepland" + "Planned" ], - "to": "In productie", + "to": "In production", "description": "Go live with the usage." }, "phaseOut": { "from": [ - "In productie" + "In production" ], - "to": "Uit te faseren", + "to": "To be phased out", "description": "Mark the usage to be phased out." }, "retire": { "from": [ - "Uit te faseren" + "To be phased out" ], - "to": "Uitgefaseerd", + "to": "Phased out", "description": "Retire the usage." } } @@ -7305,6 +7343,319 @@ "objectDescriptionField": "longDescription", "autoPublish": false } + }, + "aiSystem": { + "uri": null, + "slug": "aiSystem", + "title": "AI system", + "x-schema-org": "schema:SoftwareApplication", + "description": "An AI agent, AI model or AI feature the organisation uses, with its EU AI Act classification.", + "version": "0.1.0", + "icon": "RobotOutline", + "required": [ + "name" + ], + "source": "internal", + "hardValidation": false, + "immutable": false, + "searchable": true, + "maxDepth": 0, + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name the organisation uses for this AI system.", + "facetable": false, + "order": 1, + "table": { + "default": true + } + }, + "description": { + "type": "string", + "format": "markdown", + "title": "Description", + "description": "What the AI system is and how it is used.", + "facetable": false, + "order": 2 + }, + "kind": { + "type": "string", + "enum": [ + "AI agent", + "AI model", + "AI feature" + ], + "x-enum-labels": { + "AI agent": "AI agent", + "AI model": "AI model", + "AI feature": "AI feature" + }, + "title": "Kind", + "description": "An AI agent acts on its own, an AI model is a trained model, an AI feature is part of an application.", + "facetable": true, + "order": 3, + "table": { + "default": true + } + }, + "module": { + "type": "object", + "$ref": "#/components/schemas/module", + "objectConfiguration": { + "handling": "related-object" + }, + "inversedBy": "aiSystems", + "title": "Application", + "description": "The application this AI system runs in or supports.", + "facetable": true, + "order": 4, + "table": { + "default": true + } + }, + "provider": { + "type": "object", + "$ref": "#/components/schemas/organization", + "objectConfiguration": { + "handling": "related-object" + }, + "title": "Supplier", + "description": "The organisation that supplies the AI system.", + "facetable": true, + "order": 5 + }, + "purpose": { + "type": "string", + "title": "Purpose", + "description": "What the AI system decides, recommends or produces.", + "facetable": false, + "order": 6 + }, + "aiActRiskCategory": { + "type": "string", + "enum": [ + "prohibited", + "high risk", + "limited risk", + "minimal risk", + "not yet assessed" + ], + "x-enum-labels": { + "prohibited": "Prohibited", + "high risk": "High risk", + "limited risk": "Limited risk", + "minimal risk": "Minimal risk", + "not yet assessed": "Not yet assessed" + }, + "default": "not yet assessed", + "title": "AI Act risk category", + "description": "The risk category under the EU AI Act, as the organisation classified it.", + "facetable": true, + "order": 7, + "table": { + "default": true + } + }, + "aiActRole": { + "type": "string", + "enum": [ + "provider", + "deployer" + ], + "x-enum-labels": { + "provider": "Provider", + "deployer": "Deployer" + }, + "title": "Role under the AI Act", + "description": "Whether the organisation provides the AI system or deploys it.", + "facetable": true, + "order": 8 + }, + "algorithmRegisterUrl": { + "type": "string", + "format": "uri", + "title": "Algorithm register entry", + "description": "The link to this system in the Dutch algorithm register.", + "facetable": false, + "order": 9 + }, + "assessedOn": { + "type": "string", + "format": "date", + "title": "Last assessed on", + "description": "The date the classification was last assessed.", + "facetable": false, + "order": 10 + }, + "friaDocumentRef": { + "type": "string", + "title": "Fundamental rights impact assessment", + "description": "A reference to the fundamental rights impact assessment (FRIA). A high-risk system without one is flagged.", + "facetable": false, + "order": 11 + }, + "status": { + "type": "string", + "enum": [ + "in development", + "in use", + "withdrawn" + ], + "x-enum-labels": { + "in development": "In development", + "in use": "In use", + "withdrawn": "Withdrawn" + }, + "default": "in use", + "title": "Status", + "description": "Where the AI system stands in its lifecycle.", + "facetable": true, + "order": 12, + "table": { + "default": true + } + } + }, + "configuration": { + "objectNameField": "name", + "objectDescriptionField": "purpose", + "allowFiles": true, + "allowedTags": [ + "FRIA", + "Technical documentation", + "Human oversight", + "Logging" + ], + "autoPublish": false, + "x-openregister-lifecycle": { + "field": "status", + "initial": "in development", + "final": [ + "withdrawn" + ], + "transitions": { + "release": { + "from": [ + "in development" + ], + "to": "in use", + "description": "Take the AI system into use." + }, + "withdraw": { + "from": [ + "in development", + "in use" + ], + "to": "withdrawn", + "description": "Withdraw the AI system." + } + } + } + }, + "authorization": { + "create": [ + "software-catalog-admins", + "organisatie-beheerder", + "organisaties-beheerder", + "functioneel-beheerder", + "gebruik-beheerder", + "aanbod-beheerder" + ], + "read": [ + "software-catalog-admins", + { + "group": "organisatie-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "organisaties-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "functioneel-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "gebruik-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "aanbod-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "aanbod-beheerder", + "match": { + "provider": "$organisation" + } + } + ], + "update": [ + "software-catalog-admins", + { + "group": "organisatie-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "organisaties-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "functioneel-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "gebruik-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ], + "delete": [ + "software-catalog-admins", + { + "group": "organisatie-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "organisaties-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "functioneel-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "gebruik-beheerder", + "match": { + "_organisation": "$organisation" + } + } + ] + } } }, "objects": [ @@ -7665,7 +8016,10 @@ "opmerkingen": "Voorbeeld Opmerkingen 2", "decisions": [ "00000000-0000-4000-8000-000000000001" - ] + ], + "licenceMetric": "Per named user", + "licencesBought": 400, + "licencesInUse": 460 }, { "@self": { @@ -7698,6 +8052,40 @@ "00000000-0000-4000-8000-000000000002" ] }, + { + "@self": { + "register": "stackiq", + "schema": "catalogContract", + "slug": "contract-seats-5" + }, + "service": {}, + "usage": {}, + "startDate": "2026-03-02", + "contractNumber": "Voorbeeld Contractnumber 5", + "contractType": "Licence", + "status": "Active", + "approvalDecisionId": "Voorbeeld Approvaldecisionid 2", + "approvalState": "none", + "endDate": "2026-03-02", + "cost": 2.0, + "costPeriod": "Annually", + "contactPersonProvider": { + "name": "Voorbeeld Name 2", + "email": "Voorbeeld Email 2" + }, + "contactPersonUser": { + "name": "Voorbeeld Name 2", + "email": "Voorbeeld Email 2" + }, + "documentReference": "Voorbeeld Documentreference 2", + "opmerkingen": "Voorbeeld Opmerkingen 2", + "decisions": [ + "00000000-0000-4000-8000-000000000001" + ], + "licenceMetric": "Per inhabitant", + "licencesBought": 58000, + "licencesInUse": 57120 + }, { "@self": { "register": "stackiq", @@ -8635,7 +9023,12 @@ "plannedReplacementDate": "2026-03-01", "timeClassification": "Tolerate", "timeRationale": "Voorbeeld Timerationale 1", - "timeReviewDate": "2026-03-01" + "timeReviewDate": "2026-03-01", + "businessValue": 1, + "technicalFit": 2, + "riskScore": 4, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Eliminate" }, { "@self": { @@ -8677,7 +9070,12 @@ "plannedReplacementDate": "2026-03-02", "timeClassification": "Invest", "timeRationale": "Voorbeeld Timerationale 2", - "timeReviewDate": "2026-03-02" + "timeReviewDate": "2026-03-02", + "businessValue": 5, + "technicalFit": 4, + "riskScore": 2, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Invest" }, { "@self": { @@ -8688,6 +9086,8 @@ "consumer": {}, "provider": {}, "contactPerson": {}, + "businessOwner": {}, + "technicalOwner": {}, "participants": [ {} ], @@ -8719,7 +9119,12 @@ "plannedReplacementDate": "2026-03-03", "timeClassification": "Migrate", "timeRationale": "Voorbeeld Timerationale 3", - "timeReviewDate": "2026-03-03" + "timeReviewDate": "2026-03-03", + "businessValue": 5, + "technicalFit": 2, + "riskScore": 3, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Migrate" }, { "@self": { @@ -10204,7 +10609,12 @@ "plannedReplacementDate": "2026-03-01", "timeClassification": "Tolerate", "timeRationale": "Voorbeeld Timerationale 1", - "timeReviewDate": "2026-03-01" + "timeReviewDate": "2026-03-01", + "businessValue": 2, + "technicalFit": 4, + "riskScore": 2, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Tolerate" }, { "@self": { @@ -10246,7 +10656,12 @@ "plannedReplacementDate": "2026-03-02", "timeClassification": "Invest", "timeRationale": "Voorbeeld Timerationale 2", - "timeReviewDate": "2026-03-02" + "timeReviewDate": "2026-03-02", + "businessValue": 4, + "technicalFit": 5, + "riskScore": 1, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Invest" }, { "@self": { @@ -10257,6 +10672,8 @@ "consumer": {}, "provider": {}, "contactPerson": {}, + "businessOwner": {}, + "technicalOwner": {}, "participants": [ {} ], @@ -10288,7 +10705,12 @@ "plannedReplacementDate": "2026-03-03", "timeClassification": "Migrate", "timeRationale": "Voorbeeld Timerationale 3", - "timeReviewDate": "2026-03-03" + "timeReviewDate": "2026-03-03", + "businessValue": 4, + "technicalFit": 1, + "riskScore": 5, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Migrate" }, { "@self": { @@ -10445,6 +10867,186 @@ "longDescription": "Voorbeeld markdown 2", "cveCode": "CVE-2345-2345", "cvssScore": 2.0 + }, + { + "@self": { + "register": "stackiq", + "schema": "aiSystem", + "slug": "ai-system-chat-assistant" + }, + "name": "Chat assistant", + "kind": "AI feature", + "module": {}, + "provider": {}, + "purpose": "Answers residents' questions about permits on the website and hands over to an employee.", + "aiActRiskCategory": "limited risk", + "aiActRole": "deployer", + "assessedOn": "2026-06-01", + "algorithmRegisterUrl": "https://algoritmes.overheid.nl/", + "status": "in use" + }, + { + "@self": { + "register": "stackiq", + "schema": "aiSystem", + "slug": "ai-system-benefit-scoring-model" + }, + "name": "Benefit application scoring model", + "kind": "AI model", + "module": {}, + "provider": {}, + "purpose": "Ranks benefit applications for a manual check.", + "aiActRiskCategory": "high risk", + "aiActRole": "deployer", + "assessedOn": "2026-05-15", + "status": "in development" + }, + { + "@self": { + "register": "stackiq", + "schema": "aiSystem", + "slug": "ai-system-mail-sorting-agent" + }, + "name": "Mail sorting agent", + "kind": "AI agent", + "module": {}, + "provider": {}, + "purpose": "Sorts incoming mail to the right team.", + "aiActRiskCategory": "minimal risk", + "aiActRole": "deployer", + "status": "in use" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "aiSystem", + "slug": "ai-system-chat-assistant-gemma" + }, + "name": "Chat assistant", + "kind": "AI feature", + "module": {}, + "provider": {}, + "purpose": "Answers residents' questions about permits on the website and hands over to an employee.", + "aiActRiskCategory": "limited risk", + "aiActRole": "deployer", + "assessedOn": "2026-06-01", + "algorithmRegisterUrl": "https://algoritmes.overheid.nl/", + "status": "in use" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "aiSystem", + "slug": "ai-system-benefit-scoring-model-gemma" + }, + "name": "Benefit application scoring model", + "kind": "AI model", + "module": {}, + "provider": {}, + "purpose": "Ranks benefit applications for a manual check.", + "aiActRiskCategory": "high risk", + "aiActRole": "deployer", + "assessedOn": "2026-05-15", + "status": "in development" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "aiSystem", + "slug": "ai-system-mail-sorting-agent-gemma" + }, + "name": "Mail sorting agent", + "kind": "AI agent", + "module": {}, + "provider": {}, + "purpose": "Sorts incoming mail to the right team.", + "aiActRiskCategory": "minimal risk", + "aiActRole": "deployer", + "status": "in use" + }, + { + "@self": { + "register": "stackiq", + "schema": "maintenanceWindow", + "slug": "maintenance-database-upgrade" + }, + "module": {}, + "title": "Database upgrade", + "description": "The supplier moves the application to a new database version. Log out before the window starts.", + "startsAt": "2026-10-10T06:00:00+02:00", + "endsAt": "2026-10-10T10:00:00+02:00", + "impact": "unavailable", + "status": "planned" + }, + { + "@self": { + "register": "stackiq", + "schema": "maintenanceWindow", + "slug": "maintenance-security-patch" + }, + "module": {}, + "title": "Security patch", + "description": "A security patch is installed; the application stays available but may respond slowly.", + "startsAt": "2026-10-17T20:00:00+02:00", + "endsAt": "2026-10-17T21:00:00+02:00", + "impact": "degraded", + "status": "planned" + }, + { + "@self": { + "register": "stackiq", + "schema": "maintenanceWindow", + "slug": "maintenance-storage-move" + }, + "module": {}, + "title": "Storage move", + "description": "Documents move to new storage. Nothing changes for users.", + "startsAt": "2026-10-24T22:00:00+02:00", + "endsAt": "2026-10-25T02:00:00+02:00", + "impact": "no impact", + "status": "completed" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "maintenanceWindow", + "slug": "maintenance-database-upgrade" + }, + "module": {}, + "title": "Database upgrade", + "description": "The supplier moves the application to a new database version. Log out before the window starts.", + "startsAt": "2026-10-10T06:00:00+02:00", + "endsAt": "2026-10-10T10:00:00+02:00", + "impact": "unavailable", + "status": "planned" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "maintenanceWindow", + "slug": "maintenance-security-patch" + }, + "module": {}, + "title": "Security patch", + "description": "A security patch is installed; the application stays available but may respond slowly.", + "startsAt": "2026-10-17T20:00:00+02:00", + "endsAt": "2026-10-17T21:00:00+02:00", + "impact": "degraded", + "status": "planned" + }, + { + "@self": { + "register": "vng-gemma", + "schema": "maintenanceWindow", + "slug": "maintenance-storage-move" + }, + "module": {}, + "title": "Storage move", + "description": "Documents move to new storage. Nothing changes for users.", + "startsAt": "2026-10-24T22:00:00+02:00", + "endsAt": "2026-10-25T02:00:00+02:00", + "impact": "no impact", + "status": "completed" } ] } diff --git a/openapi.json b/openapi.json index 264274cdb..9746b7596 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "stackiq", - "version": "0.2.2-unstable.20260910105110", + "version": "0.2.3-unstable.20260912202721", "description": "Stackiq", "license": { "name": "agpl" diff --git a/openspec/changes/adopt-connection-registry/.openspec.yaml b/openspec/changes/adopt-connection-registry/.openspec.yaml new file mode 100644 index 000000000..a40cb63c1 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/adopt-connection-registry/design.md b/openspec/changes/adopt-connection-registry/design.md new file mode 100644 index 000000000..be0bc86f9 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/design.md @@ -0,0 +1,96 @@ +# Design: adopt-connection-registry + +The contract is hydra `openspec/changes/connection-registry/design.md` (hydra#667, amended in hydra#673, with hydra#674 pending). This file records how stackiq meets it and where it fits loosely. + +## D1. Which connections are declared + +Each candidate was checked against the code on `development`. + +| Key | Declared as | Why | +|---|---|---| +| `email` | `adapter.configKey: email_transport_type`, `simulatedValues: ["null"]` | `SymfonyEmailService::createTransport()` builds `null://null` for `null`. | +| `federation` | `reportedOnly: true` | `FederationService` needs OpenCatalogi installed and the boolean key `federation_enabled`. Neither is readable as a filled string. | +| `eol-feed` | `reportedOnly: true`, `sourceTemplate: endoflife-date` | `EolSyncService::run()` reads integriq's `eol_product` and `eol_cycle` through OpenRegister. Integriq seeds the `endoflife-date` source. | + +**Why an empty transport is not simulated.** Stackiq reads a key that was never set with the default `smtp`. A key set to an empty or unknown value reaches the `default` branch of `createTransport()`, which builds an SMTP transport with a warning. An empty value sends real mail, so the contract default `[""]` would be a false Simulated. The file lists `null` only. + +**Why email has no `requiredConfig`.** Which keys a transport needs depends on the transport: a host for SMTP, an API key for SendGrid, nothing for sendmail. And `email_enabled` stores `false` as a filled string. No fixed key list can say "the settings are complete", so stackiq reports it (D2). + +**Why federation and eol-feed are reported only.** `federation_enabled` is typed boolean, and integriq's reader answers `typed` for it, which is a filled value. `eol_sync_config` is a JSON blob that is filled after any save, with `enabled` true or false. Rule 5 would read both as configured. + +**Anchors.** The admin section is `stackiq` (`StackiqAdmin::getSection()`), so each link is `/settings/admin/stackiq#section-…`. The three section components put the id on their `AlwaysVisibleSection`, whose root `NcSettingsSection` inherits it. + +## D2. What stackiq reports, and when + +`lib/Service/ConnectionReportService.php` sends both events. It names the classes by string behind `class_exists` (ADR-041) and never throws. + +Every save sends the refresh first and the report second. Under hydra#674 the refresh retires older observations, so a report sent before it would be retired by it. + +**Email, on an email settings save** (`POST /api/settings/email`, or `PUT` and `POST /api/settings` with `emailSettings`). Stackiq reads `SymfonyEmailService::isEmailSystemConfigured()`. + +| Stackiq sees | Status | Message | +|---|---|---| +| Email switched off | `unconfigured` | "Email is switched off, so stackiq sends no mail." | +| The transport misses what it needs | `unconfigured` | "Email is on, and the SMTP Server transport misses a setting it needs." | +| A required template is empty | `unconfigured` | "Email is on, and a required mail template is empty." | +| Everything filled | `configured` | "Email is on and the SMTP Server transport settings are filled. No test mail was sent." | + +The `null` transport still reads Simulated: rule 3 sits above every report. + +**Federation, on a peer add or remove.** Stackiq reads `FederationService::getStatus()`. + +| Stackiq sees | Status | Message | +|---|---|---| +| OpenCatalogi not installed | `unavailable` | "Federation needs the OpenCatalogi app, and it is not installed." | +| `federation_enabled` off | nothing: the refresh alone, and the switch makes integriq read `disabled` with the declared message | | +| No peers | `unconfigured` | "Federation is on, and no peer catalog is added yet." | +| Ready | nothing | the refresh alone, so the row reads the declared "Not checked yet" | + +**Federation, after a pull** (Pull now, or `FederationSyncJob`). The same three blocking states come from the pull's `reason`. Otherwise: + +| Peers that answered | Status | +|---|---| +| all | `configured` | +| some | `limited`, naming the first host that failed | +| none | `error`, naming the first host that failed | + +A message names a peer by host only, never by its full URL, and cuts a failure reason at 160 characters. + +**End-of-life feed, on an EOL sync settings save.** A refresh and no report. When `enabled` is off the switch makes integriq read `disabled`. Otherwise the row reads "Not checked yet" until the next run. + +**End-of-life feed, after a run** (Sync now, or `EolSyncJob`). `EolSyncService::run()` already records a status. The report maps its `reason`: + +| Reason | Status | +|---|---| +| none, the run completed | `configured`, with the matched and skipped counts | +| `disabled` | nothing, the switch says it | +| `openregister-not-installed` | `unavailable` | +| `object-service-unavailable` | `error` | +| `module-schema-not-configured` | `unconfigured` | +| `eol-register-or-schema-not-found` | `unconfigured`, naming integriq's endoflife.date source | +| anything else | `error`, naming the reason | + +**Why this is cheap.** A save and a button are admin actions. `FederationSyncJob` runs once per `federation_sync_interval` (3600 s by default), and `EolSyncJob` once per `intervalSeconds`, never below 300 s. No page request sends an event (ADR-076). + +**Wiring.** `FederationService`, `FederationSyncJob`'s service and `EolSyncService` are built by hand in `Application::register()`. Those factories pass the report service by name. `SettingsController` is autowired, so it takes the service as an optional last argument. + +## D3. The page + +- `src/manifest.d/connection-registry.json`: an `index` page `Integrations` at `/settings/integrations`, `requiresApp` integriq, `permission: admin`, `showAdd: false`, and the columns connection, status, status message, last checked and settings. +- Its menu entry `IntegrationsMenu` sits in the settings gear with `query: {app: stackiq}`, `permission: admin` and `visibleIf.appInstalled: integriq`. +- `src/services/connectionRegistry.js` holds `openIntegriqConnections`. +- `App.vue` passes no `formatters`: CnAppRoot supplies the two built-ins. `src/customComponents.js` carries the handler, because CnIndexPage resolves a header action's handler against `customComponents`. + +**Formatters.** `@conduction/nextcloud-vue` 3.2.0 ships `connectionStatus` and `connectionSettingsLabel` as built-ins, `disabled` included (nextcloud-vue#1173). Stackiq carried a local copy while it resolved 2.39.0, and dropped it on moving to 3.2.0. + +## D4. Contract misfits + +- **A boolean app-config key.** `federation_enabled` is typed boolean, and a stored `false` counted as filled. Resolved by hydra#676 (`false` reads empty) and hydra#677: the row declares `switch: {"configKey": "federation_enabled"}` and reads `disabled` while it is off. +- **A flag inside a blob.** `eol_sync_config` holds `{"enabled": false, …}`. Resolved by hydra#677: the row declares `switch: {"configKey": "eol_sync_config", "jsonPath": "enabled"}`. An unset blob has no `enabled` and reads off, which matches `SettingsService::getEolSyncConfig()`'s default. +- **Completeness that depends on the adapter.** Email needs different keys per transport. `requiredConfig` is one fixed list. +- **Gate 116's vendored schema is behind integriq.** `hydra-gates/scripts/schemas/connections.schema.json` on `.github` `main` has no `jsonPath`, `simulatedValues` or `reportedOnly`, so gate 116 warns on every file that uses the hydra#673 fields. The file validates against integriq's own schema on `development`. + +## Risks + +- **Same-second ordering.** Stackiq sends the refresh before the report. If integriq stamps `refreshedAt` later than the report's `at` within one request, the report is retired. Hydra#674 compares with "not older than", so an equal stamp counts. +- **A federation row can lag a failing peer.** Between pulls the row keeps the last outcome. `FederationSyncJob` bounds that to one sync interval. diff --git a/openspec/changes/adopt-connection-registry/proposal.md b/openspec/changes/adopt-connection-registry/proposal.md new file mode 100644 index 000000000..d2d3f8e36 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/proposal.md @@ -0,0 +1,45 @@ +--- +kind: code +--- + +# Proposal: adopt-connection-registry + +## Why + +Stackiq talks to three outside systems, and an admin can only tell whether they work by reading three settings sections and a log. + +- **Email.** Mail goes out through Symfony Mailer. `email_transport_type` picks smtp, sendmail, native, null, sendgrid, mailgun, postmark, ses or mailjet. On `null`, every mail is dropped without a sound. +- **Catalog federation.** OpenCatalogi announces this catalog to directory.opencatalogi.nl and pulls entries from peer catalogs. It needs OpenCatalogi installed and `federation_enabled` on, and a peer can fail for hours before anyone notices. +- **End-of-life feed.** Integriq ingests endoflife.date, and stackiq matches the cycles to module versions. A missing register or a switched-off sync shows only inside its own section. + +Hydra change `connection-registry` (hydra#667, amended in hydra#673 and hydra#674) gives every app one page of its connections, backed by integriq. + +## What changes + +- New `lib/Settings/connections.json` with three connections: `email`, `federation` and `eol-feed`. +- `email` names `email_transport_type` as its adapter key, and only `null` reads Simulated. An empty value is not simulated: stackiq falls back to SMTP. +- `federation` and `eol-feed` are `reportedOnly`. Only stackiq can see OpenCatalogi, the peers and the sync outcome. Each declares its on/off setting as a `switch` (hydra#677): `federation_enabled`, and `enabled` inside `eol_sync_config`, so a switched-off feature reads Switched off. +- `eol-feed` offers integriq's `endoflife-date` source as its template. +- The three settings sections get stable ids: `section-email`, `section-federation` and `section-eol-sync`. +- An email settings save, a peer add or remove, and an EOL sync settings save send `ConnectionRefreshRequestedEvent` for that connection, then report what stackiq can see. +- A federation pull and an EOL sync run report their outcome. Both run on a schedule or on the admin's button, never on a page request. +- An Integrations page under the settings gear, over integriq's `app_connection` schema, preset to `app=stackiq`, admin only, and only shown when integriq is installed. +- Add integration opens `/apps/integriq/connections?app=stackiq&link=1`. +- The `connectionStatus` and `connectionSettingsLabel` formatters come from `@conduction/nextcloud-vue` 3.2.0, which labels all seven statuses. The page strings are in English and Dutch. + +## Depends on + +- hydra `openspec/changes/connection-registry`, design D2, D4, D6, D8, D9 and D12, and hydra#674 (a refresh retires older observations). +- integriq on `development`: the `app_connection` schema, the declaration sync, both events, the Connections overview and the `endoflife-date` source. + +Without integriq the menu entry is hidden, a deep link shows the missing-dependency screen, and nothing is sent. + +## Out of scope + +- The stackiq register schema `connection` (softwarecatalogus). It describes a catalogue item and is unrelated to integriq's `app_connection`. +- The email test buttons. The store posts `testEmail` and `settings`, and the controller reads `email` and `emailSettings`, so neither test reaches a real send today. A report from them would describe unsaved settings. +- The directory announce. Its result is logged, and the row speaks for the pull. + +## Rollback + +Revert the change. Stackiq writes no rows of its own. Integriq removes the rows without a linked source on its next sync. diff --git a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md new file mode 100644 index 000000000..2d3c4ab48 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md @@ -0,0 +1,103 @@ +# admin-integrations Specification Delta + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- [adopt-connection-registry](../../) + +## Purpose + +Admins see stackiq's outside connections on one page, with a status stackiq can back. + +## ADDED Requirements + +### Requirement: REQ-STACKIQ-CONN-001 Stackiq declares its outside connections in one static file + +Stackiq SHALL declare `email`, `federation` and `eol-feed` in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The `email` entry SHALL name `email_transport_type` as its adapter key with `simulatedValues` holding `null` and not the empty string, because an empty transport sends mail through SMTP. The `federation` and `eol-feed` entries SHALL be `reportedOnly`. The `federation` entry SHALL declare `federation_enabled` as its `switch`, and the `eol-feed` entry SHALL declare `enabled` inside `eol_sync_config` as its `switch`, so a switched-off feature reads `disabled` (hydra connection-registry D12 items 6, 7 and 9). The `eol-feed` entry SHALL offer integriq's `endoflife-date` source template. Every `settingsUrl` SHALL point at a section id that exists in the admin settings page. + +#### Scenario: The declaration names this app and passes integriq's schema +@e2e exclude A static file with no browser surface; tests/Unit/Settings/ConnectionsDeclarationTest.php checks the shape, the app id, unique keys and the anchors. + +- **GIVEN** `lib/Settings/connections.json` +- **WHEN** it is validated against integriq's `connections.schema.json` +- **THEN** it SHALL validate +- **AND** its `app` SHALL equal the id in `appinfo/info.xml` +- **AND** every key SHALL be unique +- **AND** every `#section-…` anchor SHALL be an id in a settings section component + +#### Scenario: Switched-off federation and a switched-off sync read disabled +@e2e exclude The rule lives in integriq's resolver; tests/Unit/Settings/ConnectionsDeclarationTest.php asserts both switches and that the code reads the same keys with an off default. + +- **GIVEN** integriq has synced stackiq's declaration +- **WHEN** `federation_enabled` holds `false`, or `eol_sync_config` holds `{"enabled": false}` +- **THEN** integriq's rule 2b SHALL resolve that row as `disabled` +- **AND** stackiq SHALL send no report that says the feature is off + +#### Scenario: The null transport reads simulated, and an empty one does not +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** integriq has synced stackiq's declaration +- **WHEN** `email_transport_type` holds `null` +- **THEN** the Email row SHALL read Simulated with the declared message +- **AND** when `email_transport_type` is empty or `smtp`, rule 3 SHALL NOT apply + +### Requirement: REQ-STACKIQ-CONN-002 A save asks integriq to look again, and a run reports what it met + +When a save writes the settings of a declared connection, stackiq SHALL send `ConnectionRefreshRequestedEvent` with app `stackiq` and that key, and SHALL send it before any report for that key (hydra REQ-CONN-004, hydra#674). An email settings save SHALL then report what `SymfonyEmailService::isEmailSystemConfigured()` sees. A peer add or remove SHALL report OpenCatalogi missing as `unavailable`, and federation without peers as `unconfigured`. Switched-off federation and a switched-off EOL sync SHALL send the refresh and no report, from a save, a pull or a run, because the row's switch says it. A federation pull SHALL report every peer answering as `configured`, some as `limited` and none as `error`. An EOL sync run SHALL report its recorded outcome. A message SHALL name a peer by host only. Both events SHALL be named by string and sent only when the class exists. Neither SHALL change the response of the request, job or run that sent it. No page request SHALL send an event. + +#### Scenario: Saving email settings refreshes, then reports +@e2e exclude The event is not observable from a browser; tests/Unit/Service/ConnectionReportServiceTest.php and tests/Unit/Controller/SettingsControllerConnectionReportTest.php assert the order and the unchanged response. + +- **GIVEN** integriq is installed +- **WHEN** an admin saves the email settings with email switched off +- **THEN** stackiq SHALL send a refresh for `email` +- **AND** then a report `unconfigured` saying email is switched off + +#### Scenario: A pull where some peers fail reads limited +@e2e exclude A pull needs OpenCatalogi and reachable peers, which the CI instance does not have; tests/Unit/Service/ConnectionReportServiceTest.php drives the outcomes. + +- **GIVEN** federation is on with two peers +- **WHEN** a pull reaches one peer and not the other +- **THEN** stackiq SHALL report `federation` as `limited` +- **AND** the message SHALL name the failing peer's host and not its path + +#### Scenario: An EOL run without integriq's register reads not configured +@e2e exclude The run's outcome depends on integriq's register on the instance; tests/Unit/Service/ConnectionReportServiceTest.php asserts the report per reason, and tests/Unit/Service/ConnectionReportCallersTest.php that a run hands it over. + +- **GIVEN** EOL sync is switched on +- **WHEN** a run cannot find the `eol_product` or `eol_cycle` schema +- **THEN** stackiq SHALL report `eol-feed` as `unconfigured` with a message naming integriq's endoflife.date source + +#### Scenario: Without integriq nothing is sent +@e2e exclude The CI instance installs integriq; tests/Unit/Service/ConnectionReportServiceTest.php asserts nothing is sent or logged when the class is absent. + +- **GIVEN** integriq is not installed +- **WHEN** an admin saves email settings, or a pull or a sync runs +- **THEN** no event SHALL be sent and nothing SHALL be logged +- **AND** the save, pull or run SHALL answer as it did before this change + +### Requirement: REQ-STACKIQ-CONN-003 An admin reads the connections on an Integrations page + +Stackiq SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `stackiq` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. The status column SHALL name all seven statuses, `limited` and `disabled` included, through the `connectionStatus` formatter `@conduction/nextcloud-vue` ships. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=stackiq&link=1`. + +#### Scenario: The page lists only the rows of stackiq +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** stackiq and integriq are installed and integriq has synced the declaration +- **WHEN** an admin opens the Integrations page +- **THEN** the page SHALL list the three declared connections +- **AND** every listed row SHALL have `app` equal to `stackiq` + +#### Scenario: Add integration goes to integriq +@e2e tests/e2e/workflows/integrations-page.spec.ts + +- **GIVEN** the Integrations page +- **WHEN** the admin chooses Add integration +- **THEN** the browser SHALL open integriq's Connections overview with `app=stackiq` and `link=1` + +#### Scenario: A connection that works in part reads Limited +@e2e exclude Only a federation pull with a failing peer produces limited; tests/vitest/connectionRegistry.spec.js asserts the status column uses the library's built-in connectionStatus, whose labels nextcloud-vue's tests/utils/builtInFormatters.spec.js (formatConnectionStatus) asserts, with Beperkt in the library's l10n/nl.json. + +- **GIVEN** a row whose status is `limited` +- **WHEN** the page renders it +- **THEN** the cell SHALL read Limited, or Beperkt on a Dutch instance diff --git a/openspec/changes/adopt-connection-registry/tasks.md b/openspec/changes/adopt-connection-registry/tasks.md new file mode 100644 index 000000000..bbac546b4 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/tasks.md @@ -0,0 +1,39 @@ +# adopt-connection-registry tasks + +## 1. Declare + +- [x] 1.1 Write `lib/Settings/connections.json` with `email`, `federation` and `eol-feed`. +- [x] 1.2 Give the Email, Catalog federation and End-of-life feed sync sections the ids the file links to. +- [x] 1.3 Guard the file in `tests/Unit/Settings/ConnectionsDeclarationTest.php`. + +## 2. Page + +- [x] 2.1 Add `src/manifest.d/connection-registry.json` with the page and its settings-gear menu entry. +- [x] 2.2 Add `src/services/connectionRegistry.js` with the two formatters and the Add integration handler. +- [x] 2.3 Wire the formatters in `src/App.vue` and the handler in `src/customComponents.js`; register `PowerPlugOutline` in `src/icons.js`. +- [x] 2.4 Add the strings to `l10n/en` and `l10n/nl`. +- [x] 2.5 Cover it in `tests/vitest/connectionRegistry.spec.js`. + +## 3. Reports and refresh + +- [x] 3.1 Add `lib/Service/ConnectionReportService.php`. +- [x] 3.2 Refresh and report from the two email settings save paths in `SettingsController`. +- [x] 3.3 Refresh and report from `FederationService` peer changes and pulls. +- [x] 3.4 Refresh and report from `EolSyncService` config saves and runs. +- [x] 3.5 Pass the service in the `Application` factories. +- [x] 3.6 Add the integriq event stubs for PHPUnit, psalm and phpstan. +- [x] 3.7 Cover it in `ConnectionReportServiceTest`, `ConnectionReportCallersTest` and `SettingsControllerConnectionReportTest`. + +## 4. End to end + +- [x] 4.1 Write `tests/e2e/workflows/integrations-page.spec.ts`. +- [x] 4.2 Install integriq in the CI `additional-apps`. + +## 5. Switch and built-in formatters (hydra#677) + +- [x] 5.1 Declare `switch` on `federation` and `eol-feed`, and stop reporting `unconfigured` for a switched-off feature. +- [x] 5.2 Move `@conduction/nextcloud-vue` to the release with the built-in connection formatters and delete the local copy. + +## 6. After integriq ships + +- [ ] 6.1 Run the e2e spec against an instance with both apps, then archive this change. diff --git a/openspec/changes/architecture-assistant-drafted-views/.openspec.yaml b/openspec/changes/architecture-assistant-drafted-views/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-assistant-drafted-views/design.md b/openspec/changes/architecture-assistant-drafted-views/design.md new file mode 100644 index 000000000..9fcfd90f5 --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/design.md @@ -0,0 +1,111 @@ +# Design: architecture-assistant-drafted-views + +Read at development 49e65cb4, with the open changes `architecture-views-editor` and `mcp-full-action-surface` as the ground this change stands on. + +## Where it fits + +| Layer | Touched | Notes | +|---|---|---| +| MCP provider | `lib/Mcp/StackiqToolProvider.php` (created by `mcp-full-action-surface`, its `tasks.md` 2.1) | two descriptors and two dispatch entries, no logic | +| Argument checks | `lib/Mcp/McpArgumentValidator.php` (same change, `tasks.md` 2.3) | reused as is | +| Service | new `lib/Service/ArchitectureViewDraftService.php` | validation, element resolution, relation reuse, writes | +| Register | new fragment `lib/Settings/register.d/architecture-assistant-drafted-views.json` | appends `assistant` to `origin` on `view`, `element` and `relation`, adds `draftedFor` and `draftedAt` to `view` | +| Page | `ViewEditor` from `architecture-views-editor` (`src/views/architecture/ArchitectureViewEditor.vue`) | a notice and a first-open layout | +| Routes | none | the tools are reached through OpenRegister's MCP server (`openregister-ro/appinfo/routes.php:1990`, `POST /api/mcp`) and Hermiq's facade | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`). `deepMergeConfig` (:7338) appends list values, so `"enum": ["assistant"]` under `origin` adds the value to the enum `architecture-views-editor` declares. The fragment bumps `view`, `element` and `relation` once more. + +## Decisions + +### D1. Two curated tools on the provider `mcp-full-action-surface` creates + +| Tool id | Delegates to | Scope | Reach | Hints | +|---|---|---|---|---| +| `stackiq.searchArchitectureElements` | `ArchitectureViewDraftService::searchElements(query, types, limit)` | read | user | `readOnlyHint: true` | +| `stackiq.draftView` | `ArchitectureViewDraftService::draftView(name, description, elements, relations)` | create | instance | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: false` | + +`draftView` is `reach: instance` because a drafted view is visible to the members of the caller's organisation, not only to the caller. Hermiq fail-closes an undeclared reach to `external` (`mcp-full-action-surface` design section 5), so both tools declare one. + +Rejected: an `#[McpTool]` attribute on the service method (ADR-063 Decision 2). `mcp-full-action-surface` builds stackiq's tools as a hand-written provider with one argument validator and per-object gates. A second mechanism in the same app would split where a reviewer looks for stackiq's tools. + +Rejected: derived `x-openregister-mcp` writes on `view`, `element` and `relation`. Both open MCP changes exclude the AMEF schemas for good reason (`element` has more than 80 properties, and a raw `view.create` would take the node list as free JSON). A draft is a composite write across three schemas, which is what a curated tool is for. + +### D2. The input is structure, not a picture + +`draftView` takes: + +```json +{ + "name": "Zaakgericht werken, concept", + "description": "How the case system hands documents to document management.", + "elements": [ + { "key": "a", "uuid": "00000000-0000-0000-0000-000000000000" }, + { "key": "b", "type": "ApplicationComponent", "name": "Documentbeheer" } + ], + "relations": [ + { "source": "a", "target": "b", "type": "Flow" } + ] +} +``` + +An element names either an existing AMEF element by `uuid` or a new one by ArchiMate `type` and `name`. A relation names two element keys and an ArchiMate relation type. Types come from closed lists in the service: the ArchiMate 3.2 element types and the eleven relation types. A draft holds at most 60 elements and 120 relations. An unknown type, a dangling key or a list over the cap returns a validation error before anything is written. + +The result is `{ "uuid": ..., "url": "/apps/stackiq/views/", "created": { "elements": n, "relations": n } }`, so the assistant can hand the user a link. + +### D3. The mark is written with the object (ADR-088) + +Every object the tool writes carries `origin: assistant` in the same `saveObject` call that creates it: the view, each new element and each new relation. The view also carries `draftedFor` (the Nextcloud user id of the session the tool ran in) and `draftedAt`. There is no second write that could fail after the first, so an unmarked draft cannot exist (ADR-088 Decisions 1 and 5). + +ADR-088 Decision 2 asks for the mark in the object's own metadata. OpenRegister's object metadata has no agent or provenance field (`openregister-ro/lib/Db/ObjectEntity.php:174` to :759 holds `owner`, `application`, `organisation` and the like), and `application` reads stackiq for a human save and a tool save alike. So the mark is the schema field `origin`, which the editor already reads and the Views index already facets. + +Rejected: an OpenRegister change that adds an agent field to the object metadata. It is the better long-term home, but it is OpenRegister's to design for every app, and stackiq's field can move there later without losing data. + +Stackiq does not record which agent drafted the view. `ToolRegistryFacade::invokeTool` passes no agent identity to a provider (`openregister-ro/lib/Service/Mcp/ToolRegistryFacade.php:351-353`). Hermiq's tool trace records the agent with the tool id and the returned view uuid (ADR-088 Decision 3), and OpenRegister's invocation audit records the call (ADR-063 Decision 8). The mark says "an assistant drafted this", the trace says which one. + +A person editing a drafted view keeps the mark. The editor shows "Drafted by an assistant for on " on every open, and the Views index shows `assistant` in its origin facet. + +### D4. Drafts carry no positions and are laid out on first open + +The service writes nodes without `x` and `y`. `ArchitectureViewEditor.vue` checks the nodes with `needsFullLayout` and places them with `layoutFlowNodes` (`@conduction/nextcloud-vue` 2.57.1, `src/composables/flowGraphLayout.js:129` and :329), a deterministic layered layout. The first human save stores the positions. A draft has no nested nodes, so the flat layout fits. + +The package root does not export these two helpers: `src/index.js:438` exports `useFlowStore`, which uses them (`src/composables/useFlowStore.js:28`), but not the helpers themselves. The editor imports them through the package's `./src/*` export (`package.json` `exports`), and the vitest spec imports the same path, so a rename in the library fails the test instead of the page. + +Rejected: a layout in PHP. It would be a second layout algorithm next to the one the shared library already ships and tests. + +### D5. The caller's rights decide + +The tool runs in the caller's session (ADR-034 Decision 7). `ArchitectureViewDraftService` writes through OpenRegister's `ObjectService` with RBAC and multitenancy on, so a caller without create rights on `view` gets a forbidden result and nothing is written. The draft is scoped to the caller's active organisation. `searchArchitectureElements` reads the same way, so it only returns elements the caller may see. + +### D6. A draft stays out of shared readers + +`architecture-views-editor` makes `GET /api/views`, `GET /api/views/{viewId}` and the full ArchiMate export keep only views whose `origin` is empty or `imported`. `assistant` is neither, so a draft is never cached for all callers and never exported with the GEMMA model. This change adds no filter of its own and adds a test that the existing readers skip `assistant`. + +## Declarative versus imperative + +The draft is imperative: one call writes up to three kinds of object, resolves keys and reuses relations, which no `x-openregister-*` extension expresses. The status stays under the lifecycle `architecture-views-editor` declares, and the tool cannot move it past draft. No notification, aggregation or widget is added. + +## Seed data + +The fragment changes the `view`, `element` and `relation` schemas, so it seeds one drafted view next to the drawn examples `architecture-views-editor` seeds. All objects live in the `vng-gemma` register. + +### Schema: `view` + +| Field | Object 1 | +|---|---| +| slug | `seed-view-assistant-zaak-dms` | +| name | Zaaksysteem en documentbeheer, concept van de assistent | +| status | draft | +| origin | assistant | +| draftedFor | admin | +| draftedAt | 2026-09-27T09:00:00+00:00 | +| nodes | `seed-el-zaaksysteem` and `seed-el-dms`, without `x` and `y` | +| connections | one Flow connection that reuses `seed-rel-zaak-dms` | + +The seed reuses the drawn example elements and relation, so it adds no element or relation of its own, and a fresh install shows the notice and the first-open layout on a real object. + +## Risks + +- **A wrong element.** The assistant may pick a GEMMA element that does not mean what the user meant. The notice and the draft status tell the reviewer the view is unreviewed, and `searchArchitectureElements` returns the GEMMA type and name so the assistant can show its picks. +- **Duplicate new elements.** An assistant can create "Documentbeheer" when a GEMMA element with that name exists. The service matches a new element's type and name against existing elements in the caller's scope and reuses an exact match. +- **Dependency order.** `lib/Mcp/StackiqToolProvider.php` does not exist until `mcp-full-action-surface` lands. This change is blocked on it. +- **A deep import.** The layout helpers come from `@conduction/nextcloud-vue/src/composables/flowGraphLayout.js`, not the package root. A library release that moves the file breaks the import at build time, which the vitest spec catches. Asking the library to export them from the root is the clean fix and can follow. diff --git a/openspec/changes/architecture-assistant-drafted-views/proposal.md b/openspec/changes/architecture-assistant-drafted-views/proposal.md new file mode 100644 index 000000000..8623d55ca --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - mcp-full-action-surface +--- + +# Let an assistant draft an architecture view for review + +## Summary + +A user asks Hermiq's assistant for a view ("draw how our case system talks to document management"). The assistant looks up the GEMMA elements that fit through a stackiq tool, then calls a second stackiq tool that writes the view as a draft. The draft opens in the view editor, marked as drafted by an assistant, and a person reviews, adjusts and saves it. Stackiq owns the two tools and the mark. The chat, the model and the agent's rights are Hermiq's. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-ai-diagram`, "Have a diagram drafted for you by an assistant from a description." No tender, feature request or changelog names it for stackiq. + +- SAP LeanIX rates yes: "AI agents connected to your workspace via the MCP server can now create, populate, and edit diagrams ... You describe what you want to see, and the agent adds fact sheets to a canvas" (https://updates.leanix.net/announcements/build-and-edit-architecture-diagrams-with-ai-agents). +- BlueDolphin rates yes: "Modelling Assistant or BPMN Generator instantly creates BPMN 2.0-compliant process diagrams from a simple prompt or by uploading existing documentation" (https://help.bluedolphin.io/en/articles/12528662-ai-capabilities-of-bluedolphin). + +The lane decided build because two competitors rate yes and architecture is a core area. The matrix notes "Nothing drafts diagrams." + +## What stackiq has today + +- No MCP surface. `grep -rn "IMcpToolProvider\|McpTool" lib appinfo` returns nothing, and `lib/Mcp` does not exist at development 49e65cb4. +- Two open changes plan one. `stackiq-mcp-adoption` excludes `element`, `view`, `model`, `property-definition` and `relation` (its `design.md` exclusion table and Decision 3). `mcp-full-action-surface` keeps that exclusion for derived tools (its `design.md` section 3, "Excluded from derivation"), adds read-only `stackiq.listViews` and `stackiq.getView` over `ViewService` (its `design.md` section 5), and creates `lib/Mcp/StackiqToolProvider.php` and `lib/Mcp/McpArgumentValidator.php` (its `tasks.md` 2.1 and 2.3). Neither writes a view. +- OpenRegister's `IMcpToolProvider` (`openregister-ro/lib/Mcp/IMcpToolProvider.php:47`) runs a tool in the caller's Nextcloud session, and `ToolRegistryFacade::invokeTool` (`openregister-ro/lib/Service/Mcp/ToolRegistryFacade.php:350-365`) passes no agent identity to the provider. +- `architecture-views-editor` adds the editor, the `origin` field on `view`, `element` and `relation`, and the readers that keep only imported views in the shared list and the full export. + +## What this change builds + +- `stackiq.searchArchitectureElements`, a read tool that returns a short projection of AMEF elements (uuid, identifier, name, ArchiMate type, GEMMA type) so a draft reuses existing elements instead of inventing duplicates. +- `stackiq.draftView`, a create tool that takes a name, a description, elements (an existing uuid, or a new ArchiMate type and name) and relations (source, target, ArchiMate relation type), and writes a `view` in status draft. +- `lib/Service/ArchitectureViewDraftService.php`, which validates the input, resolves elements, reuses or creates relations, and writes every object with the assistant mark in the same write (ADR-088). +- An `assistant` value on `origin`, a notice in the editor on a drafted view, and a first-open layout for drafted nodes. + +## Out of scope + +- The chat, the prompt, the model call, the agent's tool grants and the human approval gate. Those are Hermiq's (ADR-034, ADR-063 Decision 4). +- Letting an assistant change an existing view. SAP LeanIX's agents also edit diagrams. This change only drafts new views, and a later change can add an edit tool once drafts have been reviewed in practice. +- Drafting business processes in BPMN, which is BlueDolphin's evidence. Stackiq models processes in `architecture-process-mapping`, and a process draft tool can follow it. +- Reading an uploaded document to draft from it. + +## Risks + +- An agent can create many drafts. Drafts are ordinary organisation objects under OpenRegister RBAC, the tool caps a draft at 60 elements and 120 relations, and Hermiq's approval gate sits in front of every create tool. +- The relation reuse rule exists twice: in the editor's store (`architecture-views-editor` D5) and in this service. Both are tested against the same fixture. diff --git a/openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md b/openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md new file mode 100644 index 000000000..078a390f9 --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md @@ -0,0 +1,103 @@ +# architecture-assistant-views specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-assistant-drafted-views + +## Purpose + +A user asks Hermiq's assistant to draw a view, and the assistant drafts it through two stackiq MCP tools: one finds the AMEF elements that fit, one writes the draft. The draft is an ordinary `view` in status draft, marked as drafted by an assistant in the same write (ADR-088), and a person reviews it in the view editor from `architecture-views-editor`. The chat, the model, the agent's grants and the approval gate are Hermiq's (ADR-034, ADR-063). Stackiq owns the tools, the input checks and the mark. + +## ADDED Requirements + +### Requirement: REQ-AAV-001 Stackiq SHALL offer a read tool that finds architecture elements for a draft + +The provider `lib/Mcp/StackiqToolProvider.php` SHALL list `stackiq.searchArchitectureElements` with scope read, reach user and `readOnlyHint` true. It SHALL take a search text, an optional list of ArchiMate element types and a limit of at most 50, and SHALL return for each match the uuid, the ArchiMate identifier, the name, the ArchiMate type and the GEMMA type. It SHALL read through OpenRegister with RBAC and multitenancy on, so it returns only elements the caller may read. + +#### Scenario: A sibling app finds the GEMMA element for a case system +@e2e exclude The tool is called over MCP, not through a page; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts the projection fields and the type filter, and tests/Unit/Mcp/StackiqToolProviderDraftToolsTest.php asserts the descriptor's scope, reach and hints. + +- **GIVEN** the imported GEMMA model holds an application component named Zaaksysteem +- **WHEN** Hermiq, the sibling app, calls `stackiq.searchArchitectureElements` with the text zaak and the type ApplicationComponent in a signed-in user's session +- **THEN** the result SHALL list Zaaksysteem with its uuid, identifier, name, ArchiMate type and GEMMA type +- **AND** the result SHALL hold no other element fields + +### Requirement: REQ-AAV-002 Stackiq SHALL offer a create tool that writes a draft view from elements and relations + +The provider SHALL list `stackiq.draftView` with scope create, reach instance, `readOnlyHint` false, `destructiveHint` false and `idempotentHint` false. It SHALL take a name, a description, a list of elements (an existing element uuid, or a new ArchiMate element type and name, each with a key) and a list of relations (a source key, a target key and an ArchiMate relation type). It SHALL check the whole input before it writes anything: an unknown element or relation type, a key no element declares, more than 60 elements or more than 120 relations SHALL return an error result and SHALL write nothing. On success it SHALL write one `view` in status draft, reuse a `relation` of the same type between the same two elements or create one, reuse an existing element of the same type and exact name in the caller's scope or create one, and SHALL return the view's uuid, its editor link `/apps/stackiq/views/` and the number of elements and relations it created. + +#### Scenario: An assistant drafts a view and hands back a link +@e2e tests/e2e/workflows/architecture-assistant-views.spec.ts + +- **GIVEN** an application owner signed in to stackiq and the seeded elements Zaaksysteem and Documentbeheer +- **WHEN** a tool call to `stackiq.draftView` on OpenRegister's MCP endpoint `POST /apps/openregister/api/mcp` names both elements and a Flow relation between them +- **THEN** the result SHALL carry a view uuid and the link `/apps/stackiq/views/` +- **AND** the Views page SHALL list the new view with status draft + +#### Scenario: A dangling key writes nothing +@e2e exclude Input checks are a service concern; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts that a relation naming an undeclared key, an unknown type and a list over the cap each return an error and call no save. + +- **GIVEN** a draft request whose relation names the target key c while only the keys a and b are declared +- **WHEN** Hermiq calls `stackiq.draftView` +- **THEN** the result SHALL be an error that names the key c +- **AND** no `view`, `element` or `relation` object SHALL be written + +#### Scenario: A new element with the name of an existing one is reused +@e2e exclude The match rule is a service concern; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts that a new ApplicationComponent named Documentbeheer resolves to the existing element of that type and name. + +- **GIVEN** an element of type ApplicationComponent named Documentbeheer in the caller's scope +- **WHEN** a draft request asks for a new ApplicationComponent named Documentbeheer +- **THEN** the view SHALL reference the existing element +- **AND** the result SHALL report zero created elements + +### Requirement: REQ-AAV-003 Every object the draft tool writes SHALL carry the assistant mark in the same write + +Every `view`, `element` and `relation` that `stackiq.draftView` creates SHALL carry `origin` set to `assistant` in the save that creates it. The view SHALL also carry `draftedFor`, the Nextcloud user id of the session the tool ran in, and `draftedAt`. A write that fails SHALL return an error result, and no object SHALL be saved first and marked later (ADR-088). A person who edits a drafted view SHALL NOT remove the mark. + +#### Scenario: A reviewer sees who the draft was made for +@e2e tests/e2e/workflows/architecture-assistant-views.spec.ts + +- **GIVEN** a view drafted through `stackiq.draftView` in the session of an application owner +- **WHEN** the application owner opens it in the view editor at `/views/` +- **THEN** the editor SHALL show the notice "Drafted by an assistant for" with their name and the date +- **AND** the notice SHALL still show after they move a node and save + +#### Scenario: New elements and relations carry the mark +@e2e exclude The mark sits in the saved objects; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts every saveObject call for a new view, element and relation carries origin assistant in the same payload. + +- **GIVEN** a draft request with one new element and one new relation +- **WHEN** `stackiq.draftView` writes it +- **THEN** the new element and the new relation SHALL each carry `origin` assistant +- **AND** the reused elements SHALL keep their own `origin` + +### Requirement: REQ-AAV-004 A drafted view SHALL be laid out when it is first opened + +The draft tool SHALL write nodes without positions. When the view editor opens a view whose nodes need a full layout, it SHALL place them with the shared library's layered layout, and the first save by a person SHALL store the positions. The Views page SHALL offer `assistant` in its origin facet. + +#### Scenario: An application owner opens a fresh draft +@e2e tests/e2e/workflows/architecture-assistant-views.spec.ts + +- **GIVEN** a view drafted with three elements and two relations and no positions +- **WHEN** the application owner opens it in the view editor +- **THEN** the three nodes SHALL render at distinct positions with both connections drawn +- **AND** choosing the origin assistant in the Views sidebar SHALL list the draft + +### Requirement: REQ-AAV-005 The draft tools SHALL run with the caller's rights and SHALL keep drafts out of shared readers + +Both tools SHALL run in the caller's Nextcloud session with no substitute account (ADR-034 Decision 7). A caller without create rights on `view` SHALL get a forbidden result and nothing SHALL be written. A drafted view SHALL belong to the caller's active organisation. `GET /api/views`, `GET /api/views/{viewId}` and the full ArchiMate export SHALL leave out views with `origin` assistant, as they leave out drawn views. + +#### Scenario: A caller without create rights gets a forbidden result +@e2e exclude The CI instance runs as admin; tests/Unit/Service/ArchitectureViewDraftServiceTest.php asserts that a forbidden save from OpenRegister's ObjectService returns a forbidden result and that no later save runs. + +- **GIVEN** a signed-in user whose groups may read but not create `view` objects +- **WHEN** Hermiq calls `stackiq.draftView` in that user's session +- **THEN** the result SHALL be forbidden +- **AND** no object SHALL be written + +#### Scenario: A draft stays out of the shared views list +@e2e exclude The shared readers are PHP; tests/Unit/Service/ViewServiceDrawnViewTest.php and tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php gain a case with origin assistant and assert it is left out. + +- **GIVEN** a view drafted by an assistant +- **WHEN** another user calls `GET /api/views` or a Nextcloud admin runs the full ArchiMate export +- **THEN** the drafted view SHALL NOT be in the response or in the exported file diff --git a/openspec/changes/architecture-assistant-drafted-views/tasks.md b/openspec/changes/architecture-assistant-drafted-views/tasks.md new file mode 100644 index 000000000..d0e869015 --- /dev/null +++ b/openspec/changes/architecture-assistant-drafted-views/tasks.md @@ -0,0 +1,71 @@ +# Tasks: architecture-assistant-drafted-views + +## Implementation tasks + +### Task 1: Register fragment for the assistant mark +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-003-every-object-the-draft-tool-writes-shall-carry-the-assistant-mark-in-the-same-write +- **files**: `lib/Settings/register.d/architecture-assistant-drafted-views.json`, `tests/Unit/Service/ArchitectureViewsRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN `origin` on `view`, `element` and `relation` accepts imported, drawn and assistant + - GIVEN the merged register WHEN it loads THEN `view` has `draftedFor` and `draftedAt` and carries a bumped version + - GIVEN a fresh install WHEN the seed runs THEN the drafted example view exists with `origin` assistant +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewsRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Draft service +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-002-stackiq-shall-offer-a-create-tool-that-writes-a-draft-view-from-elements-and-relations +- **files**: `lib/Service/ArchitectureViewDraftService.php`, `tests/Unit/Service/ArchitectureViewDraftServiceTest.php` +- **acceptance_criteria**: + - GIVEN an unknown type, a dangling key or a list over the cap WHEN `draftView` runs THEN it returns an error and calls no save + - GIVEN a new element whose type and exact name match an existing element WHEN `draftView` runs THEN the existing element is referenced + - GIVEN a relation of the same type between the same two elements WHEN `draftView` runs THEN it is reused + - GIVEN any object the service creates WHEN it is saved THEN `origin` assistant is in the same payload, and the view carries `draftedFor` and `draftedAt` + - GIVEN `searchElements` WHEN it runs THEN it returns only uuid, identifier, name, ArchiMate type and GEMMA type, at most 50 rows +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewDraftServiceTest`) + +### Task 3: Two tools on the stackiq MCP provider +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-001-stackiq-shall-offer-a-read-tool-that-finds-architecture-elements-for-a-draft +- **files**: `lib/Mcp/StackiqToolProvider.php`, `tests/Unit/Mcp/StackiqToolProviderDraftToolsTest.php` +- **acceptance_criteria**: + - GIVEN the provider WHEN it lists its tools THEN `stackiq.searchArchitectureElements` is scope read, reach user, read-only, and `stackiq.draftView` is scope create, reach instance, not read-only, not destructive, not idempotent + - GIVEN a call to either tool WHEN it is dispatched THEN the arguments pass `McpArgumentValidator` and reach the service unchanged +- [ ] Implement +- [ ] Test (PHPUnit `StackiqToolProviderDraftToolsTest`) + +### Task 4: Caller rights and shared readers +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-005-the-draft-tools-shall-run-with-the-callers-rights-and-shall-keep-drafts-out-of-shared-readers +- **files**: `lib/Service/ArchitectureViewDraftService.php`, `tests/Unit/Service/ArchitectureViewDraftServiceTest.php`, `tests/Unit/Service/ViewServiceDrawnViewTest.php`, `tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php` +- **acceptance_criteria**: + - GIVEN OpenRegister refuses the view save WHEN `draftView` runs THEN the result is forbidden and no later save runs + - GIVEN a view with `origin` assistant WHEN `GET /api/views`, `GET /api/views/{viewId}` or the full ArchiMate export runs THEN it is left out +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewDraftServiceTest`, `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest`) + +### Task 5: Draft notice and first-open layout in the editor +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-004-a-drafted-view-shall-be-laid-out-when-it-is-first-opened +- **files**: `src/views/architecture/ArchitectureViewEditor.vue`, `src/utils/viewGraph.js`, `tests/vitest/viewGraph.spec.js`, `tests/e2e/workflows/architecture-assistant-views.spec.ts` +- **acceptance_criteria**: + - GIVEN a view with `origin` assistant WHEN it opens THEN the notice names the user it was drafted for and the date, also after a save + - GIVEN nodes without positions WHEN the editor opens them THEN `layoutFlowNodes` places each at a distinct point, and a save stores the points + - GIVEN the Views page WHEN the origin facet is opened THEN assistant is one of its values +- [ ] Implement +- [ ] Test (vitest `viewGraph.spec.js` layout case, Playwright `architecture-assistant-views.spec.ts`) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-assistant-drafted-views/specs/architecture-assistant-views/spec.md#requirement-req-aav-003-every-object-the-draft-tool-writes-shall-carry-the-assistant-mark-in-the-same-write +- **files**: `docs/features/architecture-views.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN a section shows a drafted view with its notice in a screenshot and names the two tools + - GIVEN a Dutch instance WHEN a drafted view opens THEN the notice reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshot captured with Playwright) + +## Verification + +- `openspec validate architecture-assistant-drafted-views --type change --strict` +- PHPUnit: `ArchitectureViewDraftServiceTest`, `StackiqToolProviderDraftToolsTest`, `ArchitectureViewsRegisterShapeTest`, `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest` +- vitest: `viewGraph.spec.js` +- Playwright: `tests/e2e/workflows/architecture-assistant-views.spec.ts` +- Documentation in `docs/features/architecture-views.md` with a screenshot (ADR-010) +- English and Dutch strings for the notice and the facet value (ADR-005) diff --git a/openspec/changes/architecture-data-model-and-ggm/.openspec.yaml b/openspec/changes/architecture-data-model-and-ggm/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-data-model-and-ggm/design.md b/openspec/changes/architecture-data-model-and-ggm/design.md new file mode 100644 index 000000000..f415c449e --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/design.md @@ -0,0 +1,102 @@ +# Design: architecture-data-model-and-ggm + +Read at development 49e65cb4. Line numbers below are from that sha. The `origin` field on `element` and `relation` and the Architecture menu group come from `architecture-views-editor` (its D1 and D8). `GebruikDetail` comes from `landscape-usage-registration` (`src/manifest.d/usages.json` in its design). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `usage` (`lib/Settings/softwarecatalogus_register.json:2654`) gains `dataEntities`; `element` (:4130) gains `attributes`; `relation` (:6300) gains `sourceCardinality` and `targetCardinality` | through a new fragment `lib/Settings/register.d/architecture-data-model-and-ggm.json` | +| Pages | new `src/manifest.d/architecture-data-model.json` with `Gegevensmodel` (index) and `GegevensobjectDetail` (detail) | | +| Pages | `GebruikDetail` in `src/manifest.d/usages.json` shows `dataEntities` in its data widget | | +| Menu | `src/menu-layout.json` relocates `Gegevensmodel` under the `Architecture` group | | +| Views | new `src/views/architecture/DataEntityRelations.vue` and `src/views/architecture/DataEntityAttributes.vue`, registered in `src/customComponents.js` | | +| Service, controller, routes | none | reads and writes go through OpenRegister's objects API | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`, `deepMergeConfig` at :7338). It bumps `usage`, `element` and `relation`. + +## Decisions + +### D1. A data entity is an AMEF `element` of type `BusinessObject` + +The GGM entities are already `element` objects after a GEMMA import: 503 `BusinessObject` elements in `lib/Settings/GEMMA_release.xml` carry `GGM-guid` (propid-6, :121153), and 636 Association and 158 Specialization relationships run between them. The import keeps them all (`lib/Service/ArchiMateImportService.php:973-980`) and stores the guid in `ggm-guid` (register.json:5038, through `convertToCamelCase` at :2256). A municipality's own entity is an `element` of type `BusinessObject` with `origin` drawn, so the view editor can place it next to GGM entities. + +Rejected: a new `dataEntity` schema in the `stackiq` register. It would copy 503 GGM entities out of the model they belong to, and a view could no longer place them, because the editor and both exports read the AMEF schemas only. + +### D2. The link lives on the application in use + +`usage.dataEntities` is a list of related `element` objects, with `objectConfiguration.queryParams` `type=BusinessObject`, so the picker offers only data entities. It is facetable, so the usages index can filter on an entity. + +Rejected: the field on `module`, filled by the supplier. Which data an application holds depends on how the municipality uses it: one product serves several reference components, and a municipality may keep only part of its data there. The tender asks for the municipality's own architecture repository. A supplier-declared list can follow as a suggestion when a usage is created. + +### D3. The Data model pages + +`Gegevensmodel` (`/gegevensmodel`) is a `CnIndexPage` (manifest `type: index`) over `@resolve:amef_register` and `element` with `filter` `{"type": "BusinessObject"}`, columns name, ggm-uml-type, gemmaType and origin, and quick filters All, GGM (`origin` imported) and Own (`origin` drawn). Its add dialog uses `createDefaults` `{"type": "BusinessObject", "origin": "drawn"}` and `includeFields` name, documentation and attributes (`@conduction/nextcloud-vue` 2.57.1 `src/components/CnIndexPage/CnIndexPage.vue`, props at :1817 and :1949), because `element` has 86 properties and the default dialog would show them all. + +`GegevensobjectDetail` (`/gegevensmodel/:id`) is a `type: detail` page on the ADR-062 grid with: +- a `data` widget for name, documentation, ggm-guid, ggm-uml-type and origin, +- a body widget `DataEntityRelations` (D4), +- a body widget `DataEntityAttributes` (D5), +- an `object-list` widget `entity-usages` over `@resolve:voorzieningen_register` and `usage` with filter `{"dataEntities": "@objectId"}`, titled "Applications that hold this data", `rowRoute: GebruikDetail`. + +OpenRegister filters an array property on one value with a JSON containment test (`openregister-ro/lib/Db/MagicMapper/MagicSearchHandler.php:1601`). + +### D4. The relations of an entity are drawn around it + +`DataEntityRelations.vue` loads the `relation` objects whose `source` or `target` is the entity's `identifier` and whose `type` is Association, Aggregation, Composition or Specialization, then the elements at the other end. It passes them to `CnRelationshipGraph` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnRelationshipGraph/CnRelationshipGraph.vue`, props `nodes`, `edges`, `layout` and `legend` at :123 to :171) with the radial layout, the entity as root, and an edge label of the relation name, its type, and the cardinalities when set. The component's colour props default to hex values (:150 to :158), so the view passes Nextcloud CSS variables for every colour (ADR-003). Under the graph a table lists every relation as text, and choosing a node or a row opens that entity's page. + +Rejected: `CnGraphCanvas`. The picture is an entity and its direct neighbours, which is what the relationship graph draws; a canvas with pan and zoom suits a whole view, and the view editor already offers it. + +Rejected: a UML class diagram with attribute compartments. GGM entities carry no attributes in the AMEFF file, so the compartments would be empty for 503 of them. + +### D5. Attributes and keys on the municipality's own entities + +`element.attributes` is a list of objects: `name` (required), `dataType` (enum text, number, date, boolean, reference), `isKey` (boolean) and `description`. `DataEntityAttributes.vue` shows them as a table with the key attributes first and marked "key" in text, and edits them for an entity with `origin` drawn. An imported entity shows "The GGM does not publish attributes in this file". + +`relation.sourceCardinality` and `relation.targetCardinality` are enums `0..1`, `1`, `0..*` and `1..*`. They are set on relations with `origin` drawn and shown on the edge label. + +Rejected: attributes as separate `element` objects of their own type. ArchiMate has no attribute element, and the export would write them as elements that Archi does not know how to show. + +## Declarative versus imperative + +- The usage to entity link is a `related-object` list, so OpenRegister keeps it in its relation index, and the entity page's application list is a manifest `object-list` with a filter. No PHP. +- The attribute list and cardinalities are schema properties. No lifecycle, notification or aggregation is added. +- `DataEntityRelations.vue` and `DataEntityAttributes.vue` are the imperative pieces: the first only reads, the second writes one property of one object. + +## Seed data + +`architecture-views-editor` seeds drawn elements in `vng-gemma`. This change adds two own data entities, one relation and one usage link. + +### Schema: `element` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-el-melding` | `seed-el-melder` | +| identifier | `id-seed-el-melding` | `id-seed-el-melder` | +| type | `BusinessObject` | `BusinessObject` | +| name | Melding openbare ruimte | Melder | +| origin | drawn | drawn | +| attributes | meldingnummer (text, key), datum melding (date), locatie (text) | e-mailadres (text, key), naam (text) | + +### Schema: `relation` + +| Field | Object 1 | +|---|---| +| slug | `seed-rel-melding-melder` | +| type | `Association` | +| name | gedaan door | +| source | `id-seed-el-melding` | +| target | `id-seed-el-melder` | +| sourceCardinality | `0..*` | +| targetCardinality | `1` | +| origin | drawn | + +### Schema: `usage` + +The seeded usage `gebruik-topdesk-gem-leiden-deelnemers` (register.json `components.objects`) gets `dataEntities` with the uuid of `seed-el-melding`, so the entity page shows one application on a fresh install. + +## Risks + +- **Identifier lookups.** A relation stores ArchiMate identifiers in `source` and `target`, while an imported element's uuid is its GEMMA object id (`ArchiMateImportService.php:4794-4798`). The view queries on the entity's `identifier` field, not its uuid, and its vitest spec covers an imported and a drawn entity. +- **Two queries per open.** The relations of one entity need a query on `source` and one on `target`. Both are limited to 200 rows, and the text list says when the limit is reached. +- **Schema versions.** The fragment bumps three schemas. The register changelog entry 2.4.4 (register.json:7) records that a deployed version equal to or above the declared one makes the import skip. diff --git a/openspec/changes/architecture-data-model-and-ggm/proposal.md b/openspec/changes/architecture-data-model-and-ggm/proposal.md new file mode 100644 index 000000000..3a20c764e --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/proposal.md @@ -0,0 +1,45 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - landscape-usage-registration +--- + +# Show the data model and link applications to the entities of the GGM + +## Summary + +A municipal information manager opens a Data model page in stackiq and finds the entities of the Gemeentelijk Gegevensmodel (GGM), each with its relations to other entities drawn around it. They record which entities an application in use holds data for, and an entity's page lists those applications. The municipality can add its own data entities with attributes and keys, and relate them with a cardinality. + +## Why + +This change builds two rows of the stackiq parity matrix. Both come from the Helmond architecture repository tender, https://www.tenderned.nl/aankondigingen/overzicht/398728. + +- `stackiq:arch-data-model`, "Model data entities and their relations, as an entity relationship or UML diagram, next to the applications." The matrix note: "Helmond REQ54 asks for entity relationship or UML data models." BlueDolphin rates yes: "Primary keys are unique identifiers for a data object and can be used to create a relationship between data objects" (https://help.bluedolphin.io/en/articles/11967570-keys-and-relationships), with a logical data dictionary (https://help.bluedolphin.io/en/articles/11967568-logical-data-dictionary) and views of type Logical Data (https://help.bluedolphin.io/en/articles/11967483-views-button-explanation). SAP LeanIX rates partial. The lane decided build on tender demand and a core area. +- `stackiq:arch-ggm-link`, "Relate applications to the entities of the Gemeentelijk Gegevensmodel they hold data for." The matrix note: "Helmond's architecture repository tender (REQ3, REQ61) asks to relate the repository to the GGM." BlueDolphin rates partial, through a third-party route: "hiervoor gebruik je het AMEFF-bestand van het GGM uit de GEMMA-repository voor de Architectuur module van BlueDolphin" (https://github.com/Gemeente-Delft/Gemeentelijk-Gegevensmodel/blob/master/README.md). No competitor rates yes. Decided build on tender demand and a core area. + +## What stackiq has today + +- The GGM is already in the data after a GEMMA import. `lib/Settings/GEMMA_release.xml` defines the property `GGM-guid` (propid-6, :121153) and carries it on 503 `BusinessObject` elements, such as Formatieplaats, Werknemer and Aanvraag, and on the relationships between them. The import keeps every element type (`lib/Service/ArchiMateImportService.php:973-980`) and turns a property name into a key by lowercasing it (`convertToCamelCase`, :2256), so `GGM-guid` lands in `ggm-guid`, which the `element` schema declares (`lib/Settings/softwarecatalogus_register.json:5038`, schema at :4130). +- No page shows these entities. The only AMEF page, Standaarden (`src/manifest.json:701`), is filtered to `gemmaType` standaard. +- No application points at a data entity. `module` (register.json:6777) and `usage` (:2654) hold no such field. `usage.amefElements` holds reference component ids that `GebruikSyncService` fills (`lib/Service/GebruikSyncService.php:170-272`). +- `relation` (:6300) has `source`, `target`, `type` and `name`, and no cardinality. `element` has no attribute list. + +## What this change builds + +- A field `dataEntities` on `usage`: the data entities an application in use holds data for. +- A Data model index page over `element` objects of type `BusinessObject`, and a data entity page with its relations drawn around it, its attributes and the applications that hold its data. +- An attribute list with keys on data entities the municipality adds, and a cardinality on relations it draws. +- A picker for data entities on the usage page `GebruikDetail`. + +## Out of scope + +- Importing the GGM separately. The GEMMA release carries it, and a municipality that wants a newer GGM imports that AMEFF file through the existing ArchiMate import. +- The attributes of GGM entities. The GEMMA AMEFF file has entities and relations but no attributes, so a GGM entity shows none. Loading GGM attributes from its UML source is a later change. +- Drawing a data model freehand. The view editor from `architecture-views-editor` draws `BusinessObject` elements and their relations on a canvas; this change adds the entity pages and the fields. +- A field on the catalogue `module` that suppliers fill. See design D2. + +## Risks + +- A GEMMA re-import updates imported entities. The municipality's own entities carry `origin` drawn and the import never writes them (`architecture-views-editor` D2). +- An entity with many relations draws a crowded graph. The graph shows direct neighbours only, and the text list under it holds every relation. diff --git a/openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md b/openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md new file mode 100644 index 000000000..190551652 --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md @@ -0,0 +1,90 @@ +# data-model-and-ggm specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-data-model-and-ggm + +## Purpose + +A municipality sees the entities of the Gemeentelijk Gegevensmodel (GGM) in stackiq, with their relations, and records which entities each application in use holds data for. It can add its own data entities with attributes and keys. Entities are AMEF `element` objects of type `BusinessObject`, and the link is a field on `usage`, both stored in OpenRegister (ADR-001) and shown with `CnIndexPage`, `CnDetailPage` and `CnRelationshipGraph` (ADR-012). + +## ADDED Requirements + +### Requirement: REQ-DMG-001 Stackiq SHALL show the data entities of the imported model on a Data model page + +Stackiq SHALL offer a Data model page at `/gegevensmodel` (page `Gegevensmodel`) that lists the `element` objects of type `BusinessObject`, with columns name, GGM UML type, GEMMA type and origin, and the quick filters All, GGM and Own. It SHALL offer a data entity page at `/gegevensmodel/:id` (page `GegevensobjectDetail`) with the entity's name, documentation, GGM guid and origin. The page SHALL sit under the Architecture menu group. + +#### Scenario: An information manager finds a data entity +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** the seeded own data entity Melding openbare ruimte +- **WHEN** a municipal information manager opens Architecture, then Data model, chooses the quick filter Own and searches Melding +- **THEN** the list SHALL show Melding openbare ruimte with origin drawn +- **AND** opening it SHALL show its name and documentation + +#### Scenario: GGM entities are listed after a GEMMA import +@e2e exclude The CI seed (tests/e2e/ci-seed.sh) imports the register but no GEMMA model; tests/validate-manifest.js asserts that the Gegevensmodel page filters element on type BusinessObject and that the quick filter GGM filters on origin imported. + +- **GIVEN** a stackiq instance where a Nextcloud admin imported the GEMMA model +- **WHEN** a municipal information manager opens the Data model page and chooses the quick filter GGM +- **THEN** the list SHALL show GGM entities such as Werknemer and Aanvraag +- **AND** no application component SHALL be listed + +### Requirement: REQ-DMG-002 A data entity page SHALL draw the entity's relations to other entities + +The data entity page SHALL show the entity with every entity it is related to by an Association, Aggregation, Composition or Specialization relation, drawn on `CnRelationshipGraph` with the entity at the centre and each edge labelled with the relation name or type and its cardinalities. A table under the graph SHALL list the same relations as text. Choosing a node or a row SHALL open that entity's page. All colours SHALL be Nextcloud CSS variables. + +#### Scenario: An information manager sees what a melding relates to +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** the seeded own entities Melding openbare ruimte and Melder with the relation gedaan door +- **WHEN** a municipal information manager opens the page of Melding openbare ruimte +- **THEN** the graph SHALL show Melder connected to it with the label gedaan door, 0..* to 1 +- **AND** the relation table SHALL hold the same relation as text + +#### Scenario: Relations are found by identifier for imported and drawn entities +@e2e exclude A lookup detail; tests/vitest/dataEntityRelations.spec.js asserts that an imported entity, whose uuid differs from its identifier, and a drawn entity both find their relations through the identifier field. + +- **GIVEN** an imported entity whose uuid is its GEMMA object id and whose identifier starts with id- +- **WHEN** its relations load +- **THEN** relations whose source or target is its identifier SHALL be found + +### Requirement: REQ-DMG-003 An application in use SHALL record the data entities it holds data for + +The `usage` schema SHALL have a facetable list `dataEntities` of related `element` objects, and its picker SHALL offer only elements of type `BusinessObject`. The usage page `GebruikDetail` SHALL show and edit the list. The data entity page SHALL list the usages that name it under "Applications that hold this data". + +#### Scenario: An application owner links their application to a data entity +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** an application owner on the page of a usage at `/gebruik/:id` +- **WHEN** they edit the usage, pick the data entity Melding openbare ruimte and save +- **THEN** the usage page SHALL show the entity under data entities +- **AND** the page of Melding openbare ruimte SHALL list the usage under "Applications that hold this data" + +#### Scenario: The picker offers data entities only +@e2e exclude A schema setting; tests/Unit/Settings/DataModelRegisterShapeTest.php asserts that usage.dataEntities is a related element list with the query type=BusinessObject and is facetable. + +- **GIVEN** the merged register +- **WHEN** the shape test reads `usage.dataEntities` +- **THEN** it SHALL relate to `element` filtered on type BusinessObject + +### Requirement: REQ-DMG-004 A municipality SHALL add its own data entities with attributes, keys and cardinalities + +The Data model page SHALL offer New data entity, which SHALL create an `element` of type `BusinessObject` with `origin` drawn and ask only for name, documentation and attributes. An attribute SHALL have a name, a data type (text, number, date, boolean or reference), a key flag and a description. The entity page SHALL list attributes with key attributes first and marked as key in text, and SHALL let an owner edit the attributes of an entity with `origin` drawn. A relation with `origin` drawn SHALL carry a source and a target cardinality of `0..1`, `1`, `0..*` or `1..*`. An imported entity SHALL show that the GGM file publishes no attributes. + +#### Scenario: An information manager adds an entity with a key +@e2e tests/e2e/workflows/data-model.spec.ts + +- **GIVEN** a municipal information manager on the Data model page +- **WHEN** they choose New data entity, enter the name Vergunning, add the attribute zaaknummer as a text key and save +- **THEN** the Data model page SHALL list Vergunning under the quick filter Own +- **AND** its page SHALL show zaaknummer first, marked key + +#### Scenario: An imported entity cannot be given attributes +@e2e exclude A view rule; tests/vitest/dataEntityAttributes.spec.js asserts that an entity with origin imported renders no edit control and shows the notice about the GGM file. + +- **GIVEN** an imported GGM entity +- **WHEN** its page renders +- **THEN** the attribute section SHALL show no edit control +- **AND** it SHALL say that the GGM file publishes no attributes diff --git a/openspec/changes/architecture-data-model-and-ggm/tasks.md b/openspec/changes/architecture-data-model-and-ggm/tasks.md new file mode 100644 index 000000000..ce280e29e --- /dev/null +++ b/openspec/changes/architecture-data-model-and-ggm/tasks.md @@ -0,0 +1,70 @@ +# Tasks: architecture-data-model-and-ggm + +## Implementation tasks + +### Task 1: Register fragment for data entities, attributes and the usage link +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-003-an-application-in-use-shall-record-the-data-entities-it-holds-data-for +- **files**: `lib/Settings/register.d/architecture-data-model-and-ggm.json`, `tests/Unit/Settings/DataModelRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN `usage.dataEntities` relates to `element` with the query type=BusinessObject and is facetable + - GIVEN the merged register WHEN it loads THEN `element.attributes` has name, dataType, isKey and description, and `relation` has both cardinality enums + - GIVEN the fragment WHEN it is compared with development THEN `usage`, `element` and `relation` carry bumped versions + - GIVEN a fresh install WHEN the seed runs THEN the two own entities, their relation and the usage link exist +- [ ] Implement +- [ ] Test (PHPUnit `DataModelRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Data model index and entity page +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-001-stackiq-shall-show-the-data-entities-of-the-imported-model-on-a-data-model-page +- **files**: `src/manifest.d/architecture-data-model.json`, `src/menu-layout.json` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Gegevensmodel` filters `element` on type BusinessObject with the quick filters All, GGM and Own + - GIVEN the add dialog WHEN it opens THEN it asks only for name, documentation and attributes and creates type BusinessObject with origin drawn + - GIVEN the effective menu WHEN it renders THEN Data model sits under the Architecture group + - GIVEN an entity page WHEN it renders THEN "Applications that hold this data" lists usages filtered on `dataEntities` +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `tests/e2e/workflows/data-model.spec.ts`) + +### Task 3: Relations drawn around an entity +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-002-a-data-entity-page-shall-draw-the-entitys-relations-to-other-entities +- **files**: `src/views/architecture/DataEntityRelations.vue`, `src/utils/dataEntityGraph.js`, `src/customComponents.js`, `tests/vitest/dataEntityRelations.spec.js` +- **acceptance_criteria**: + - GIVEN an imported and a drawn entity WHEN their relations load THEN both are found through the identifier field + - GIVEN a relation with cardinalities WHEN it is drawn THEN the edge label holds its name and both cardinalities + - GIVEN the graph WHEN it renders THEN every colour prop is a CSS variable and a text table lists the same relations +- [ ] Implement +- [ ] Test (vitest `dataEntityRelations.spec.js`, Playwright relation scenario) + +### Task 4: Attributes with keys +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-004-a-municipality-shall-add-its-own-data-entities-with-attributes-keys-and-cardinalities +- **files**: `src/views/architecture/DataEntityAttributes.vue`, `tests/vitest/dataEntityAttributes.spec.js` +- **acceptance_criteria**: + - GIVEN a drawn entity WHEN its page renders THEN key attributes come first, marked key in text, and can be edited + - GIVEN an imported entity WHEN its page renders THEN no edit control shows and the notice about the GGM file does +- [ ] Implement +- [ ] Test (vitest `dataEntityAttributes.spec.js`, Playwright new entity scenario) + +### Task 5: Data entities on the usage page +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-003-an-application-in-use-shall-record-the-data-entities-it-holds-data-for +- **files**: `src/manifest.d/usages.json` +- **acceptance_criteria**: + - GIVEN a usage page WHEN it is edited THEN the data entities picker offers business objects only, and the saved list shows on the page +- [ ] Implement +- [ ] Test (Playwright usage link scenario) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-data-model-and-ggm/specs/data-model-and-ggm/spec.md#requirement-req-dmg-001-stackiq-shall-show-the-data-entities-of-the-imported-model-on-a-data-model-page +- **files**: `docs/features/data-model.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the Data model page, an entity page with its graph and a usage with data entities, each in a screenshot, and says how to import a newer GGM + - GIVEN a Dutch instance WHEN the Data model page renders THEN every new label reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-data-model-and-ggm --type change --strict` +- PHPUnit: `DataModelRegisterShapeTest`, `RegisterFragmentMergeTest` +- vitest: `dataEntityRelations.spec.js`, `dataEntityAttributes.spec.js` +- Playwright: `tests/e2e/workflows/data-model.spec.ts` +- Documentation in `docs/features/data-model.md` with screenshots (ADR-010) +- English and Dutch strings for every new label and enum value (ADR-005) diff --git a/openspec/changes/architecture-decision-register/.openspec.yaml b/openspec/changes/architecture-decision-register/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-decision-register/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-decision-register/design.md b/openspec/changes/architecture-decision-register/design.md new file mode 100644 index 000000000..44499d666 --- /dev/null +++ b/openspec/changes/architecture-decision-register/design.md @@ -0,0 +1,108 @@ +# Design: architecture-decision-register + +Read at development 49e65cb4. Line numbers below are from that sha. The Architecture menu group comes from `architecture-views-editor` (its D8), and `GebruikDetail` from `landscape-usage-registration`. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `stackiq` register (`lib/Settings/softwarecatalogus_register.json:817`), new schema `architectureDecision` | through a new fragment `lib/Settings/register.d/architecture-decision-register.json` | +| Lifecycle guard | new `lib/Lifecycle/ArchitectureDecisionReviewGuard.php`, implementing `OCA\OpenRegister\Lifecycle\LifecycleGuardInterface` (`openregister-ro/lib/Lifecycle/LifecycleGuardInterface.php:37`) | resolved by OpenRegister through the server container by its class name | +| Analysis stub | `tests/Stubs` gains the OpenRegister guard interface and `GuardResult` if psalm and phpstan cannot see them | | +| Pages | new `src/manifest.d/architecture-decision-register.json` with `Architectuurbesluiten` (index) and `ArchitectuurbesluitDetail` (detail) | | +| Pages | `GebruikDetail` in `src/manifest.d/usages.json` and `StandaardDetail` (`src/manifest.json:724`) each gain an `object-list` widget | | +| Menu | `src/menu-layout.json` relocates `Architectuurbesluiten` under the `Architecture` group | | +| Service, controller, routes | none | reads and writes go through OpenRegister's objects API; transitions through OpenRegister's lifecycle actions | + +## Decisions + +### D1. An architecture decision is a stackiq object, not a decidiq decision + +An architecture decision is recorded as an `architectureDecision` object in the `stackiq` register with a lifecycle declared on the schema (ADR-031). When a board formally adopts it in decidiq, the object links to that decidiq decision, in the way `catalogContract.decisions` does (register.json:3450). + +Rejected: every architecture decision as a decidiq decision, projected back as contracts are. An architecture decision record is an architecture artefact: context, options, consequences, links to applications and GEMMA elements, and a chain of decisions that supersede each other. Architects review it; most never reach a board. A decidiq decision is a governance act with meetings, voting and signing. Delegating would make recording any architecture decision depend on decidiq being installed, move architecture fields into decidiq, and cost the fail-closed cross-app event path the contracts carry, including the two event class spellings after the rename (`lib/Service/ContractApprovalService.php:64` to :104). + +### D2. The schema + +`architectureDecision`: + +| Field | Type | Notes | +|---|---|---| +| title | string, required | | +| context | string, long text | why a decision is needed | +| decision | string, long text | what was decided | +| alternatives | list of objects `{option, reasonRejected}` | the options not chosen | +| consequences | string, long text | | +| category | enum application, data, integration, infrastructure, security, standards, facetable | | +| impact | enum low, medium, high, facetable | | +| status | enum draft, in review, accepted, rejected, superseded, deprecated, default draft, facetable | lifecycle in the Declarative section | +| reviewer | string, a Nextcloud user id | required to submit | +| decidedOn | date | set by the owner when accepted | +| applications | list of related `usage` | the applications in use it affects | +| elements | list of related `element` | the GEMMA reference components, standards or other elements it affects | +| supersedes | related `architectureDecision` | | +| supersededBy | related `architectureDecision` | | +| boardDecisions | list of decidiq decision uuids, `x-external-register` decidesk, `referenceType` decision | as `catalogContract.decisions` (register.json:3450) | + +The read and write rules follow `usage` (register.json, `usage.authorization`): the catalogue groups create and update, and read is matched on `_organisation`. OpenRegister multitenancy scopes each decision to the organisation that recorded it. + +### D3. Fixed classification lists + +Category and impact are enums on the schema, so `CnIndexPage` facets and the forms render them without code. + +Rejected: dropdown fields an administrator adds to a decision template, as the LeanIX changelog describes. That needs a field-definition schema and a renderer for its answers next to the schema-driven forms (ADR-012), and properties added to the schema in OpenRegister's editor vanish on the next register import with a version bump. Two fixed lists cover the classification the row asks for, and a list can grow in a later register version. + +### D4. A second pair of eyes through one guard + +`ArchitectureDecisionReviewGuard::check(object, action, userId)` returns: + +| Action | Allowed when | +|---|---| +| submit | `reviewer` names a Nextcloud user and is not the caller | +| accept, reject | the caller is the `reviewer` | +| supersede | `supersededBy` points at a decision in status accepted | + +Other actions pass. The guard reads only; it never writes (the interface contract, `LifecycleGuardInterface.php:31` to :35). OpenRegister runs it for a named transition and for a direct edit of `status` alike (`openregister-ro/openspec/specs/object-lifecycle/spec.md:126` to :134), and denies with a 403 and the guard's message. + +Rejected: the review rule in a stackiq controller in front of the transition. A direct save of `status` through OpenRegister's objects API would pass around it. The guard sits in the save pipeline. + +### D5. Pages + +`Architectuurbesluiten` (`/architectuurbesluiten`) is a `CnIndexPage` over `architectureDecision` with columns title, status, category, impact and reviewer, the facets in the sidebar, and quick filters All, In review, Accepted and "To review by me" (`{"reviewer": "@me"}`, resolved by `@conduction/nextcloud-vue` `src/utils/resolveFilterTokens.js:119`). + +`ArchitectuurbesluitDetail` (`/architectuurbesluiten/:id`) is a `type: detail` page on the ADR-062 grid with a `data` widget for the text fields, a second `data` widget for classification, reviewer and dates, `object-list` widgets for the linked applications and elements, `lifecycleActions` on, and the History tab. + +`GebruikDetail` and `StandaardDetail` each get an `object-list` widget "Architecture decisions" over `architectureDecision` with filter `{"applications": "@objectId"}` or `{"elements": "@objectId"}`, which OpenRegister answers with a JSON containment test (`openregister-ro/lib/Db/MagicMapper/MagicSearchHandler.php:1601`). + +## Declarative versus imperative + +- The lifecycle is declared as `configuration.x-openregister-lifecycle` on `architectureDecision`: field `status`, initial draft, final rejected, superseded and deprecated, transitions submit (draft to in review, `requires` the guard), accept (in review to accepted, `requires` the guard), reject (in review to rejected, `requires` the guard), rework (in review or rejected to draft), supersede (accepted to superseded, `requires` the guard) and deprecate (accepted to deprecated). Every `from` and `to` value is an enum value exactly (register changelog 2.4.4, register.json:7). +- Two notifications are declared in `x-openregister-notifications` on the schema, in the shape `usage` uses (register.json:2662): `review-requested`, trigger `updated` with the condition status equals in review, recipient `{"kind": "field", "field": "reviewer"}` (a single user id, which the resolver checks is a real user, `NotificationRecipientResolver.php:187`); and `review-concluded`, trigger `updated` with status in accepted or rejected, recipient `{"kind": "object-acl", "permission": "manage"}`. Both on the channels `nc-notification` and `email`, with Dutch and English subjects. +- The links are `related-object` properties and manifest `object-list` widgets. +- The guard is the only PHP, and it is a read-only check the platform calls. + +## Seed data + +All objects live in the `stackiq` register. + +### Schema: `architectureDecision` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-ab-zaakgericht-werken` | `seed-ab-api-first` | +| title | Eén zaaksysteem voor alle domeinen | Nieuwe koppelingen alleen via API's | +| context | Drie domeinen gebruiken elk een eigen zaaksysteem. | Bestandsuitwisseling via FTP is niet te volgen. | +| decision | We gaan naar één zaaksysteem, domein voor domein. | Een nieuwe koppeling gebruikt een gedocumenteerde API. | +| category | application | integration | +| impact | high | medium | +| status | accepted | in review | +| reviewer | admin | admin | +| applications | `gebruik-suite4-gem-delft-eigenaar` | | + +The usage slug is one of the three the register seeds (register.json `components.objects`). + +## Risks + +- **Guard resolution.** OpenRegister resolves a guard by class name through the server container and fails closed when it cannot (`openregister-ro/openspec/specs/object-lifecycle/spec.md:593`). A typo in the `requires` value blocks the transition on every instance, so the register shape test asserts the value equals the guard's class name. +- **Payload shape.** The guard reads `reviewer`, `supersededBy` and the linked decision's status from the payload OpenRegister passes. Its unit test builds that payload from a saved object, not by hand. +- **One reviewer.** The notification recipient kind `field` takes one user id, so a decision has one reviewer. A review by a group can follow once the resolver takes a list. diff --git a/openspec/changes/architecture-decision-register/proposal.md b/openspec/changes/architecture-decision-register/proposal.md new file mode 100644 index 000000000..a548a7a2c --- /dev/null +++ b/openspec/changes/architecture-decision-register/proposal.md @@ -0,0 +1,46 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - landscape-usage-registration +--- + +# Record architecture decisions with a review and link them to applications + +## Summary + +A municipal information manager records an architecture decision in stackiq: the context, the decision, the options they rejected and the consequences, classified by category and impact. They name a reviewer and submit it; the reviewer accepts or rejects it, and a later decision can supersede it. Each decision links to the applications in use and the GEMMA elements it affects, and the page of an application in use lists the decisions about it. When a board formally adopts a decision in decidiq, the architecture decision links to that decidiq decision. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-decision-register`, "Record architecture decisions with a status and review flow, and link each decision to the applications it affects." The demand is a changelog entry, https://updates.leanix.net/announcements/classify-architecture-decisions-with-dropdown-fields. + +- SAP LeanIX rates yes: admins "add single-select and multi-select dropdown fields to architecture decision templates", and "Document decisions about enterprise architecture in a structured, template-driven format ... Track decisions through a review process with defined statuses" (https://help.sap.com/docs/leanix/ea/architecture-decisions). +- GLPI rates no; GEMMA Softwarecatalogus, BlueDolphin and TOPdesk are unknown. + +The lane decided build: architecture is a core area. + +## What stackiq has today + +- The only decisions are contract approvals and renewals, delegated to decidiq. `lib/Service/ContractApprovalService.php:254` (`submitForApproval`) dispatches decidiq's `DecisionRequestedEvent` with the decision type `contract` or `contract-renewal` (:129, :135) and projects the outcome onto `approvalDecisionId` and `approvalState` (`lib/Settings/register.d/contracts-to-decidesk.json`). It must handle two event class spellings after decidiq's rename (:64 to :104) and fails closed when decidiq is absent. +- `catalogContract.decisions` (`lib/Settings/softwarecatalogus_register.json:3450`) holds decidiq decision uuids with `x-external-register` decidesk and `referenceType` decision: a link, not a copy. +- No schema holds an architecture decision, and no page lists one. +- OpenRegister runs a lifecycle transition through `requires` guards that an app can supply (`openregister-ro/openspec/specs/object-lifecycle/spec.md:126` to :134, `openregister-ro/lib/Lifecycle/LifecycleGuardInterface.php:37`), and its notification engine resolves a recipient from a user id held in an object field (`openregister-ro/lib/Service/Notification/NotificationRecipientResolver.php:187`). + +## What this change builds + +- A schema `architectureDecision` in the `stackiq` register with the decision text, category, impact, reviewer, links to usages and AMEF elements, supersedes and superseded by, and links to decidiq decisions. +- A declared review lifecycle (draft, in review, accepted, rejected, superseded, deprecated) with one stackiq guard class that enforces a second pair of eyes. +- Notifications to the reviewer on submit and to the owner on the outcome, declared on the schema. +- An Architecture decisions index and a decision page under the Architecture menu group, and a list of decisions on the usage page and on the standard page. + +## Out of scope + +- Making a formal board or council decision. That is decidiq's: its meetings, voting and signing. Stackiq only links to a decidiq decision the organisation already took. Raising an architecture decision in decidiq through `DecisionRequestedEvent` would need decidiq to accept a new decision type, and can follow the contract pattern later. +- Templates an administrator defines. Category and impact are fixed lists; see design D3. +- Decisions about a catalogue product for every municipality. A decision belongs to the organisation that records it. + +## Risks + +- The guard class implements an OpenRegister interface. If OpenRegister cannot resolve the guard, the transition fails closed, so a misconfigured instance blocks acceptance rather than letting anyone accept. +- A reviewer who leaves the organisation keeps pending decisions. The owner can send the decision back to draft and name a new reviewer. diff --git a/openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md b/openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md new file mode 100644 index 000000000..abe18f281 --- /dev/null +++ b/openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md @@ -0,0 +1,87 @@ +# architecture-decision-register specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-decision-register + +## Purpose + +A municipality records its architecture decisions in stackiq with a review by a second person, and links each decision to the applications in use and the GEMMA elements it affects. Decisions are `architectureDecision` objects in the `stackiq` register (ADR-001) with a lifecycle and notifications declared on the schema (ADR-031), shown with `CnIndexPage` and `CnDetailPage` (ADR-012). A formal board decision stays in decidiq, and stackiq links to it. + +## ADDED Requirements + +### Requirement: REQ-ADREG-001 An information manager SHALL record an architecture decision with its context, options and consequences + +Stackiq SHALL offer an Architecture decisions page at `/architectuurbesluiten` (page `Architectuurbesluiten`) and a decision page at `/architectuurbesluiten/:id` (page `ArchitectuurbesluitDetail`) under the Architecture menu group. A decision SHALL have a title, a context, the decision, the rejected alternatives each with a reason, the consequences, a category (application, data, integration, infrastructure, security or standards), an impact (low, medium or high), a status and a reviewer. Category, impact and status SHALL be facetable. Decisions SHALL be scoped to the organisation that recorded them. + +#### Scenario: An information manager records a decision +@e2e tests/e2e/workflows/architecture-decisions.spec.ts + +- **GIVEN** a municipal information manager signed in to stackiq +- **WHEN** they open Architecture, then Architecture decisions, create the decision Nieuwe koppelingen alleen via API's with category integration, impact medium, one rejected alternative and a reviewer, and save +- **THEN** the page SHALL list the decision with status draft +- **AND** its page SHALL show the rejected alternative with its reason + +#### Scenario: Another municipality does not see the decision +@e2e exclude The CI instance has one organisation; tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php asserts the read rules match on _organisation, as usage does. + +- **GIVEN** a decision of municipality A +- **WHEN** a user of municipality B opens the Architecture decisions page +- **THEN** the decision SHALL NOT be listed + +### Requirement: REQ-ADREG-002 A decision SHALL be reviewed by a second person through a declared lifecycle + +The status SHALL move through transitions declared as `x-openregister-lifecycle`: submit (draft to in review), accept and reject (in review to accepted or rejected), rework (in review or rejected to draft), supersede (accepted to superseded) and deprecate (accepted to deprecated), with values that are members of the status enum. Submit SHALL be allowed only when the reviewer is a Nextcloud user other than the caller. Accept and reject SHALL be allowed only for the reviewer. Supersede SHALL be allowed only when the decision names an accepted decision that supersedes it. A denied transition SHALL answer 403 with the reason, whether it was applied as an action or as a direct edit of the status. + +#### Scenario: A reviewer accepts a decision +@e2e tests/e2e/workflows/architecture-decisions.spec.ts + +- **GIVEN** a decision in review whose reviewer is a second test user, created by the test fixture +- **WHEN** that reviewer opens the decision and applies Accept +- **THEN** the decision SHALL read accepted +- **AND** the transition SHALL appear in its History tab + +#### Scenario: The author cannot accept their own decision +@e2e exclude A guard rule; tests/Unit/Lifecycle/ArchitectureDecisionReviewGuardTest.php asserts that submit is denied when the reviewer is the caller and that accept and reject are denied for anyone but the reviewer. + +- **GIVEN** a decision in review whose reviewer is a colleague +- **WHEN** the author applies Accept +- **THEN** the transition SHALL be denied with a 403 naming the reviewer rule + +#### Scenario: A decision is superseded only by an accepted one +@e2e exclude A guard rule; tests/Unit/Lifecycle/ArchitectureDecisionReviewGuardTest.php asserts supersede is allowed when supersededBy points at an accepted decision and denied otherwise. + +- **GIVEN** an accepted decision whose supersededBy names a decision still in draft +- **WHEN** the owner applies Supersede +- **THEN** the transition SHALL be denied + +### Requirement: REQ-ADREG-003 The reviewer and the owner SHALL be notified through declared notifications + +The schema SHALL declare in `x-openregister-notifications` a notification to the reviewer when a decision enters in review, and a notification to the owners of the decision when it is accepted or rejected, on the channels Nextcloud notification and email, with Dutch and English subjects. + +#### Scenario: A reviewer is told a decision waits for them +@e2e exclude Delivery runs in OpenRegister's engine; tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php asserts the review-requested rule uses the field recipient reviewer and the review-concluded rule the object-acl manage recipient, and that both subjects have nl and en. + +- **GIVEN** a decision with a colleague as reviewer +- **WHEN** the author submits it +- **THEN** the colleague SHALL receive a Nextcloud notification that links to the decision + +### Requirement: REQ-ADREG-004 A decision SHALL link to the applications and elements it affects and to board decisions + +A decision SHALL hold a list of the usages it affects, a list of the AMEF elements it affects, the decision it supersedes, the decision that supersedes it, and a list of decidiq decision ids with `x-external-register` decidesk. The usage page `GebruikDetail` and the standard page `StandaardDetail` SHALL each list the architecture decisions that name them. + +#### Scenario: An application owner sees the decisions about their application +@e2e tests/e2e/workflows/architecture-decisions.spec.ts + +- **GIVEN** the seeded accepted decision Eén zaaksysteem voor alle domeinen linked to the seeded Suite4 usage +- **WHEN** an application owner opens that usage at `/gebruik/:id` +- **THEN** the list Architecture decisions SHALL show the decision with status accepted +- **AND** choosing it SHALL open the decision page + +#### Scenario: A decision links a board decision without copying it +@e2e exclude The CI instance runs without decidiq; tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php asserts boardDecisions is a uuid list with x-external-register decidesk and referenceType decision, as catalogContract.decisions is. + +- **GIVEN** a decision adopted by a board in decidiq +- **WHEN** the owner adds the decidiq decision to the architecture decision +- **THEN** the architecture decision SHALL store only the decidiq decision id diff --git a/openspec/changes/architecture-decision-register/tasks.md b/openspec/changes/architecture-decision-register/tasks.md new file mode 100644 index 000000000..9447ed36e --- /dev/null +++ b/openspec/changes/architecture-decision-register/tasks.md @@ -0,0 +1,62 @@ +# Tasks: architecture-decision-register + +## Implementation tasks + +### Task 1: Register fragment with lifecycle and notifications +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-001-an-information-manager-shall-record-an-architecture-decision-with-its-context-options-and-consequences +- **files**: `lib/Settings/register.d/architecture-decision-register.json`, `tests/Unit/Settings/ArchitectureDecisionRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN the `stackiq` register lists `architectureDecision` with magic mapping on and read rules matched on `_organisation` + - GIVEN the lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a status enum value and every `requires` equals the guard's class name + - GIVEN the notifications WHEN the shape test reads them THEN review-requested uses the field recipient reviewer, review-concluded the object-acl manage recipient, and both subjects have nl and en + - GIVEN `boardDecisions` WHEN the shape test reads it THEN it matches the shape of `catalogContract.decisions` + - GIVEN a fresh install WHEN the seed runs THEN the two demo decisions exist +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureDecisionRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Review guard +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-002-a-decision-shall-be-reviewed-by-a-second-person-through-a-declared-lifecycle +- **files**: `lib/Lifecycle/ArchitectureDecisionReviewGuard.php`, `tests/Stubs/`, `tests/Unit/Lifecycle/ArchitectureDecisionReviewGuardTest.php` +- **acceptance_criteria**: + - GIVEN submit WHEN the reviewer is empty, unknown or the caller THEN the guard denies with a message + - GIVEN accept or reject WHEN the caller is not the reviewer THEN the guard denies + - GIVEN supersede WHEN supersededBy is not an accepted decision THEN the guard denies + - GIVEN any action WHEN the guard runs THEN it writes nothing +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureDecisionReviewGuardTest`, psalm and phpstan on the guard) + +### Task 3: Decision pages and menu +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-001-an-information-manager-shall-record-an-architecture-decision-with-its-context-options-and-consequences +- **files**: `src/manifest.d/architecture-decision-register.json`, `src/menu-layout.json`, `tests/e2e/workflows/architecture-decisions.spec.ts` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Architectuurbesluiten` and `ArchitectuurbesluitDetail` exist with the quick filter "To review by me" on `@me` + - GIVEN the effective menu WHEN it renders THEN Architecture decisions sits under the Architecture group + - GIVEN a decision page WHEN it renders THEN its lifecycle actions and History tab show +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright record and accept scenarios) + +### Task 4: Decisions on the usage and standard pages +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-004-a-decision-shall-link-to-the-applications-and-elements-it-affects-and-to-board-decisions +- **files**: `src/manifest.d/usages.json`, `src/manifest.json` +- **acceptance_criteria**: + - GIVEN a decision that names a usage WHEN the usage page opens THEN Architecture decisions lists it + - GIVEN a decision that names a standard WHEN the standard page opens THEN Architecture decisions lists it +- [ ] Implement +- [ ] Test (Playwright usage page scenario) + +### Task 5: Documentation and translations +- **spec_ref**: openspec/changes/architecture-decision-register/specs/architecture-decision-register/spec.md#requirement-req-adreg-002-a-decision-shall-be-reviewed-by-a-second-person-through-a-declared-lifecycle +- **files**: `docs/features/architecture-decisions.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows a decision page, the review step and the list on a usage page, each in a screenshot, and explains when to use decidiq + - GIVEN a Dutch instance WHEN the pages render THEN every new label and enum value reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-decision-register --type change --strict` +- PHPUnit: `ArchitectureDecisionRegisterShapeTest`, `ArchitectureDecisionReviewGuardTest`, `RegisterFragmentMergeTest` +- Playwright: `tests/e2e/workflows/architecture-decisions.spec.ts` +- Documentation in `docs/features/architecture-decisions.md` with screenshots (ADR-010) +- English and Dutch strings for every new label, enum value and notification subject (ADR-005) diff --git a/openspec/changes/architecture-future-state-scenarios/.openspec.yaml b/openspec/changes/architecture-future-state-scenarios/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-future-state-scenarios/design.md b/openspec/changes/architecture-future-state-scenarios/design.md new file mode 100644 index 000000000..3771b1eb2 --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/design.md @@ -0,0 +1,149 @@ +# Design: architecture-future-state-scenarios + +Read at development 49e65cb4. Line numbers below are from that sha. `ReferenceComponentCoverageDerivation`, `ReferenceComponentCoverageService` and `OrganisationReportAccess` come from `architecture-reference-component-coverage` (its D1 and D2); the Architecture menu group from `architecture-views-editor` (its D8); `GebruikDetail` and the usage lifecycle on English values from `landscape-usage-registration`. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `stackiq` register (`lib/Settings/softwarecatalogus_register.json:817`), new schemas `scenario` and `scenarioChange`; `usage` (:2654) read and, on apply, written | through a new fragment `lib/Settings/register.d/architecture-future-state-scenarios.json` | +| Derivation | new `lib/Service/LandscapeAtDateDerivation.php` | pure, uses `PortfolioReportDerivation::deriveLifecyclePhase` (`lib/Service/PortfolioReportDerivation.php:58`) | +| Service | new `lib/Service/LandscapeComparisonService.php` | reads usages through `ReferenceComponentCoverageService` and scores both landscapes with `ReferenceComponentCoverageDerivation` | +| Controller and route | new `lib/Controller/LandscapeComparisonController.php`, route `landscapeComparison#index` at `GET /api/landscape-comparison`, next to `portfolioReport#index` (`appinfo/routes.php:303`) | | +| Pages | new `src/manifest.d/architecture-future-state-scenarios.json` with `Scenarios` (index), `ScenarioDetail` (detail) and `LandscapeComparison` (custom) | | +| Views | new `src/views/architecture/LandscapeComparisonView.vue`; `src/views/LifecycleRoadmapView.vue` gains a Compare with the plan button in its header (:4 to :24) | registered in `src/customComponents.js` | +| Store | new `src/store/modules/scenarioApply.js` | writes through OpenRegister's objects API | +| Menu | `src/menu-layout.json` relocates `Scenarios` under the `Architecture` group | | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`, `deepMergeConfig` at :7338) with `components.schemas`, `components.registers.stackiq.schemas` and `components.registers.stackiq.configuration.schemas` entries for both new schemas. + +## Decisions + +### D1. The plan is read from the dates, at any date + +The landscape on date D holds every usage whose phase on D, by `deriveLifecyclePhase($usage, D)` (`PortfolioReportDerivation.php:58`), is In production or To be phased out. A usage whose `plannedReplacementDate` is on or before D leaves the landscape on that date and its `plannedReplacement` module enters it as a planned successor, carrying the usage's `usedForReferenceComponents`. A usage with no phase date but a `status` of In production or To be phased out (the schema default is In production, register.json usage `status`) is in today's landscape with that phase and stays in every later landscape until a date or a scenario change says otherwise, because most usages carry a status and no dates. A usage with neither a phase date nor one of those status values reads Onbekend and is listed apart as not dated. + +Today's landscape is the same function with D set to today. So "today" and "the plan on D" come from one rule. The Portfolio roadmap uses the same date rule in the browser (`src/utils/lifecyclePhase.js:101`); the one difference is the status fallback, and a usage it adds is one the roadmap shows in its Onbekend lane. + +Rejected: a stored snapshot of the landscape per date. It would be a second copy of the usages that drifts when a date is edited, which the archived lifecycle change ruled out for the phase (its design Decision 1). + +### D2. A scenario is a change set on top of the plan + +`scenario`: + +| Field | Type | Notes | +|---|---|---| +| name | string, required | | +| description | string | | +| organisation | related `organization`, required | the landscape it changes; the comparison checks access on it | +| targetDate | date, required | | +| status | enum draft, proposed, adopted, rejected, default draft, facetable | lifecycle in the Declarative section | +| appliedAt | date-time | set when the scenario is applied | + +`scenarioChange`: + +| Field | Type | Notes | +|---|---|---| +| scenario | related `scenario`, required | | +| action | enum add, phase out, replace, required | | +| usage | related `usage` | required for phase out and replace | +| module | related `module` | required for add and replace | +| referenceComponents | list of related `element`, query `gemmaType=referentiecomponent` | for add and replace; the form fills it from `module.referenceComponents` | +| effectiveDate | date | defaults to the scenario's target date | +| note | string | | +| appliedTo | related `usage` | set on apply, so a second apply changes nothing | + +The scenario landscape on its target date is the plan on that date with the changes applied in order of `effectiveDate`. Each difference in the comparison names its source: plan or scenario. + +Rejected: future-state flags on usages, as BlueDolphin does on objects. A flag holds one future; two options for the same decision (replace A by B, or by C) need two change sets side by side. + +Rejected: a copy of every usage per scenario. Copies go stale the moment today's landscape changes, while a change set stays small and is always read against the current data. + +### D3. One comparison endpoint + +`GET /api/landscape-comparison?organisation=&date=&scenario=` (scenario optional; with a scenario, its organisation and target date are used). The controller runs `OrganisationReportAccess::isAuthorised` before any read and fails closed, as the two report controllers do. `LandscapeComparisonService` reads the organisation's usages once with the bounded query of `ReferenceComponentCoverageService`, and reads the scenario's changes with RBAC on. `LandscapeAtDateDerivation` builds both landscapes, and `ReferenceComponentCoverageDerivation` scores each for coverage. The response: + +```json +{ + "organisation": "00000000-0000-0000-0000-000000000000", + "today": "2026-09-27", + "date": "2027-06-30", + "scenario": null, + "applications": [ + { "moduleName": "Zaaksysteem A", "change": "removed", "source": "plan", "date": "2027-03-01" } + ], + "coverage": [ + { "component": "Zaakregistratiecomponent", "today": "overlap", "future": "covered" } + ], + "notDated": 2, + "truncated": false +} +``` + +`change` is one of added, removed, replaced (with `replacedBy`) and unchanged; `coverage` lists only components whose state differs. + +Rejected: computing the comparison in the browser with `lifecyclePhase.js`. The coverage half needs the reference components and the organisation-scoped usage read that `architecture-reference-component-coverage` put behind one bounded endpoint; a second path in the browser would read them differently. + +### D4. The pages + +`Scenarios` (`/scenarios`) is a `CnIndexPage` over `scenario` with columns name, organisation, targetDate and status, and quick filters All, Draft, Proposed and Adopted. `ScenarioDetail` (`/scenarios/:id`) is a `type: detail` page with a `data` widget, an `object-list` widget over `scenarioChange` with filter `{"scenario": "@objectId"}` and columns action, usage, module and effectiveDate, the body widget `LandscapeComparisonView` bound to the scenario, `lifecycleActions` on, and the History tab. + +`LandscapeComparison` (`/landscape-comparison`) is a custom page holding `LandscapeComparisonView` with an organisation picker and a date picker, for the plan alone. The Portfolio roadmap header gets a button Compare with the plan that opens it with the roadmap's organisation. + +`LandscapeComparisonView.vue` shows two columns, Today and the chosen date, with a `CnDataTable` of application differences (a text tag Added, Removed or Replaced by, and the source), a table of coverage differences (gap closed, gap opened, overlap resolved, overlap created) and the not dated count. Colours are Nextcloud CSS variables and every state is also a word. + +### D5. Applying an adopted scenario + +On an adopted scenario that has no `appliedAt`, the scenario page offers Apply to landscape. `src/store/modules/scenarioApply.js` writes each change through OpenRegister's objects API as the signed-in user: + +| Action | Write | +|---|---| +| add | a new `usage` with `consumer` the scenario's organisation, `module`, `usedForReferenceComponents`, `status` Planned and `startDateInProduction` the effective date | +| phase out | `startDateOutPhased` on the usage set to the effective date | +| replace | `plannedReplacement` and `plannedReplacementDate` on the usage | + +It stores the written usage in `appliedTo` after each write and sets `appliedAt` last. A retry skips changes that have `appliedTo`. After the apply, the plan comparison and the Portfolio roadmap show the changes, because they read the same fields. + +Rejected: a PHP service for the apply. It is three plain object writes per change with no rule the platform does not already enforce (ADR-022, config rule "Uses OpenRegister API directly from frontend"). + +## Declarative versus imperative + +- The scenario status lifecycle is declared as `configuration.x-openregister-lifecycle` on `scenario`: initial draft, final adopted, transitions propose (draft to proposed), adopt (proposed to adopted), reject (proposed to rejected) and rework (proposed or rejected to draft). The `from` and `to` values are the enum values exactly (register changelog 2.4.4, register.json:7). +- The scenario to change and change to usage links are `related-object` properties; the change list is a manifest `object-list`. No PHP. +- The comparison is imperative, because it joins usages, planned dates, a change set and reference components across two registers, which no `x-openregister-aggregation` expresses. It is a pure derivation behind one bounded endpoint. +- The apply is imperative and runs in the browser store. + +## Seed data + +All objects live in the `stackiq` register. They use the three usages the register already seeds (register.json `components.objects`). + +### Schema: `scenario` + +| Field | Object 1 | +|---|---| +| slug | `seed-scenario-delft-2027` | +| name | Servicedesk en schuldhulp in 2027 | +| organisation | `gemeente-delft` | +| targetDate | 2027-06-30 | +| status | proposed | + +### Schema: `scenarioChange` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-scenario-change-uitfaseren` | `seed-scenario-change-toevoegen` | +| scenario | `seed-scenario-delft-2027` | `seed-scenario-delft-2027` | +| action | phase out | add | +| usage | `gebruik-suite4-gem-delft-eigenaar` | | +| module | | `topdesk-itsm` | +| effectiveDate | 2027-06-30 | 2027-03-01 | +| note | Schuldhulp gaat naar de regio. | Eigen servicedesk in plaats van de gedeelde. | + +The module slug `topdesk-itsm` is the module the seeded TOPdesk usage of Servicecenter Rijnland points at, so the demo needs no new module. The seeded usages hold `status` `in-gebruik`, which is not an enum value, and no phase dates, so the Suite4 usage reads not dated until its status is set; the demo scenario shows that case on purpose. The Playwright spec builds its own usages with dates through OpenRegister's objects API instead of relying on the seed. + +## Risks + +- **Undated usages.** A usage with no phase date and no current status value is in neither landscape. The count of not dated usages sits next to the comparison so a reader sees why an application is missing. +- **Two sources for one date.** A plan replacement and a scenario change can touch the same usage. The scenario change wins, and the difference says both sources. +- **Partial apply.** A failed write stops the apply with a notice naming the change; `appliedTo` makes the retry safe, and `appliedAt` is set only when every change is written. +- **Schema versions.** Both schemas are new, so no version bump is needed on existing schemas. diff --git a/openspec/changes/architecture-future-state-scenarios/proposal.md b/openspec/changes/architecture-future-state-scenarios/proposal.md new file mode 100644 index 000000000..f05924552 --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/proposal.md @@ -0,0 +1,49 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - architecture-reference-component-coverage + - landscape-usage-registration +--- + +# Compare a future landscape with today's + +## Summary + +A municipal information manager picks a date and sees the organisation's landscape on that date next to today's: which applications come in, go out or are replaced, and which reference component gaps and overlaps that closes or opens. The future comes from what the data already plans (planned usages, phase-out dates, planned replacements). On top of that plan they can write a named scenario, a what-if with its own additions, phase-outs and replacements, compare it the same way, take it through a proposal and adoption, and apply an adopted scenario to the landscape as planned changes. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-scenarios`, "Model a future-state landscape and compare it with today's." No tender or feature request names it. + +- SAP LeanIX rates yes: "plan your target architecture and monitor initiative progress" (https://help.sap.com/docs/leanix/ea/sap-leanix-architecture-and-road-map-planning), and "Understanding your architecture across the past, present, and future ... introducing the committed future" (https://updates.leanix.net/announcements/plan-with-consistent-future-architecture-data-introducing-the-committed-future). +- BlueDolphin rates yes: "Objects can be either Current (default) or Future state" (https://help.bluedolphin.io/en/articles/11967531-object-lifecycle-state) and "Map current and future state capabilities" (https://bluedolphin.io/capability-based-planning/). +- GEMMA Softwarecatalogus rates partial: "geplande harmonisaties ... met een status gepland met bijbehorende datum. Zo kan ook het uiteindelijke doel-landschap in 1 overzicht inzichtelijk worden gemaakt" (https://www.softwarecatalogus.nl/node/19703), with no side-by-side comparison. + +The lane decided build because two competitors rate yes and architecture is a core area. The matrix note holds: planned usage and planned replacements exist per record, but there is no future-state model and no comparison with today. + +## What stackiq has today + +- A usage carries five phase start dates, from `startDateAcquisition` to `startDateOutPhased`, a `status` with the value Planned, and `plannedReplacement` with `plannedReplacementDate` (`lib/Settings/softwarecatalogus_register.json:3070` and the usage schema at :2654). The archived change `2026-06-14-application-lifecycle-tracking` added the replacement fields as "an organisation's portfolio decision about its usage" (its design Decision 3). +- The phase of a usage at any moment is a pure function of those dates, in the browser (`src/utils/lifecyclePhase.js:101`, `derivePhase(gebruik, now)`) and in PHP (`lib/Service/PortfolioReportDerivation.php:58`, `deriveLifecyclePhase`). Both take the moment as an argument. +- The Portfolio roadmap page (`src/manifest.json:1013`, `src/views/LifecycleRoadmapView.vue`) groups today's usages by phase and orders them by the nearest EOL, phase-out or replacement date (`buildEntry`, :394). It shows one moment, today, and no comparison. +- `architecture-reference-component-coverage` adds a pure coverage derivation and the shared organisation check `OrganisationReportAccess`. + +## What this change builds + +- A landscape comparison: `lib/Service/LandscapeComparisonService.php` and `GET /api/landscape-comparison`, which builds today's landscape and the landscape on a date (the plan, plus a scenario when one is named) and returns the application differences and the coverage differences. +- Two schemas in the `stackiq` register: `scenario` (name, organisation, target date, status) and `scenarioChange` (add, phase out or replace). +- A Scenarios index and a scenario page with the comparison, under the Architecture menu group, and a Compare with the plan page reached from the Portfolio roadmap. +- An Apply to landscape action on an adopted scenario that writes its changes into the usages as planned dates and replacements. + +## Out of scope + +- Cost of a future landscape. Cost stays with the contract administration, as the archived lifecycle change decided. +- Future states of GEMMA elements or views. BlueDolphin marks objects as future; this change works on the organisation's applications in use. +- Drawing a target architecture view. The view editor from `architecture-views-editor` can draw one; linking a view to a scenario can follow. +- Approval of a scenario by a board. The adopt transition records the decision; routing it to decidiq is `architecture-decision-register`'s question. + +## Risks + +- A plan built from dates is only as good as the dates. Usages without dates have phase Onbekend at every moment; the comparison lists them apart as "not dated" instead of guessing. +- Applying a scenario writes to usages other people own. It runs only on an adopted scenario, as the signed-in user with their rights, and every write shows in the usage's History tab. diff --git a/openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md b/openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md new file mode 100644 index 000000000..c6b1e1206 --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md @@ -0,0 +1,99 @@ +# future-state-scenarios specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-future-state-scenarios + +## Purpose + +A municipality compares its landscape of today with its landscape on a future date: first as the data already plans it, then with a named scenario of its own on top. The comparison shows which applications come, go or are replaced, and which reference component gaps and overlaps that changes. An adopted scenario can be applied to the usages as planned dates and replacements. Scenarios are objects in the `stackiq` register (ADR-001) with a declared status lifecycle (ADR-031), shown with `CnIndexPage` and `CnDetailPage` (ADR-012). + +## ADDED Requirements + +### Requirement: REQ-FSS-001 Stackiq SHALL derive an organisation's landscape on any date from its usage dates + +The landscape on a date SHALL hold every usage of the organisation whose phase on that date, derived from its phase start dates, is In production or To be phased out. A usage whose planned replacement date is on or before that date SHALL leave the landscape, and its planned replacement module SHALL enter it as a planned successor with the same reference components. A usage with no phase date but a status of In production or To be phased out SHALL be in today's landscape and in every later one until a date or a scenario change removes it. A usage with neither SHALL be counted as not dated and SHALL be in neither landscape. Today's landscape SHALL be the same rule with today's date. + +#### Scenario: A planned replacement shows on its date +@e2e exclude A pure derivation; tests/Unit/Service/LandscapeAtDateDerivationTest.php asserts that a usage with plannedReplacementDate 2027-03-01 is in the landscape on 2027-02-28 and replaced by its successor on 2027-03-01. + +- **GIVEN** a usage in production with a planned replacement by another module on 2027-03-01 +- **WHEN** the landscape is derived for 2027-02-28 and for 2027-03-01 +- **THEN** the first SHALL hold the usage +- **AND** the second SHALL hold the successor module instead, with the usage's reference components + +#### Scenario: A usage with only a status counts, one with nothing is counted apart +@e2e exclude A pure derivation; tests/Unit/Service/LandscapeAtDateDerivationTest.php asserts that a usage with status In production and no dates is in both landscapes, and that a usage with neither a date nor a current status is in neither and raises the not dated count by one. + +- **GIVEN** a usage with status In production and no dates, and a usage with no phase date and the status in-gebruik +- **WHEN** today's landscape and a future landscape are derived +- **THEN** the first SHALL be in both landscapes +- **AND** the second SHALL be in neither, and the not dated count SHALL be one + +### Requirement: REQ-FSS-002 An information manager SHALL compare today's landscape with the plan on a date + +`GET /api/landscape-comparison?organisation=&date=` SHALL return the application differences between today and the date (added, removed, replaced by, unchanged, each with its date and the source plan), the reference components whose coverage state differs, and the not dated count. It SHALL refuse a user not authorised for the organisation before it reads anything. The page `LandscapeComparison` at `/landscape-comparison`, opened from a Compare with the plan button on the Portfolio roadmap, SHALL show both landscapes side by side with these differences in text. + +#### Scenario: An information manager sees what the plan changes by next summer +@e2e tests/e2e/workflows/future-state-scenarios.spec.ts + +- **GIVEN** a usage of the test organisation with a planned replacement on 2027-03-01, created by the test fixture +- **WHEN** a municipal information manager opens Portfolio roadmap, chooses Compare with the plan and picks 2027-06-30 +- **THEN** the comparison SHALL list the usage as replaced by its successor with source plan +- **AND** today's column SHALL still hold the usage + +#### Scenario: Another organisation's comparison is refused +@e2e exclude Needs a second organisation; tests/Unit/Controller/LandscapeComparisonControllerTest.php asserts a 403 before any service call through OrganisationReportAccess. + +- **GIVEN** a user of municipality A +- **WHEN** they call `GET /api/landscape-comparison` for municipality B +- **THEN** the response SHALL be 403 + +### Requirement: REQ-FSS-003 An information manager SHALL write a scenario of additions, phase-outs and replacements + +Stackiq SHALL offer a Scenarios page at `/scenarios` (page `Scenarios`) and a scenario page at `/scenarios/:id` (page `ScenarioDetail`) under the Architecture menu group. A scenario SHALL have a name, a description, an organisation, a target date and a status. A scenario change SHALL be one of add (a module with the reference components it will be used for), phase out (a usage) or replace (a usage by a module), with an optional effective date that defaults to the target date. The scenario page SHALL list its changes and SHALL show the comparison between today and the scenario landscape, where the scenario landscape is the plan on the target date with the changes applied, and every difference SHALL name its source, plan or scenario. + +#### Scenario: An information manager tries a replacement +@e2e tests/e2e/workflows/future-state-scenarios.spec.ts + +- **GIVEN** a usage in production of the test organisation and a second module, created by the test fixture +- **WHEN** a municipal information manager opens Architecture, then Scenarios, creates a scenario for the test organisation with a target date next year, and adds a change that replaces the usage by the second module +- **THEN** the scenario page SHALL list the change +- **AND** the comparison SHALL show the usage as replaced by the second module with source scenario + +#### Scenario: A scenario change wins over the plan +@e2e exclude A pure derivation; tests/Unit/Service/LandscapeAtDateDerivationTest.php asserts that a scenario phase out of a usage that the plan replaces reads as removed with both sources named. + +- **GIVEN** a usage the plan replaces on the target date and a scenario that phases it out +- **WHEN** the scenario landscape is derived +- **THEN** the usage SHALL be removed with the source scenario +- **AND** the difference SHALL also name the plan + +### Requirement: REQ-FSS-004 A scenario SHALL move through a declared lifecycle and an adopted scenario SHALL be applied to the landscape + +The `scenario` status SHALL move through transitions declared as `x-openregister-lifecycle`: propose (draft to proposed), adopt (proposed to adopted), reject (proposed to rejected) and rework (proposed or rejected to draft), with values that are members of the status enum. An adopted scenario without an applied date SHALL offer Apply to landscape, which SHALL write each change as the signed-in user: add creates a usage with status Planned and the effective date as its production start, phase out sets the usage's phased out date, and replace sets its planned replacement and date. Each applied change SHALL record the usage it wrote, and a retry SHALL skip it. The scenario SHALL record when it was applied. + +#### Scenario: An information manager applies an adopted scenario +@e2e tests/e2e/workflows/future-state-scenarios.spec.ts + +- **GIVEN** a proposed scenario of the test organisation, created by the test fixture, that adds a module on 2027-03-01 and phases out a usage on 2027-06-30 +- **WHEN** a municipal information manager applies Adopt and then Apply to landscape +- **THEN** a new usage of that module SHALL exist with status Planned and production start 2027-03-01 +- **AND** the phased out usage SHALL carry the phased out date 2027-06-30 +- **AND** Apply to landscape SHALL no longer be offered + +#### Scenario: A retried apply writes nothing twice +@e2e exclude A store rule; tests/vitest/scenarioApply.spec.js asserts that a change with appliedTo is skipped and that appliedAt is set only after the last write. + +- **GIVEN** an apply that failed after its first change was written +- **WHEN** the information manager applies again +- **THEN** the first change SHALL NOT be written again +- **AND** the scenario SHALL get its applied date once every change is written + +#### Scenario: The lifecycle matches the enum +@e2e exclude The transition engine is OpenRegister's; tests/Unit/Settings/ScenarioRegisterShapeTest.php asserts every lifecycle from and to value is a member of the status enum. + +- **GIVEN** the merged register +- **WHEN** the shape test reads the scenario lifecycle +- **THEN** every `from` and `to` value SHALL be a status enum value diff --git a/openspec/changes/architecture-future-state-scenarios/tasks.md b/openspec/changes/architecture-future-state-scenarios/tasks.md new file mode 100644 index 000000000..d3e82920f --- /dev/null +++ b/openspec/changes/architecture-future-state-scenarios/tasks.md @@ -0,0 +1,72 @@ +# Tasks: architecture-future-state-scenarios + +## Implementation tasks + +### Task 1: Landscape on a date +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-001-stackiq-shall-derive-an-organisations-landscape-on-any-date-from-its-usage-dates +- **files**: `lib/Service/LandscapeAtDateDerivation.php`, `tests/Unit/Service/LandscapeAtDateDerivationTest.php` +- **acceptance_criteria**: + - GIVEN usages with phase dates WHEN the landscape is derived for a date THEN it holds the usages in production or to be phased out on that date + - GIVEN a planned replacement on or before the date WHEN the landscape is derived THEN the successor module replaces the usage with its reference components + - GIVEN a usage with only a current status, and one with nothing WHEN both landscapes are derived THEN the first is in both and the second is counted as not dated + - GIVEN a scenario change and a plan replacement on one usage WHEN the scenario landscape is derived THEN the scenario wins and both sources are named +- [ ] Implement +- [ ] Test (PHPUnit `LandscapeAtDateDerivationTest`) + +### Task 2: Comparison service, controller and route +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-002-an-information-manager-shall-compare-todays-landscape-with-the-plan-on-a-date +- **files**: `lib/Service/LandscapeComparisonService.php`, `lib/Controller/LandscapeComparisonController.php`, `appinfo/routes.php`, `tests/Unit/Service/LandscapeComparisonServiceTest.php`, `tests/Unit/Controller/LandscapeComparisonControllerTest.php` +- **acceptance_criteria**: + - GIVEN a user of another organisation WHEN the endpoint is called THEN it answers 403 before the service runs + - GIVEN an organisation and a date WHEN the endpoint is called THEN it returns application and coverage differences in the shape of design D3 + - GIVEN a scenario id WHEN the endpoint is called THEN the scenario's organisation and target date are used and its changes are read with RBAC on +- [ ] Implement +- [ ] Test (PHPUnit `LandscapeComparisonServiceTest`, `LandscapeComparisonControllerTest`) + +### Task 3: Register fragment for scenarios +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-004-a-scenario-shall-move-through-a-declared-lifecycle-and-an-adopted-scenario-shall-be-applied-to-the-landscape +- **files**: `lib/Settings/register.d/architecture-future-state-scenarios.json`, `tests/Unit/Settings/ScenarioRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN the `stackiq` register lists `scenario` and `scenarioChange` with magic mapping on + - GIVEN the scenario lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a status enum value + - GIVEN a fresh install WHEN the seed runs THEN the demo scenario and its two changes exist +- [ ] Implement +- [ ] Test (PHPUnit `ScenarioRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 4: Comparison view, scenario pages and the roadmap button +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-003-an-information-manager-shall-write-a-scenario-of-additions-phase-outs-and-replacements +- **files**: `src/manifest.d/architecture-future-state-scenarios.json`, `src/menu-layout.json`, `src/views/architecture/LandscapeComparisonView.vue`, `src/views/LifecycleRoadmapView.vue`, `src/customComponents.js`, `tests/e2e/workflows/future-state-scenarios.spec.ts` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Scenarios`, `ScenarioDetail` and `LandscapeComparison` exist and Scenarios sits under the Architecture group + - GIVEN the Portfolio roadmap WHEN Compare with the plan is chosen THEN the comparison page opens for the roadmap's organisation + - GIVEN a comparison WHEN it renders THEN every difference carries its change and source as text +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `future-state-scenarios.spec.ts` plan and scenario scenarios) + +### Task 5: Apply an adopted scenario +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-004-a-scenario-shall-move-through-a-declared-lifecycle-and-an-adopted-scenario-shall-be-applied-to-the-landscape +- **files**: `src/store/modules/scenarioApply.js`, `src/views/architecture/LandscapeComparisonView.vue`, `tests/vitest/scenarioApply.spec.js` +- **acceptance_criteria**: + - GIVEN an adopted scenario without appliedAt WHEN Apply to landscape runs THEN add, phase out and replace write the fields of design D5 + - GIVEN a change with appliedTo WHEN the apply runs again THEN it is skipped + - GIVEN a failed write WHEN the apply stops THEN appliedAt stays empty and the notice names the change +- [ ] Implement +- [ ] Test (vitest `scenarioApply.spec.js`, Playwright apply scenario) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-future-state-scenarios/specs/future-state-scenarios/spec.md#requirement-req-fss-003-an-information-manager-shall-write-a-scenario-of-additions-phase-outs-and-replacements +- **files**: `docs/features/future-state-scenarios.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the plan comparison, a scenario page and the apply result, each in a screenshot + - GIVEN a Dutch instance WHEN the scenario pages render THEN every new label and enum value reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-future-state-scenarios --type change --strict` +- PHPUnit: `LandscapeAtDateDerivationTest`, `LandscapeComparisonServiceTest`, `LandscapeComparisonControllerTest`, `ScenarioRegisterShapeTest`, `RegisterFragmentMergeTest` +- vitest: `scenarioApply.spec.js` +- Playwright: `tests/e2e/workflows/future-state-scenarios.spec.ts` +- Documentation in `docs/features/future-state-scenarios.md` with screenshots (ADR-010) +- English and Dutch strings for every new label and enum value (ADR-005) diff --git a/openspec/changes/architecture-process-mapping/.openspec.yaml b/openspec/changes/architecture-process-mapping/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-process-mapping/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-process-mapping/design.md b/openspec/changes/architecture-process-mapping/design.md new file mode 100644 index 000000000..fcbc424d9 --- /dev/null +++ b/openspec/changes/architecture-process-mapping/design.md @@ -0,0 +1,135 @@ +# Design: architecture-process-mapping + +Read at development 49e65cb4. Line numbers below are from that sha. `GebruikDetail` comes from the open change `landscape-usage-registration` (its `design.md`, `src/manifest.d/usages.json`), and the Architecture menu group from `architecture-views-editor` (its design D8). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `stackiq` register (`lib/Settings/softwarecatalogus_register.json:817`), new schemas `process` and `processStep` | through a new fragment `lib/Settings/register.d/architecture-process-mapping.json` | +| Register, read only | `vng-gemma` `element` (:4130) for the reference process, `usage` (:2654) for the supporting applications | no change to either schema | +| Pages | new `src/manifest.d/architecture-process-mapping.json` with `Processen` (index), `ProcesDetail` (detail) and `ProcesStapDetail` (detail) | | +| Pages | `GebruikDetail` in `src/manifest.d/usages.json` (from `landscape-usage-registration`) gains an `object-list` widget | | +| Menu | `src/menu-layout.json` relocates `Processen` under the `Architecture` group | | +| Views | new `src/views/architecture/ProcessStepFlow.vue` and `src/views/architecture/ProcessStepUsages.vue`, registered in `src/customComponents.js` | | +| Service, controller, routes | none | the frontend writes through OpenRegister's objects API (config rule "Uses OpenRegister API directly from frontend") | + +The fragment merges through `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`, `deepMergeConfig` at :7338). It carries `components.schemas.process`, `components.schemas.processStep`, `components.registers.stackiq.schemas: ["process", "processStep"]` and `components.registers.stackiq.configuration.schemas.: {"magicMapping": true, "autoCreateTable": true}` for both. + +## Decisions + +### D1. A process is organisation data in the `stackiq` register + +`process` and `processStep` live in the `stackiq` register next to `usage`, with the same authorization shape `usage` has (register.json, `usage.authorization`): create and update for the catalogue groups, read for `gebruik-beheerder` and `aanbod-beheerder` matched on `_organisation`. Processes are the municipality's own, like its usages, so OpenRegister multitenancy scopes them to the organisation that made them. + +`process` carries `configuration.jsonld.type` `https://schema.org/HowTo` and `processStep` carries `https://schema.org/HowToStep`, in the way `module` carries `SoftwareApplication`. + +Rejected: a process as an AMEF `element` of type `BusinessProcess` in the `vng-gemma` register, with steps as child elements and Composition relations. The AMEF register holds VNG's model, `element` has 86 properties built for GEMMA content, and a municipality's process would need the `origin` guards of `architecture-views-editor` D9 on every reader. The supporting applications are `usage` objects in the `stackiq` register, and a relation across the two registers is what the organisation data already does through `usedForReferenceComponents`. + +### D2. The schemas + +`process`: + +| Field | Type | Notes | +|---|---|---| +| name | string, required | | +| description | string | | +| processOwner | related `contactPerson` | picked from the organisation's contact roles, as `landscape-usage-registration` does for owners | +| status | enum draft, active, retired, default draft, facetable | lifecycle in the Declarative section | +| referenceProcess | related `element` in `vng-gemma`, `objectConfiguration.queryParams` `type=BusinessProcess` | the GEMMA reference process this one follows | +| tags | list of strings, facetable | | + +`processStep`: + +| Field | Type | Notes | +|---|---|---| +| process | related `process`, required | | +| name | string, required | | +| description | string | | +| stepType | enum task, event, decision, default task | | +| position | integer, required | the order within the process | +| follows | list of `processStep` uuids | empty means "the step before it by position" | +| usages | list of related `usage` | the applications in use that support the step | +| riskLevel | enum not assessed, low, medium, high, default not assessed, facetable | | +| riskNote | string | | +| complianceCheck | enum not checked, compliant, not compliant, not applicable, default not checked, facetable | | +| complianceNote | string | | +| checkedOn | date | when the compliance check was last done | + +The `referenceProcess` uuid is stable across GEMMA imports, because the import sets an element's uuid to its GEMMA object id (`lib/Service/ArchiMateImportService.php:4794-4798`). + +### D3. Pages under the Architecture group + +`Processen` (`/processen`) is a `CnIndexPage` (manifest `type: index`) over `process` with columns name, status, processOwner and tags, the schema facets in the sidebar, and quick filters All, Active and Draft. + +`ProcesDetail` (`/processen/:id`) is a `type: detail` page on the ADR-062 grid with: +- a `data` widget for name, description, status, owner, reference process and tags, +- an `object-list` widget `process-steps` over `processStep` with filter `{"process": "@objectId"}`, columns position, name, stepType, riskLevel and complianceCheck, sorted on position, `rowRoute: ProcesStapDetail`, +- a body widget `ProcessStepFlow` (see D4), +- `lifecycleActions` on, and the History tab in the sidebar. + +`ProcesStapDetail` (`/processtappen/:id`) is a `type: detail` page with a `data` widget over every step field and a body widget `ProcessStepUsages` that lists the step's usages. It is a small custom component rather than an `object-list`, because it compares the stored uuids with the returned objects to count the hidden ones (see Risks). + +The menu entry `Processen` is relocated under the `Architecture` group that `architecture-views-editor` adds (its D8), so the top-level count does not grow (ADR-097). + +### D4. The step flow is read-only on `CnGraphCanvas` + +`ProcessStepFlow.vue` loads the steps of one process, turns each into a node (name, step type, risk level, and the names of the supporting applications) and draws an edge from each uuid in `follows` to the step, or from the step with the next lower position when `follows` is empty. It passes them to `CnGraphCanvas` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnGraphCanvas/CnGraphCanvas.vue`, props `nodes`, `edges` and `readOnly` at :178, :184 and :196) with `readOnly` true, and places them with `layoutFlowNodes` (`src/composables/flowGraphLayout.js:329`), imported through the package's `./src/*` export as `architecture-assistant-drafted-views` D4 does. A step with a high risk or a failed compliance check gets `--color-error` on its border, and the text on the node says the same, so colour is never the only signal (WCAG 1.4.1). + +Rejected: an editable BPMN canvas. The rows ask to model processes and link applications, and a list with an order and a `follows` list does that. An editor is a second drawing tool next to the view editor, and it can come later on the same data. + +Rejected: OpenRegister flows. A flow (`openregister-ro/appinfo/routes.php:825`, BPMN export) is an automation that runs; a business process here is a description of how the municipality works, with owners, risks and applications. Storing descriptions as flows would put non-runnable flows in the flow engine's list. + +### D5. Fixed step fields, not customer-defined questionnaires + +The step fields are properties of `processStep`: risk level, risk note, compliance check, compliance note and checked on. `CnDetailPage` and `CnFormDialog` render them from the schema, and the facets filter on them. + +Rejected: questions a functional administrator defines per step type, answered per step, as BlueDolphin offers. That needs a question-definition schema and a form renderer for answers of any type, which is a second form system beside the schema-driven one (ADR-012). Letting an administrator add properties to `processStep` in OpenRegister's schema editor was also rejected: the repair step re-imports the register JSON and a version bump replaces the properties, so an added question would vanish on an update. The matrix row names risk level and a compliance check, which the fixed fields cover. + +### D6. The application in use lists the steps it supports + +`GebruikDetail` gets an `object-list` widget `usage-process-steps` over `processStep` with filter `{"usages": "@objectId"}`, columns process, name, riskLevel and complianceCheck, `rowRoute: ProcesStapDetail`, titled "Process steps this application supports". OpenRegister filters an array property on one value with a JSON containment test (`openregister-ro/lib/Db/MagicMapper/MagicSearchHandler.php:1601`), so no index or service is needed. + +## Declarative versus imperative + +- The process status lifecycle is declared as `configuration.x-openregister-lifecycle` on `process`: field `status`, initial draft, transitions activate (draft to active), retire (active to retired) and reopen (retired to draft). The `from` and `to` values are the enum values exactly (register changelog 2.4.4, register.json:7). `lifecycleActions` on `ProcesDetail` renders them. +- The step to process and step to usage links are `related-object` properties, so OpenRegister keeps them in its relation index. No PHP. +- The two lists are manifest `object-list` widgets with filters. No aggregation or notification is added. +- `ProcessStepFlow.vue` is the only imperative piece, and it only reads. + +## Seed data + +All objects live in the `stackiq` register. The reference process is the GEMMA element "Bedrijfsproces Behandelen vergunningaanvraag" (`lib/Settings/GEMMA_release.xml:950`), uuid `01f2e505-8245-44e5-860c-336a831eaae9`. On an instance without a GEMMA import the reference stays empty. + +### Schema: `process` + +| Field | Object 1 | +|---|---| +| slug | `seed-proces-vergunningaanvraag` | +| name | Behandelen vergunningaanvraag | +| description | Van intake tot besluit op een aanvraag voor een vergunning. | +| status | active | +| referenceProcess | `01f2e505-8245-44e5-860c-336a831eaae9` | +| tags | vergunningen | + +### Schema: `processStep` + +| Field | Object 1 | Object 2 | Object 3 | +|---|---|---|---| +| slug | `seed-stap-intake` | `seed-stap-toetsen` | `seed-stap-besluiten` | +| process | `seed-proces-vergunningaanvraag` | `seed-proces-vergunningaanvraag` | `seed-proces-vergunningaanvraag` | +| name | Intake vergunningaanvraag | Toetsen indieningsvereisten | Besluiten vergunningaanvraag | +| stepType | event | task | decision | +| position | 1 | 2 | 3 | +| usages | `gebruik-topdesk-gem-leiden-deelnemers` | none | none | +| riskLevel | low | medium | high | +| complianceCheck | compliant | not checked | not compliant | +| complianceNote | | | Besluit wordt nog niet gearchiveerd volgens de selectielijst. | + +The usage slug is one of the three usages the register already seeds (register.json `components.objects`). + +## Risks + +- **An array filter on MariaDB.** The containment test at `MagicSearchHandler.php:1601` is PostgreSQL SQL. The Playwright scenario for D6 runs on the CI database, and a MariaDB instance needs a check before this ships there. +- **Hidden usages.** RBAC can hide a linked usage from a reader. `ProcesStapDetail` compares the stored uuids with the returned objects and shows "1 linked application is not visible to you" instead of a silent gap. +- **Steps out of order.** Two steps with the same position draw side by side. The index sorts on position and then name, and the form warns on a duplicate position without refusing it. diff --git a/openspec/changes/architecture-process-mapping/proposal.md b/openspec/changes/architecture-process-mapping/proposal.md new file mode 100644 index 000000000..d9e56582c --- /dev/null +++ b/openspec/changes/architecture-process-mapping/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +depends_on: + - architecture-views-editor + - landscape-usage-registration +--- + +# Model business processes and link their steps to the applications that support them + +## Summary + +A municipal information manager records the organisation's business processes in stackiq, step by step, and names for each step the applications in use that support it. Each step carries a risk level and a compliance check. The process page shows the steps as a flow, and the page of an application in use lists the process steps it supports. A process can point at the GEMMA reference process it follows. + +## Why + +This change builds two rows of the stackiq parity matrix. + +- `stackiq:arch-process-mapping`, "Model business processes and link them to the applications that support them." No tender or feature request names it. SAP LeanIX rates yes: "business context subtype 'Process : Processes show the different steps and interactions', related to applications" (https://help.sap.com/docs/leanix/ea/business-context-modeling-guidelines). BlueDolphin rates yes: "The Create BPMN diagram button allows you to create a diagram directly from a business process" (https://help.bluedolphin.io/en/articles/11967673-process-linked-to-ea-perspectives) and "For each application, you will find different processes in which the selected application is involved" (https://help.bluedolphin.io/en/articles/11967500-getting-started-with-process-publication-portal). The lane decided build because two competitors rate yes and architecture is a core area. +- `stackiq:arch-process-step-fields`, "Record structured fields on individual process steps, such as risk level or a compliance check." The demand is a changelog entry, https://bluedolphin.io/blog/july-2026-bluedolphin-updates/. BlueDolphin rates yes: customers can "define questionnaires directly on BPMN elements such as tasks and events" to "centralize documentation like risk levels, compliance checks, and technical specifications" (https://help.bluedolphin.io/en/articles/15874771-questionnaires-for-bpmn-elements). Decided build: core area. + +The matrix notes for both rows hold: stackiq models no processes. + +## What stackiq has today + +- The register holds 20 schemas (`lib/Settings/softwarecatalogus_register.json`, `components.schemas`) and none is a process. The `stackiq` register (:817) lists 15 of them, the `vng-gemma` register (:916) the five AMEF schemas. +- The ArchiMate import keeps every element type. It copies `xsi:type` into `type` without a filter (`lib/Service/ArchiMateImportService.php:973-980` and :5244-5249), and it sets an element's uuid to its GEMMA object id (:4794-4798), so the uuid survives a re-import. The GEMMA release in `lib/Settings/GEMMA_release.xml` holds 157 `BusinessProcess` elements, such as "Bedrijfsproces Behandelen vergunningaanvraag" (:950). These are VNG's reference processes. No page shows them: the only AMEF page, Standaarden (`src/manifest.json:701`), filters on `gemmaType` standaard. +- The organisation's applications are `usage` objects (register.json:2654). A usage links to reference components (`usedForReferenceComponents`) and, through `GebruikSyncService`, to AMEF element ids in `amefElements` (`lib/Service/GebruikSyncService.php:170-272`), never to a process. +- OpenRegister's flows (`src/manifest.json:1057`, page Flows) are automation flows with a BPMN export (`openregister-ro/appinfo/routes.php:825`). They run work; they do not describe how the municipality works. + +## What this change builds + +- Two schemas in the `stackiq` register through a fragment `lib/Settings/register.d/architecture-process-mapping.json`: `process` and `processStep`, with the step fields risk level, risk note, compliance check, compliance note and checked on. +- A Processes index page and a process detail page with a steps list and a read-only step flow on `CnGraphCanvas`, and a step detail page, under the Architecture menu group. +- A list "Process steps this application supports" on the usage detail page `GebruikDetail`. +- A link from a process to the GEMMA reference process it follows. + +## Out of scope + +- Drawing processes freehand in BPMN. The step flow is read-only and follows the step order. A BPMN editor can follow once the process data is in use. +- Customer-defined questionnaires on steps. This change ships a fixed set of step fields. See design D5 for why. +- Putting processes into the ArchiMate export. The organisation export (`lib/Service/ArchiMateExportService.php:2734`) draws applications into GEMMA views, and adding processes there is a later change. +- Drafting a process with an assistant. `architecture-assistant-drafted-views` drafts views only. +- Automation. Running a process is OpenRegister's flow engine (ADR-065), not this change. + +## Risks + +- A step lists usages across the whole organisation. A usage the reader may not see is left out of the list by OpenRegister RBAC, which can make a step look unsupported. The step detail says how many linked applications are hidden. +- GEMMA reference processes exist only after a GEMMA import. On an instance without one, the reference field offers nothing to pick, which is correct but can look broken. The field's help text says so. diff --git a/openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md b/openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md new file mode 100644 index 000000000..4c4bc588e --- /dev/null +++ b/openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md @@ -0,0 +1,116 @@ +# business-process-mapping specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-process-mapping + +## Purpose + +A municipality describes its business processes in stackiq, step by step, and links each step to the applications in use that support it. Steps carry a risk level and a compliance check. A process (schema.org `HowTo`) and its steps (schema.org `HowToStep`) are objects in the `stackiq` register (ADR-001), shown with `CnIndexPage`, `CnDetailPage` and a read-only `CnGraphCanvas` (ADR-012), with the status lifecycle declared on the schema (ADR-031). + +## ADDED Requirements + +### Requirement: REQ-BPM-001 A municipal information manager SHALL record a business process with ordered steps + +Stackiq SHALL offer a Processes page at `/processen` (page `Processen`) over the `process` schema and a process page at `/processen/:id` (page `ProcesDetail`). A process SHALL have a name, a description, an owner picked from the organisation's contact roles, a status, tags and an optional GEMMA reference process. A step SHALL be a `processStep` object with the process, a name, a description, a step type (task, event or decision), a position and an optional list of steps it follows. The process page SHALL list its steps in position order. Processes and steps SHALL be scoped to the organisation that created them. + +#### Scenario: An information manager adds a process with three steps +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** a municipal information manager signed in to stackiq +- **WHEN** they open Architecture, then Processes, create the process Behandelen melding and add the steps Registreren, Beoordelen and Afhandelen at positions 1, 2 and 3 +- **THEN** the process page SHALL list the three steps in that order +- **AND** the Processes page SHALL list the process with status draft + +#### Scenario: Another municipality does not see the process +@e2e exclude The CI instance has one organisation; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts that the read rules of process and processStep match on _organisation, as usage does. + +- **GIVEN** a process of municipality A +- **WHEN** a user of municipality B opens the Processes page +- **THEN** the process SHALL NOT be listed + +### Requirement: REQ-BPM-002 A step SHALL name the applications in use that support it + +A step SHALL hold a list of `usage` objects: the organisation's applications in use that support the step. The usage page `GebruikDetail` SHALL show a list "Process steps this application supports" with every step that names the usage, its process, its risk level and its compliance check, each row opening the step page at `/processtappen/:id` (page `ProcesStapDetail`). When a linked usage is not visible to the reader, the step page SHALL say how many linked applications are hidden. + +#### Scenario: An application owner sees which process steps their application supports +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** the seeded step Intake vergunningaanvraag linked to the seeded TOPdesk usage +- **WHEN** an application owner opens that usage at `/gebruik/:id` +- **THEN** the list "Process steps this application supports" SHALL show Intake vergunningaanvraag with the process Behandelen vergunningaanvraag +- **AND** choosing the row SHALL open the step page + +#### Scenario: A hidden usage is counted, not dropped +@e2e exclude Needs two organisations with different rights; tests/vitest/processStepUsages.spec.js asserts that two stored usage uuids with one returned object give the notice "1 linked application is not visible to you". + +- **GIVEN** a step linked to two usages, one of which the reader may not read +- **WHEN** the reader opens the step page +- **THEN** the page SHALL list the visible usage +- **AND** it SHALL show that one linked application is not visible to them + +### Requirement: REQ-BPM-003 A step SHALL carry a risk level and a compliance check + +Every `processStep` SHALL have a risk level (not assessed, low, medium or high, default not assessed), a risk note, a compliance check (not checked, compliant, not compliant or not applicable, default not checked), a compliance note and the date of the last check. The risk level and the compliance check SHALL be facetable, and the step list on the process page SHALL show both. + +#### Scenario: An information manager marks a step as not compliant +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** the step Besluiten vergunningaanvraag with compliance check not checked +- **WHEN** a municipal information manager edits the step, sets the risk level to high, the compliance check to not compliant and a compliance note, and saves +- **THEN** the step list on the process page SHALL show high and not compliant for that step +- **AND** the step page SHALL show the compliance note + +#### Scenario: The step fields have defaults +@e2e exclude A schema default; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts the enums and defaults of riskLevel and complianceCheck. + +- **GIVEN** the merged register +- **WHEN** a step is created without a risk level or a compliance check +- **THEN** it SHALL read not assessed and not checked + +### Requirement: REQ-BPM-004 The process page SHALL show the steps as a read-only flow + +The process page SHALL render the steps on a read-only `CnGraphCanvas`: one node per step with its name, type, risk level and supporting applications, and an edge from each step it follows, or from the step before it by position when it follows none. A step with a high risk or a not compliant check SHALL be marked in the error colour and in text. + +#### Scenario: A reader sees the flow of a process +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** the seeded process Behandelen vergunningaanvraag with three steps +- **WHEN** a municipal information manager opens its process page +- **THEN** the flow SHALL show three nodes connected in position order +- **AND** the node Besluiten vergunningaanvraag SHALL read high risk and not compliant + +#### Scenario: A branch follows the follows list +@e2e exclude A pure mapping; tests/vitest/processStepFlow.spec.js asserts that a step whose follows list names two steps gets two incoming edges and no edge from the step before it by position. + +- **GIVEN** a step that follows two earlier steps +- **WHEN** the flow is built +- **THEN** the step SHALL have one incoming edge from each of the two +- **AND** no edge from the step before it by position + +### Requirement: REQ-BPM-005 A process SHALL move through a declared lifecycle and MAY follow a GEMMA reference process + +The `process` status SHALL move through transitions declared as `x-openregister-lifecycle` on the schema: activate (draft to active), retire (active to retired) and reopen (retired to draft), with `from` and `to` values that are members of the status enum. A process MAY reference one AMEF `element` of type `BusinessProcess` as its GEMMA reference process, and the process page SHALL show that reference with a link to it. + +#### Scenario: An owner activates a process +@e2e tests/e2e/workflows/process-mapping.spec.ts + +- **GIVEN** a process in status draft +- **WHEN** its owner applies Activate on the process page +- **THEN** the process SHALL read active +- **AND** the transition SHALL appear in its History tab + +#### Scenario: The lifecycle matches the enum +@e2e exclude The transition engine is OpenRegister's; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts every lifecycle from and to value is a member of the status enum. + +- **GIVEN** the merged register +- **WHEN** the shape test reads the process lifecycle +- **THEN** every `from` and `to` value SHALL be a status enum value + +#### Scenario: A process points at its GEMMA reference process +@e2e exclude The CI instance runs without a GEMMA import; tests/Unit/Settings/ProcessMappingRegisterShapeTest.php asserts that referenceProcess is a related element filtered on type BusinessProcess. + +- **GIVEN** an imported GEMMA model with the process Bedrijfsproces Behandelen vergunningaanvraag +- **WHEN** an information manager picks it as the reference process of their process +- **THEN** the process page SHALL show the reference by name with a link to its element page diff --git a/openspec/changes/architecture-process-mapping/tasks.md b/openspec/changes/architecture-process-mapping/tasks.md new file mode 100644 index 000000000..0e8984fa4 --- /dev/null +++ b/openspec/changes/architecture-process-mapping/tasks.md @@ -0,0 +1,62 @@ +# Tasks: architecture-process-mapping + +## Implementation tasks + +### Task 1: Register fragment for process and processStep +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-003-a-step-shall-carry-a-risk-level-and-a-compliance-check +- **files**: `lib/Settings/register.d/architecture-process-mapping.json`, `tests/Unit/Settings/ProcessMappingRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN the `stackiq` register lists `process` and `processStep` with magic mapping on + - GIVEN `processStep` WHEN the shape test reads it THEN `riskLevel` and `complianceCheck` have the enums and defaults of the design and are facetable + - GIVEN the process lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a status enum value + - GIVEN both schemas WHEN the shape test reads their read rules THEN they match on `_organisation` as `usage` does + - GIVEN a fresh install WHEN the seed runs THEN the process and its three steps exist +- [ ] Implement +- [ ] Test (PHPUnit `ProcessMappingRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Processes index, process page and step page +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-001-a-municipal-information-manager-shall-record-a-business-process-with-ordered-steps +- **files**: `src/manifest.d/architecture-process-mapping.json`, `src/menu-layout.json` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Processen`, `ProcesDetail` and `ProcesStapDetail` exist with the routes of the design + - GIVEN the effective menu WHEN it renders THEN Processes sits under the Architecture group and the top-level count is unchanged + - GIVEN a process page WHEN it renders THEN its steps list is sorted on position and its lifecycle actions show +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `tests/e2e/workflows/process-mapping.spec.ts` create and lifecycle scenarios) + +### Task 3: Step flow on CnGraphCanvas +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-004-the-process-page-shall-show-the-steps-as-a-read-only-flow +- **files**: `src/views/architecture/ProcessStepFlow.vue`, `src/utils/processStepFlow.js`, `src/customComponents.js`, `tests/vitest/processStepFlow.spec.js` +- **acceptance_criteria**: + - GIVEN steps without a follows list WHEN the flow is built THEN edges run in position order + - GIVEN a step that follows two steps WHEN the flow is built THEN it has two incoming edges + - GIVEN a step with high risk or not compliant WHEN it renders THEN its node uses `--color-error` and says so in text +- [ ] Implement +- [ ] Test (vitest `processStepFlow.spec.js`, Playwright flow scenario) + +### Task 4: Supporting applications on the usage and step pages +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-002-a-step-shall-name-the-applications-in-use-that-support-it +- **files**: `src/manifest.d/usages.json`, `src/manifest.d/architecture-process-mapping.json`, `src/views/architecture/ProcessStepUsages.vue`, `tests/vitest/processStepUsages.spec.js` +- **acceptance_criteria**: + - GIVEN a step that names a usage WHEN that usage's page opens THEN the list "Process steps this application supports" shows the step and its process + - GIVEN two stored usage uuids of which one is returned WHEN the step page renders THEN it shows the visible usage and says one is hidden +- [ ] Implement +- [ ] Test (vitest `processStepUsages.spec.js`, Playwright usage page scenario) + +### Task 5: Documentation and translations +- **spec_ref**: openspec/changes/architecture-process-mapping/specs/business-process-mapping/spec.md#requirement-req-bpm-001-a-municipal-information-manager-shall-record-a-business-process-with-ordered-steps +- **files**: `docs/features/business-processes.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the process page with its flow and the usage page with its step list, each in a screenshot + - GIVEN a Dutch instance WHEN the Processes page renders THEN every new label and enum value reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-process-mapping --type change --strict` +- PHPUnit: `ProcessMappingRegisterShapeTest`, `RegisterFragmentMergeTest` +- vitest: `processStepFlow.spec.js`, `processStepUsages.spec.js` +- Playwright: `tests/e2e/workflows/process-mapping.spec.ts` +- Documentation in `docs/features/business-processes.md` with screenshots (ADR-010) +- English and Dutch strings for every new label and enum value (ADR-005) diff --git a/openspec/changes/architecture-reference-component-coverage/.openspec.yaml b/openspec/changes/architecture-reference-component-coverage/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-reference-component-coverage/design.md b/openspec/changes/architecture-reference-component-coverage/design.md new file mode 100644 index 000000000..057a7e4cf --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/design.md @@ -0,0 +1,74 @@ +# Design: architecture-reference-component-coverage + +Read at development 49e65cb4. Line numbers below are from that sha. The read-only rendering of a GEMMA view (`src/utils/viewGraph.js` on `CnGraphCanvas`) comes from `architecture-views-editor` (its D3 and D4). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Derivation | new `lib/Service/ReferenceComponentCoverageDerivation.php` | pure, like `lib/Service/PortfolioReportDerivation.php` | +| Service | new `lib/Service/ReferenceComponentCoverageService.php` | reads usages the way `PortfolioReportService::buildRows` does (:229) | +| Service | `lib/Service/PortfolioReportService.php` `buildRow` (:284), `buildReport` (:138), `buildCsv` (:166) | overlap per row, in the summary and the CSV | +| Access check | new `lib/Service/OrganisationReportAccess.php`, taken from `PortfolioReportController::isAuthorisedForOrganisation` (`lib/Controller/PortfolioReportController.php:139`) | both report controllers call it | +| Controller and route | new `lib/Controller/ReferenceComponentCoverageController.php`, route `referenceComponentCoverage#index` at `GET /api/reference-component-coverage` next to `portfolioReport#index` (`appinfo/routes.php:303`) | | +| Pages | new `src/manifest.d/reference-component-coverage.json` with the custom page `ReferenceComponentCoverage` (`/reference-component-coverage`) and a second card on `Reports` (`src/manifest.json:1027`) | | +| Views | new `src/views/organisaties/ReferenceComponentCoverage.vue` and `src/views/organisaties/CoverageViewMap.vue`; `src/views/organisaties/PortfolioReport.vue` gains an overlap section | registered in `src/customComponents.js` next to `PortfolioReportView` (:32, :137) | +| Register | none | | + +## Decisions + +### D1. Coverage is counted on the organisation's usages + +A reference component is covered by a usage of the organisation when the usage names it in `usedForReferenceComponents` (`lib/Settings/softwarecatalogus_register.json:2982`) and its `status` is not Acquisition, Planned or Phased out. The derivation gives each reference component one state: + +| State | Rule | +|---|---| +| gap | no counting usage | +| covered | one counting usage | +| overlap | two or more counting usages with different modules | + +Two usages of the same module (two versions side by side during a migration) are one application for this count. A gap is also marked fillable when a module the organisation already uses declares the component in `module.referenceComponents` (:6992). That is the GEMMA Softwarecatalogus tile "Pakketten met meer mogelijkheden". + +Rejected: counting on `module.referenceComponents`. That says what a product can do, not what the municipality uses it for, and it would mark a component covered by a product the municipality bought for something else. + +### D2. A bounded, organisation-scoped backend endpoint + +`ReferenceComponentCoverageService::build(organisationUuid)` reads the organisation's usages with the same query `PortfolioReportService::buildRows` uses (`consumer` equal to the organisation, bounded by the page size ceiling, :544) and the reference components with `gemmaType=referentiecomponent`, the query the schemas use for their pickers (register.json:2993), bounded at 1,000 as `FacetService::ELEMENT_LOOKUP_LIMIT` is (`lib/Service/FacetService.php:95`). It resolves module names once per module, like `PortfolioReportService::fetchRelation` (:482), and hands everything to the derivation. + +`GET /api/reference-component-coverage?organisation=&format=json|csv` returns `{ organisation, generatedAt, truncated, usagesWithoutComponent, summary: { gap, fillable, covered, overlap }, components: [{ uuid, name, state, fillable, usages: [{ uuid, moduleName, status }] }] }`, or the same rows as CSV. + +The controller runs the organisation check before any query and fails closed, as `PortfolioReportController::index` does (:86). The check moves into `OrganisationReportAccess::isAuthorised(user, organisationUuid)` and both controllers call it, so the two reports keep one rule. + +Rejected: computing coverage in the browser from OpenRegister facets on `usage.usedForReferenceComponents`. A facet has no bucket for a value no row holds, so gaps need the full component list anyway, and overlap needs the usages behind each count. The portfolio report moved the same kind of cross-register join to the backend for the same reason (its manifest note, `src/manifest.json:1040`). + +### D3. The page: summary, table and map + +`ReferenceComponentCoverage.vue` follows `PortfolioReport.vue`: the same organisation picker (:61), Refresh and Export CSV buttons, and a truncation notice. Under that: + +- a summary with the four counts and "N applications in use name no reference component", +- a `CnDataTable` of components with columns name, state, applications and fillable, and quick filters All, Gaps, Fillable gaps and Overlap, +- `CoverageViewMap.vue`: a picker of imported GEMMA views from `GET /api/views` (`appinfo/routes.php:185`), and the chosen view drawn read-only through `viewGraph.js` on `CnGraphCanvas` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnGraphCanvas/CnGraphCanvas.vue`, `readOnly` at :196). Every node whose element is a reference component gets its state: a border in `--color-error` (gap), `--color-success` (covered) or `--color-warning` (overlap), and a text badge with the count and the application names, so colour is never the only signal. + +The page is reached from a second card on the Reports page, "Reference component coverage", next to Portfolio rationalization. No menu entry is added (ADR-097). + +Rejected: drawing the map with `ViewService` enrichment (`include_gebruik`). It groups usages by the single field `elementRef` (`lib/Service/ViewService.php:914`), so a usage for three components lands on at most one node. + +### D4. Overlap in the portfolio report + +`PortfolioReportService::buildRow` adds `referenceComponents` (the names the usage names) and `overlapsWith`: for each shared component, the other modules that cover it. The derivation computes this from the rows `buildRows` already fetched, so the report makes one extra bounded read, the component names. `buildReport` adds `overlap` (the number of rows with at least one overlap) to its payload, and `buildCsv` adds the column `overlapsWith` as `component: module | module`. `PortfolioReport.vue` shows an Overlap section under the quadrant summary that lists each overlapping component with its applications, and marks overlapping rows in the row list. The card text "Overlapping and ageing software across the portfolio." (`src/manifest.json:1031`) then describes what the report does. + +## Declarative versus imperative + +This change adds aggregation, so ADR-031's declarative route was checked first. + +- OpenRegister facets on `usage.usedForReferenceComponents` count usages per component that has one, but return no bucket for a component nobody uses, and gaps are exactly those. +- OpenRegister's aggregation primitive omits empty buckets by contract ("buckets with zero rows SHALL be omitted from the response", `openregister-ro/openspec/specs/aggregation-api/spec.md:32`) and groups one collection, while coverage joins usages in `stackiq` with reference components in `vng-gemma`. + +So the join is imperative, in a pure derivation class with its own unit tests, behind one bounded endpoint. No lifecycle, notification or relation is added, and no schema changes. + +## Risks + +- **Seeded status values.** The three seeded usages hold `status` `in-gebruik` (register.json `components.objects`), which is not an enum value. The derivation counts any status other than Acquisition, Planned and Phased out, so an unknown value counts as in use rather than hiding an application. +- **No GEMMA import.** Without an imported model there are no reference components. The page then shows "Import the GEMMA model to see coverage" instead of an empty table, and the portfolio report shows no Overlap section. +- **`elementRef` enrichment.** `ViewService` keeps grouping on `elementRef`. A later change can move it to `usedForReferenceComponents`; this change does not depend on it. +- **Large organisations.** A usage ceiling that truncates also truncates coverage. The page shows the truncation notice the portfolio report shows. diff --git a/openspec/changes/architecture-reference-component-coverage/proposal.md b/openspec/changes/architecture-reference-component-coverage/proposal.md new file mode 100644 index 000000000..18e2a1fd6 --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: + - architecture-views-editor +--- + +# Show which reference components your landscape covers, misses and doubles + +## Summary + +A municipal information manager opens a coverage report for their organisation. It lists every GEMMA reference component with the applications in use that fulfil it, and marks each one as a gap (no application), covered (one) or overlap (two or more). The same result is drawn on a GEMMA view of their choice, as a map. The portfolio rationalization report gains the overlap it promises on its card, so a reader sees overlapping and ageing software in one place. + +## Why + +This change builds four rows of the stackiq parity matrix. No tender or feature request names them. + +- `stackiq:arch-capability-map`, "Map applications to business capabilities or functions and see the map." Rated partial, built. SAP LeanIX rates yes: "A business capability is supported by an application" and a "Business capability map" (https://help.sap.com/docs/leanix/ea/meta-model, https://help.sap.com/docs/leanix/ea/application-portfolio-assessment). BlueDolphin rates yes: "Drag and drop multi-layer current and future state capability mapping" (https://bluedolphin.io/capability-based-planning/). GEMMA Softwarecatalogus rates partial: packages are plotted "op een GEMMA architectuurkaart" (https://www.softwarecatalogus.nl/Hoe%20print%20ik%20een%20kaart%3F). The lane decided build: the missing half is a map view of applications on reference components inside stackiq. +- `stackiq:arch-gap-analysis`, "Find reference components that no application in your landscape covers." Rated no. GEMMA Softwarecatalogus rates partial with the tile "Pakketten met meer mogelijkheden" (https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1), SAP LeanIX partial with a Matrix Report for "Coverage gap analysis" (https://help.sap.com/docs/leanix/ea/report-types), BlueDolphin partial: "Identify capability gaps" (https://bluedolphin.io/capability-based-planning/). Decided build: core area. +- `stackiq:life-overlap`, "Find applications that overlap because they fulfil the same reference component." Rated no. GEMMA Softwarecatalogus rates yes: "Deze tegel signaleert dat er meer dan 1 pakket(versie) bij eenzelfde referentiecomponent in productie is" (https://www.softwarecatalogus.nl/Releasebrief%20GEMMA%20Softwarecatalogus%20versie%204.1). BlueDolphin rates yes: "overlapping application functions are quickly made visible" (https://help.bluedolphin.io/en/articles/11967472-welcome-to-bluedolphin). Decided build: two competitors rate yes. +- `stackiq:life-rationalisation-report`, "Open a report of overlapping and ageing software for rationalisation." Rated partial, built. SAP LeanIX rates yes: "automated TIME classification, application portfolio and landscape reports ... to streamline application rationalization" (https://help.sap.com/docs/leanix/ea/application-rationalization-evaluate-data). This row rides with `stackiq:life-overlap`: its missing half is overlap in the portfolio report, which is the overlap this change computes. + +## What stackiq has today + +- The mapping exists in the data. `module.referenceComponents` (`lib/Settings/softwarecatalogus_register.json:6992`, module schema at :6777) says which reference components a product implements, and `usage.usedForReferenceComponents` (:2982) which ones the organisation uses it for. Both relate to `element` objects with `gemmaType=referentiecomponent`. The GEMMA release holds 168 reference components (`lib/Settings/GEMMA_release.xml`, elements of type `ApplicationComponent` with GEMMA type Referentiecomponent). +- `lib/Service/FacetService.php` counts modules per reference component across the catalogue (`DIMENSIONS`, :109, built in `buildDimensionValueMap`, :648). It never lists a component with no module, and it works on `module`, not on one organisation's usages. +- The view API enriches a view's nodes with the organisation's usages (`lib/Service/ViewService.php:813`, `getGebruikData`), but it groups usages by the single field `elementRef` (:914), not by `usedForReferenceComponents`, so a usage for three components lands on one node or none. No page renders a view (`architecture-views-editor`, What stackiq has today). +- The organisation export draws applications into copies of GEMMA views (`lib/Service/ArchiMateExportService.php:2734`, `copyAndEnrichViews`), so the map exists only in Archi after an admin export. +- The portfolio report (`GET /api/portfolio-report`, `appinfo/routes.php:303`) is built by `lib/Service/PortfolioReportService.php` from the organisation's usages (`buildRows`, :229) with TIME quadrants, EOL exposure, cloud share and cost, and a CSV (`buildCsv`, :166). No row carries a reference component. The Reports card still reads "Overlapping and ageing software across the portfolio." (`src/manifest.json:1031`). + +## What this change builds + +- `lib/Service/ReferenceComponentCoverageDerivation.php`, pure functions that turn usages and reference components into a coverage list with gap, covered and overlap states. +- `lib/Service/ReferenceComponentCoverageService.php` and `GET /api/reference-component-coverage`, organisation-scoped and bounded like the portfolio report, with a CSV. +- A Reference component coverage page with a table, filters for gaps and overlaps, and a map on a GEMMA view, reached from a new card on the Reports page. +- Overlap in the portfolio report: per row, the reference components it shares with another application in use, a count in the summary and a column in the CSV. + +## Out of scope + +- The organisation's own capability model. The map uses GEMMA reference components and GEMMA views, which is what municipalities share. A self-defined capability tree is a later change. +- Fixing `ViewService` enrichment on `elementRef`. The coverage map reads its own endpoint and leaves the view API as it is. The `elementRef` grouping is named in Risks. +- Planned future coverage. Usages in status Acquisition or Planned are shown but do not count as coverage. `architecture-future-state-scenarios` compares current and planned landscapes. +- The catalogue-wide facet counts on the Modules page, which stay as they are. + +## Risks + +- A usage that names no reference component covers nothing, so a municipality that never filled `usedForReferenceComponents` sees every component as a gap. The page says how many usages name no component, next to the gap count. +- The report reads usages with RBAC off after an organisation check, as the portfolio report does. The check is shared, not copied, so the two reports cannot drift. diff --git a/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md b/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md new file mode 100644 index 000000000..16516faba --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md @@ -0,0 +1,97 @@ +# reference-component-coverage specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-reference-component-coverage + +## Purpose + +A municipality sees, per GEMMA reference component, which of its applications in use fulfil it: none (a gap), one, or several (an overlap). It reads the result as a table, on a GEMMA view as a map, and as overlap in the portfolio rationalization report. The data stays in OpenRegister (ADR-001); the join between the organisation's usages and the GEMMA reference components runs in one bounded, organisation-scoped endpoint, because no declarative aggregation returns empty buckets (ADR-031). + +## ADDED Requirements + +### Requirement: REQ-RCC-001 Stackiq SHALL give each reference component a coverage state for one organisation + +`GET /api/reference-component-coverage?organisation=` SHALL return every reference component with the organisation's usages that name it in `usedForReferenceComponents` and a state: gap when no counting usage names it, covered when one module does, overlap when two or more different modules do. A usage SHALL count unless its status is Acquisition, Planned or Phased out. A gap SHALL be marked fillable when a module the organisation uses declares the component in `module.referenceComponents`. The response SHALL say how many usages name no component. The endpoint SHALL refuse a user who is not authorised for the organisation before it reads anything, with the same check the portfolio report uses, and SHALL offer the same rows as CSV with `format=csv`. + +#### Scenario: Two applications for one component read as overlap +@e2e exclude The rule is a pure derivation; tests/Unit/Service/ReferenceComponentCoverageDerivationTest.php asserts gap, covered and overlap, that two usages of one module count once, and that Planned and Phased out usages do not count. + +- **GIVEN** an organisation with two usages in production of different modules that both name the reference component Zaakregistratiecomponent +- **WHEN** the coverage is derived +- **THEN** Zaakregistratiecomponent SHALL read overlap with both applications +- **AND** a component no usage names SHALL read gap + +#### Scenario: A gap the organisation could fill is marked +@e2e exclude A pure derivation; tests/Unit/Service/ReferenceComponentCoverageDerivationTest.php asserts that a gap is fillable when a used module lists the component in referenceComponents. + +- **GIVEN** a usage of a module whose `referenceComponents` include Documentbeheercomponent, while no usage names Documentbeheercomponent +- **WHEN** the coverage is derived +- **THEN** Documentbeheercomponent SHALL read gap and fillable + +#### Scenario: Another organisation's coverage is refused +@e2e exclude Needs a second organisation; tests/Unit/Controller/ReferenceComponentCoverageControllerTest.php asserts a 403 before any service call for a user of another organisation, and tests/Unit/Service/OrganisationReportAccessTest.php covers the shared rule. + +- **GIVEN** a signed-in user whose organisation is municipality A +- **WHEN** they call `GET /api/reference-component-coverage?organisation=` +- **THEN** the response SHALL be 403 +- **AND** no usage SHALL be read + +### Requirement: REQ-RCC-002 A coverage page SHALL list the components with filters for gaps and overlaps + +Stackiq SHALL offer a Reference component coverage page at `/reference-component-coverage` (page `ReferenceComponentCoverage`), reached from a card on the Reports page. After an organisation is picked it SHALL show the counts of gaps, fillable gaps, covered components and overlaps, the number of usages that name no component, and a table of components with their state and applications, with the quick filters All, Gaps, Fillable gaps and Overlap, and an Export CSV button. Without any reference component it SHALL say that the GEMMA model must be imported. + +#### Scenario: An information manager filters on overlap +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** two reference component elements and two usages of different modules that both name the first, created by the test fixture through OpenRegister's objects API +- **WHEN** a municipal information manager opens Reports, chooses Reference component coverage, picks the organisation and chooses the quick filter Overlap +- **THEN** the table SHALL show the first component with both applications +- **AND** the summary SHALL read one overlap and one gap + +#### Scenario: The page explains an instance without GEMMA +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** an instance with no reference component elements +- **WHEN** a municipal information manager opens the coverage page and picks an organisation +- **THEN** the page SHALL say that the GEMMA model must be imported to see coverage + +### Requirement: REQ-RCC-003 The coverage SHALL be drawn on a GEMMA view as a map + +The coverage page SHALL let the user pick an imported GEMMA view and SHALL draw it read-only on `CnGraphCanvas`. Every node of a reference component SHALL carry its state as a border colour (the error colour for a gap, the success colour for covered, the warning colour for an overlap) and as a text badge with the number and names of its applications. + +#### Scenario: An information manager sees their applications on a GEMMA view +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** the fixture from REQ-RCC-002 and a view created by the fixture that holds both reference components +- **WHEN** the information manager picks that view on the coverage page +- **THEN** the node of the first component SHALL read overlap with two application names +- **AND** the node of the second SHALL read gap + +#### Scenario: Colour is never the only signal +@e2e exclude A rendering rule; tests/vitest/coverageViewMap.spec.js asserts that every reference component node carries a text badge with its state and that colours are CSS variables. + +- **GIVEN** a view with a gap, a covered and an overlap node +- **WHEN** the map renders +- **THEN** each of the three nodes SHALL carry its state in text + +### Requirement: REQ-RCC-004 The portfolio rationalization report SHALL show overlapping applications + +Each row of `GET /api/portfolio-report` SHALL carry the reference components its usage names and, per shared component, the other modules that cover it. The report SHALL count the rows with an overlap, the CSV SHALL have an `overlapsWith` column, and the Portfolio rationalization page SHALL list each overlapping component with its applications and mark overlapping rows. + +#### Scenario: The portfolio report shows overlap next to ageing +@e2e tests/e2e/workflows/reference-component-coverage.spec.ts + +- **GIVEN** the fixture from REQ-RCC-002 +- **WHEN** a municipal information manager opens Portfolio rationalization and picks the organisation +- **THEN** an Overlap section SHALL list the first component with both applications +- **AND** both rows SHALL be marked as overlapping in the row list + +#### Scenario: The CSV carries the overlap +@e2e exclude A file body; tests/Unit/Service/PortfolioReportServiceOverlapTest.php asserts the overlapsWith column holds "component: module" for an overlapping row and is empty for a row without overlap. + +- **GIVEN** two overlapping rows and one row without overlap +- **WHEN** the CSV is built +- **THEN** the overlapping rows SHALL name the component and the other module in `overlapsWith` +- **AND** the third row SHALL leave it empty diff --git a/openspec/changes/architecture-reference-component-coverage/tasks.md b/openspec/changes/architecture-reference-component-coverage/tasks.md new file mode 100644 index 000000000..b184fe133 --- /dev/null +++ b/openspec/changes/architecture-reference-component-coverage/tasks.md @@ -0,0 +1,80 @@ +# Tasks: architecture-reference-component-coverage + +## Implementation tasks + +### Task 1: Coverage derivation +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation +- **files**: `lib/Service/ReferenceComponentCoverageDerivation.php`, `tests/Unit/Service/ReferenceComponentCoverageDerivationTest.php` +- **acceptance_criteria**: + - GIVEN usages and components WHEN the coverage is derived THEN each component reads gap, covered or overlap by the rules of design D1 + - GIVEN two usages of one module WHEN the coverage is derived THEN they count as one application + - GIVEN a gap and a used module that declares it WHEN the coverage is derived THEN the gap is fillable + - GIVEN a usage with an unknown status WHEN the coverage is derived THEN it counts +- [ ] Implement +- [ ] Test (PHPUnit `ReferenceComponentCoverageDerivationTest`) + +### Task 2: Shared organisation check +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation +- **files**: `lib/Service/OrganisationReportAccess.php`, `lib/Controller/PortfolioReportController.php`, `tests/Unit/Service/OrganisationReportAccessTest.php` +- **acceptance_criteria**: + - GIVEN an admin, an ambtenaar, a member and a non-member WHEN the check runs THEN only the non-member is refused, as today + - GIVEN the portfolio report controller WHEN it runs THEN it calls the shared check and its existing tests stay green +- [ ] Implement +- [ ] Test (PHPUnit `OrganisationReportAccessTest`, existing `PortfolioReportControllerTest`) + +### Task 3: Coverage service, controller and route +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-001-stackiq-shall-give-each-reference-component-a-coverage-state-for-one-organisation +- **files**: `lib/Service/ReferenceComponentCoverageService.php`, `lib/Controller/ReferenceComponentCoverageController.php`, `appinfo/routes.php`, `tests/Unit/Controller/ReferenceComponentCoverageControllerTest.php`, `tests/Unit/Service/ReferenceComponentCoverageServiceTest.php` +- **acceptance_criteria**: + - GIVEN a user of another organisation WHEN the endpoint is called THEN it answers 403 before the service runs + - GIVEN an organisation WHEN the endpoint is called THEN usages and components are read with bounded limits and the payload has the shape of design D2 + - GIVEN `format=csv` WHEN the endpoint is called THEN the same rows come back as CSV +- [ ] Implement +- [ ] Test (PHPUnit `ReferenceComponentCoverageControllerTest`, `ReferenceComponentCoverageServiceTest`) + +### Task 4: Coverage page and Reports card +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-coverage-page-shall-list-the-components-with-filters-for-gaps-and-overlaps +- **files**: `src/manifest.d/reference-component-coverage.json`, `src/views/organisaties/ReferenceComponentCoverage.vue`, `src/customComponents.js`, `tests/e2e/workflows/reference-component-coverage.spec.ts` +- **acceptance_criteria**: + - GIVEN the Reports page WHEN it renders THEN it shows the card Reference component coverage next to Portfolio rationalization + - GIVEN a picked organisation WHEN the page loads THEN it shows the counts, the table and the four quick filters + - GIVEN no reference components WHEN the page loads THEN it says the GEMMA model must be imported +- [ ] Implement +- [ ] Test (Playwright `reference-component-coverage.spec.ts` overlap filter and empty model scenarios) + +### Task 5: Coverage map on a GEMMA view +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-003-the-coverage-shall-be-drawn-on-a-gemma-view-as-a-map +- **files**: `src/views/organisaties/CoverageViewMap.vue`, `tests/vitest/coverageViewMap.spec.js` +- **acceptance_criteria**: + - GIVEN a picked view WHEN it renders THEN it is read-only and every reference component node carries its state as a CSS variable colour and as text + - GIVEN a node whose element is not a reference component WHEN it renders THEN it carries no state +- [ ] Implement +- [ ] Test (vitest `coverageViewMap.spec.js`, Playwright map scenario) + +### Task 6: Overlap in the portfolio report +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-004-the-portfolio-rationalization-report-shall-show-overlapping-applications +- **files**: `lib/Service/PortfolioReportService.php`, `src/views/organisaties/PortfolioReport.vue`, `tests/Unit/Service/PortfolioReportServiceOverlapTest.php` +- **acceptance_criteria**: + - GIVEN two rows that share a component WHEN the report is built THEN both carry `overlapsWith` and the payload counts two overlapping rows + - GIVEN the CSV WHEN it is built THEN it has the `overlapsWith` column + - GIVEN the page WHEN the report has overlap THEN an Overlap section lists each component with its applications +- [ ] Implement +- [ ] Test (PHPUnit `PortfolioReportServiceOverlapTest`, Playwright portfolio overlap scenario) + +### Task 7: Documentation and translations +- **spec_ref**: openspec/changes/architecture-reference-component-coverage/specs/reference-component-coverage/spec.md#requirement-req-rcc-002-a-coverage-page-shall-list-the-components-with-filters-for-gaps-and-overlaps +- **files**: `docs/features/reference-component-coverage.md`, `docs/features/portfolio-rationalization.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature pages WHEN they are read THEN they show the coverage table, the map and the Overlap section, each in a screenshot + - GIVEN a Dutch instance WHEN the coverage page renders THEN every new label reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-reference-component-coverage --type change --strict` +- PHPUnit: `ReferenceComponentCoverageDerivationTest`, `OrganisationReportAccessTest`, `ReferenceComponentCoverageControllerTest`, `ReferenceComponentCoverageServiceTest`, `PortfolioReportServiceOverlapTest`, and the existing portfolio report tests +- vitest: `coverageViewMap.spec.js` +- Playwright: `tests/e2e/workflows/reference-component-coverage.spec.ts` +- Documentation in `docs/features/` with screenshots (ADR-010) +- English and Dutch strings for every new label (ADR-005) diff --git a/openspec/changes/architecture-round-trip-check/.openspec.yaml b/openspec/changes/architecture-round-trip-check/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-round-trip-check/design.md b/openspec/changes/architecture-round-trip-check/design.md new file mode 100644 index 000000000..6977cf2b6 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/design.md @@ -0,0 +1,69 @@ +# Design: architecture-round-trip-check + +Read at development 49e65cb4. Line numbers below are from that sha. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Comparator | new `lib/Service/ArchiMateModelComparator.php` | pure | +| Service | new `lib/Service/ArchiMateRoundTripService.php` | uses the import's conversion and the export's generation | +| Import service | `lib/Service/ArchiMateImportService.php`: the steps before the save in `importArchiMateFileFromPathOptimized` (:347), that is `validateArchiMateFile` (:204), `parseArchiMateXml` (:629), `extractModelIdentifier` (:663) and `transformArchiMateXmlToObjectsBatch` (:4175), move into one public method `convertFileToObjects(string $filePath): array`, which the import then calls | | +| Export service | `lib/Service/ArchiMateExportService.php`: a public `generateXmlFromObjects(array $objects): string` that runs `generateXmlDirectly` (:1009) and `runQualityAssuranceChecks` (:2143), which `exportArchiMateXml` (:951) then calls | | +| Controller and route | `SettingsController::checkArchiMateRoundTrip`, route `settings#checkArchiMateRoundTrip` at `POST /api/archimate/round-trip-check`; the old route (`appinfo/routes.php:107`) and `testArchiMateRoundTrip` (`lib/Controller/SettingsController.php:2886`) go | the upload handling of `importArchiMate` (:1490) is the pattern | +| Removed | `ArchiMateService::testRoundTrip` (`lib/Service/ArchiMateService.php:1496`), `createTestArchiMateXml` (:1559), `createTempFile` (:1585); the store action `testRoundTrip` (`src/store/modules/settings.js:1953`) | | +| View | new `src/views/settings/sections/ArchiMateRoundTripCheck.vue`, placed in `src/views/settings/sections/ArchiMateImportExport.vue` after its Export part (:555) | | +| Store | `src/store/modules/settings.js` gains `checkRoundTrip(file, mode)` | | + +## Decisions + +### D1. The check compares two exchange files by identifier + +`ArchiMateModelComparator::compare(string $sourceXml, string $resultXml): array` reads both files into maps keyed by identifier: + +| Category | Compared on | +|---|---| +| elements | type, name, documentation, property values by property definition name | +| relationships | type, source, target, name, property values | +| views | name, viewpoint, the set of node element references, the set of connection relationship references | +| view nodes | per view: element reference, parent, position and size | +| property definitions | name, type | +| organizations | the folder tree and the items under each folder | + +For each category it returns the count in the source, the count in the result, and the identifiers that are missing, extra or changed, each changed one with the fields that differ. It returns the first 50 examples per category with their names and the full counts, so a report on a GEMMA release stays readable. Order of elements in a file is never a difference. + +Rejected: counting objects, which is what the current code tries. Equal counts can hide one element lost and another added, and a count says nothing about a relationship whose target changed. + +### D2. Two modes, neither writes + +`ArchiMateRoundTripService::check(string $filePath, string $mode): array`: + +- `before-import`: `ArchiMateImportService::convertFileToObjects` turns the file into the objects the import would save, `ArchiMateExportService::generateXmlFromObjects` turns those objects into an exchange file, and the comparator compares it with the source. Nothing is saved. This covers loss in the import's conversion and the export's generation. +- `against-imported`: reads the model identifier from the file, reads the stored AMEF objects whose `model_identifier` equals it (the import stamps it on every object, `ArchiMateImportService.php:592`) through the export's reader (`getObjectsFromDatabase`, `ArchiMateExportService.php:808`), generates the exchange file from them and compares. This also covers what OpenRegister dropped when it stored the objects. When no object carries that identifier, it answers that the model is not imported. + +Both modes run the code paths the real import and export run, because the import and the full export call the two new public methods themselves. A check that ran a copy of the conversion would pass while the real import lost data. + +Rejected: importing a test model and exporting it again, which the current code does (`lib/Service/ArchiMateService.php:1504`). It writes a test model into the register every user reads and leaves it there. + +Rejected: a built-in test model. A small hand-written file shows only that the small file survives; the admin's question is whether their GEMMA release does. + +### D3. Admin only, upload only + +`POST /api/archimate/round-trip-check` takes a multipart upload `archiMateFile` and a `mode`, with the same upload handling as the import (:1490): no `file_path` parameter, the presence check of `validateArchiMateFile` (`ArchiMateImportService.php:204`), and the admin check the import makes (`SettingsController.php:1496`). The uploaded temporary file is PHP's own and is not copied. The response is the comparator's result with the mode, the model identifier and the time taken. + +The current endpoint is `@NoAdminRequired` (:2880) and writes to the register, so a signed-in user without admin rights can put objects into the AMEF register today. Removing it closes that. + +### D4. The report on the settings page + +`ArchiMateRoundTripCheck.vue` sits under the ArchiMate import and export on the admin settings page. It holds a file picker, a choice between "Before import (nothing is written)" and "Against the imported model", and a Check button. The result is a table with one row per category (in the file, after the round trip, missing, extra, changed) and, per category, an expandable list of examples with identifier, name and the fields that differ. A summary line says "No losses found" or names the categories with losses. Colours are Nextcloud CSS variables and every state is also written as text. + +## Declarative versus imperative + +The change adds no lifecycle, aggregation, notification, relation or widget behaviour. It is a read-only comparison of two files, imperative by nature, and it removes code. + +## Risks + +- **Moving import steps.** `convertFileToObjects` wraps code the import already runs, in the same order. The existing decomposition tests (`tests/Unit/Service/ArchiMateImportServiceDecompositionTest.php`, `ArchiMateExportServiceDecompositionTest.php`) must stay green, and a new test imports `lib/Settings/GEMMA_testdata_below_1_5mb.xml` through both the old and the new entry and compares the object lists. +- **Findings on day one.** The check will likely report losses on a real GEMMA file, for example property names the import lowercases (`convertToCamelCase`, `ArchiMateImportService.php:2256`). That is the point; the report names them and the fixes are separate changes. +- **Memory.** A before-import check on a GEMMA release holds the converted objects and two XML documents at once. The route runs under the import's limits, and the page warns that a large file takes as long as an import. +- **Tests that mock the old endpoint.** `tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php:458` and :482 mock `testRoundTrip`; they move to the new route. diff --git a/openspec/changes/architecture-round-trip-check/proposal.md b/openspec/changes/architecture-round-trip-check/proposal.md new file mode 100644 index 000000000..23a1b7243 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/proposal.md @@ -0,0 +1,41 @@ +--- +kind: code +depends_on: [] +--- + +# Check that an ArchiMate model survives import and export + +## Summary + +A Nextcloud admin uploads an ArchiMate exchange file on the settings page and gets a report of what would be lost if stackiq imported and exported it: elements, relationships, views, view nodes, connections and property values that go missing, appear or change on the way. The check can run before an import, writing nothing, or against a model already imported. It replaces a round-trip endpoint that can never succeed, is open to any signed-in user, and imports a test model into the live register. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-round-trip`, "Check that a model survives import and export without losing elements or relations." The row comes from stackiq's own code (feature archimate-import-and-export); no tender, feature request or competitor names it, and no competitor rates yes. The lane decided build: "Built state but rated no: the code exists and does not work, so the change repairs it." The matrix note: "There is an endpoint, but its comparison is broken (a missing key against a placeholder string) and no page calls it." + +## What stackiq has today + +- `POST /api/archimate/test-round-trip` (`appinfo/routes.php:107`) runs `SettingsController::testArchiMateRoundTrip` (`lib/Controller/SettingsController.php:2886`), marked `@NoAdminRequired`: any signed-in user may call it, while the ArchiMate import itself requires an admin (:1496). +- It calls `ArchiMateService::testRoundTrip` (`lib/Service/ArchiMateService.php:1496`), which imports a built-in test model into the live AMEF register through `importArchiMateFileFromPath` (:1504) and leaves it there. The test model (:1559) uses Archi's native namespace instead of the exchange format, uses the `xsi` prefix without declaring it, and relates `test-element-1` to a `test-element-2` that does not exist. Its temporary file (:1585) is never removed. +- The comparison reads `$importResult['imported_count']` (:1528), a key no import path sets; the import returns per-section `statistics` (`lib/Service/ArchiMateImportService.php:444` and :572). It compares that with `exported_count`, which the export returns as the literal string `calculated_in_export_service` (`lib/Service/ArchiMateService.php:271`), and the export covers the whole register, not the test model. So the check can never report success. The service returns an `error` key while the controller reads `message`. +- The store action `testRoundTrip` (`src/store/modules/settings.js:1953`) has no caller. Only `tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php:458` and :482 exercise the endpoint, with the service mocked. +- The import the settings page runs is the optimised one by default (`SettingsController.php:1594`, `useOptimized` defaults to true). It runs as separate steps: parse (`ArchiMateImportService.php:629`), read the model identifier (:663), convert to objects (`transformArchiMateXmlToObjectsBatch`, :4175) and save (:1291). The export reads objects (`ArchiMateExportService.php:808`), generates XML from them (`generateXmlDirectly`, :1009) and runs quality checks (:2143). Every imported object carries its `model_identifier` (`ArchiMateImportService.php:592`). + +## What this change builds + +- `lib/Service/ArchiMateModelComparator.php`, a pure comparison of two exchange files by identifier: elements, relationships, views with their nodes and connections, property definitions and property values. +- `lib/Service/ArchiMateRoundTripService.php` with two modes: before import (the import's conversion steps and the export's generation in memory, no write) and against the imported model (an export of the stored objects of that model, read-only). +- `POST /api/archimate/round-trip-check`, admin only, taking an uploaded file and a mode. +- A Check a model file part in the ArchiMate section of the admin settings, with a report per category and examples. +- Removal of the old endpoint, the service method, its test model and temporary file, and the unused store action. + +## Out of scope + +- Fixing what the check finds. The report names the losses; repairs to the import or export are their own changes. +- Comparing with Archi's native `.archimate` format. The check works on the Open Group exchange format that the import and export use. +- A schedule that runs the check on every import. + +## Risks + +- A check before import converts the whole file in memory, as an import does. On a GEMMA release that takes the same time and memory as an import, so the page says so and the route keeps the import's limits. +- The before-import mode cannot see what OpenRegister drops when it stores an object. The against-imported mode can, which is why both exist. diff --git a/openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md b/openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md new file mode 100644 index 000000000..60276b878 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md @@ -0,0 +1,88 @@ +# archimate-round-trip-check specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-round-trip-check + +## Purpose + +A Nextcloud admin checks, before or after an import, that an ArchiMate exchange file survives stackiq's import and export without losing or changing elements, relationships, views or property values. The check writes nothing and runs the same conversion and generation code as the real import and export. It replaces a round-trip endpoint that could never succeed, was open to every signed-in user and wrote a test model into the live register. + +## ADDED Requirements + +### Requirement: REQ-ART-001 Stackiq SHALL compare two exchange files by identifier per category + +The comparison SHALL key elements, relationships, views, view nodes, property definitions and organization folders by identifier, and SHALL report per category the count in the source, the count after the round trip, and the missing, extra and changed identifiers, each changed one with the fields that differ. Elements SHALL be compared on type, name, documentation and property values; relationships on type, source, target, name and property values; views on name, viewpoint and their node and connection references; view nodes on element reference, parent, position and size. The order of items in a file SHALL NOT count as a difference. The report SHALL hold at most 50 examples per category and always the full counts. + +#### Scenario: A relationship with a changed target is reported +@e2e exclude A pure comparison; tests/Unit/Service/ArchiMateModelComparatorTest.php compares two files that differ in one relationship target and asserts one changed relationship naming the field target, and no other difference. + +- **GIVEN** two exchange files that are equal except for the target of one relationship +- **WHEN** they are compared +- **THEN** the relationships category SHALL report one changed identifier with the field target +- **AND** every other category SHALL report no difference + +#### Scenario: Reordered elements are not a difference +@e2e exclude A pure comparison; tests/Unit/Service/ArchiMateModelComparatorTest.php compares a file with itself in reversed element order and asserts no difference. + +- **GIVEN** a file and the same file with its elements in reverse order +- **WHEN** they are compared +- **THEN** no category SHALL report a difference + +### Requirement: REQ-ART-002 The check SHALL run before an import or against an imported model without writing + +`POST /api/archimate/round-trip-check` SHALL take an uploaded exchange file and a mode. In the mode before-import it SHALL convert the file with the import's own conversion, generate an exchange file from the result with the export's own generation, and compare it with the upload, saving nothing. In the mode against-imported it SHALL read the stored objects whose model identifier equals the file's, generate an exchange file from them and compare, saving nothing; when no stored object has that identifier it SHALL say the model is not imported. The import and the full export SHALL call the same conversion and generation methods the check calls. + +#### Scenario: A check before import leaves the register untouched +@e2e exclude Needs a GEMMA-sized file and minutes of runtime; tests/Unit/Service/ArchiMateRoundTripServiceTest.php runs the before-import mode on lib/Settings/GEMMA_testdata_below_1_5mb.xml with ObjectService mocked and asserts that no save method is called and a report comes back for every category. + +- **GIVEN** a Nextcloud admin and a GEMMA exchange file +- **WHEN** they run the check before import +- **THEN** the report SHALL list every category with its counts +- **AND** the AMEF register SHALL hold the same objects as before + +#### Scenario: The import and the check convert the same way +@e2e exclude A refactor guard; tests/Unit/Service/ArchiMateImportServiceConvertTest.php converts the fixture through importArchiMateFileFromPathOptimized with the save mocked and through convertFileToObjects, and asserts equal object lists. + +- **GIVEN** the fixture file +- **WHEN** it is converted by the import and by the check +- **THEN** both SHALL produce the same objects + +#### Scenario: A model that was never imported is named as such +@e2e exclude The CI instance has no imported model; tests/Unit/Service/ArchiMateRoundTripServiceTest.php asserts the against-imported mode answers "model not imported" when no stored object carries the file's model identifier. + +- **GIVEN** an exchange file whose model identifier no stored object carries +- **WHEN** a Nextcloud admin runs the check against the imported model +- **THEN** the answer SHALL say the model is not imported + +### Requirement: REQ-ART-003 Only an admin SHALL run the check, and the old round-trip endpoint SHALL be removed + +The check route SHALL refuse a signed-in user who is not a Nextcloud admin with 403 and SHALL accept only a multipart upload, never a file path. `POST /api/archimate/test-round-trip`, `SettingsController::testArchiMateRoundTrip`, `ArchiMateService::testRoundTrip` with its built-in test model and temporary file, and the store action `testRoundTrip` SHALL be removed. + +#### Scenario: A user without admin rights is refused +@e2e exclude The route test covers it without a second user; tests/Unit/Controller/SettingsControllerRoundTripCheckTest.php asserts a 403 for a non-admin and a 400 for a body with file_path, before the service is called. + +- **GIVEN** a signed-in user who is not an admin +- **WHEN** they post a file to `/api/archimate/round-trip-check` +- **THEN** the response SHALL be 403 +- **AND** the service SHALL NOT run + +#### Scenario: The old endpoint is gone +@e2e exclude A route table rule; tests/Unit/SettingsRouteTableTest.php asserts no route named settings#testArchiMateRoundTrip and one route settings#checkArchiMateRoundTrip. + +- **GIVEN** the route table +- **WHEN** it is read +- **THEN** `/api/archimate/test-round-trip` SHALL NOT exist + +### Requirement: REQ-ART-004 The admin settings page SHALL show the round-trip report + +The ArchiMate section of the admin settings SHALL offer Check a model file with a file picker, the choice "Before import (nothing is written)" or "Against the imported model", and a Check button. The result SHALL show a table with one row per category (in the file, after the round trip, missing, extra, changed), an expandable list of examples per category, and a summary that reads "No losses found" or names the categories with losses. + +#### Scenario: An admin checks a small model before importing it +@e2e tests/e2e/workflows/archimate-round-trip-check.spec.ts + +- **GIVEN** a Nextcloud admin on the stackiq admin settings page and a small exchange file from the test fixtures +- **WHEN** they choose Check a model file, pick the file, keep Before import and choose Check +- **THEN** the page SHALL show the table with a row for elements, relationships and views +- **AND** the summary SHALL read "No losses found" or name the categories with losses diff --git a/openspec/changes/architecture-round-trip-check/tasks.md b/openspec/changes/architecture-round-trip-check/tasks.md new file mode 100644 index 000000000..cb4804653 --- /dev/null +++ b/openspec/changes/architecture-round-trip-check/tasks.md @@ -0,0 +1,69 @@ +# Tasks: architecture-round-trip-check + +## Implementation tasks + +### Task 1: Model comparator +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-001-stackiq-shall-compare-two-exchange-files-by-identifier-per-category +- **files**: `lib/Service/ArchiMateModelComparator.php`, `tests/Unit/Service/ArchiMateModelComparatorTest.php` +- **acceptance_criteria**: + - GIVEN two files that differ in one relationship target WHEN they are compared THEN exactly one changed relationship is reported with the field target + - GIVEN a file and itself in another order WHEN they are compared THEN no difference is reported + - GIVEN more than 50 differences in a category WHEN they are compared THEN 50 examples and the full counts come back +- [ ] Implement +- [ ] Test (PHPUnit `ArchiMateModelComparatorTest`) + +### Task 2: One conversion and one generation entry +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-002-the-check-shall-run-before-an-import-or-against-an-imported-model-without-writing +- **files**: `lib/Service/ArchiMateImportService.php`, `lib/Service/ArchiMateExportService.php`, `tests/Unit/Service/ArchiMateImportServiceConvertTest.php` +- **acceptance_criteria**: + - GIVEN the fixture `lib/Settings/GEMMA_testdata_below_1_5mb.xml` WHEN it is converted by the optimised import with the save mocked and by `convertFileToObjects` THEN the object lists are equal + - GIVEN `exportArchiMateXml` WHEN it runs THEN it calls `generateXmlFromObjects`, and the existing decomposition tests stay green +- [ ] Implement +- [ ] Test (PHPUnit `ArchiMateImportServiceConvertTest`, `ArchiMateImportServiceDecompositionTest`, `ArchiMateExportServiceDecompositionTest`) + +### Task 3: Round-trip service with two modes +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-002-the-check-shall-run-before-an-import-or-against-an-imported-model-without-writing +- **files**: `lib/Service/ArchiMateRoundTripService.php`, `tests/Unit/Service/ArchiMateRoundTripServiceTest.php` +- **acceptance_criteria**: + - GIVEN the before-import mode WHEN it runs on the fixture THEN no save method is called and every category is reported + - GIVEN the against-imported mode and stored objects of the model WHEN it runs THEN it compares only objects with that model identifier + - GIVEN no stored object with the model identifier WHEN the against-imported mode runs THEN it answers that the model is not imported +- [ ] Implement +- [ ] Test (PHPUnit `ArchiMateRoundTripServiceTest`) + +### Task 4: Admin route and removal of the old round trip +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-003-only-an-admin-shall-run-the-check-and-the-old-round-trip-endpoint-shall-be-removed +- **files**: `lib/Controller/SettingsController.php`, `appinfo/routes.php`, `lib/Service/ArchiMateService.php`, `src/store/modules/settings.js`, `tests/Unit/Controller/SettingsControllerRoundTripCheckTest.php`, `tests/Unit/Controller/SettingsControllerEmailArchiMateContractTest.php`, `tests/Unit/SettingsRouteTableTest.php` +- **acceptance_criteria**: + - GIVEN a non-admin WHEN they post to the check route THEN the answer is 403 before the service runs + - GIVEN a body with `file_path` WHEN it is posted THEN the answer is 400 + - GIVEN the route table WHEN it is read THEN `settings#testArchiMateRoundTrip` is gone and `settings#checkArchiMateRoundTrip` exists + - GIVEN the source WHEN it is searched THEN `testRoundTrip`, `createTestArchiMateXml` and `createTempFile` are gone +- [ ] Implement +- [ ] Test (PHPUnit `SettingsControllerRoundTripCheckTest`, `SettingsRouteTableTest`, updated `SettingsControllerEmailArchiMateContractTest`) + +### Task 5: Report on the admin settings page +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-004-the-admin-settings-page-shall-show-the-round-trip-report +- **files**: `src/views/settings/sections/ArchiMateRoundTripCheck.vue`, `src/views/settings/sections/ArchiMateImportExport.vue`, `src/store/modules/settings.js`, `tests/e2e/workflows/archimate-round-trip-check.spec.ts` +- **acceptance_criteria**: + - GIVEN the admin settings page WHEN the ArchiMate section renders THEN Check a model file offers a file picker, the two modes and a Check button + - GIVEN a result WHEN it renders THEN a row per category shows the counts, examples expand per category, and the summary names the categories with losses or reads "No losses found" +- [ ] Implement +- [ ] Test (Playwright `archimate-round-trip-check.spec.ts`) + +### Task 6: Documentation and translations +- **spec_ref**: openspec/changes/architecture-round-trip-check/specs/archimate-round-trip-check/spec.md#requirement-req-art-004-the-admin-settings-page-shall-show-the-round-trip-report +- **files**: `docs/features/archimate-import-export.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it explains both modes and shows a report in a screenshot + - GIVEN a Dutch instance WHEN the check renders THEN every label and summary reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshot captured with Playwright) + +## Verification + +- `openspec validate architecture-round-trip-check --type change --strict` +- PHPUnit: `ArchiMateModelComparatorTest`, `ArchiMateImportServiceConvertTest`, `ArchiMateRoundTripServiceTest`, `SettingsControllerRoundTripCheckTest`, `SettingsRouteTableTest`, and the existing ArchiMate decomposition tests +- Playwright: `tests/e2e/workflows/archimate-round-trip-check.spec.ts` +- Documentation in `docs/features/archimate-import-export.md` with a screenshot (ADR-010) +- English and Dutch strings for every new label (ADR-005) diff --git a/openspec/changes/architecture-views-editor/.openspec.yaml b/openspec/changes/architecture-views-editor/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-views-editor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-views-editor/design.md b/openspec/changes/architecture-views-editor/design.md new file mode 100644 index 000000000..da31d622f --- /dev/null +++ b/openspec/changes/architecture-views-editor/design.md @@ -0,0 +1,137 @@ +# Design: architecture-views-editor + +Read at development 49e65cb4. Line numbers below are from that sha. + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Register | `vng-gemma` register (`lib/Settings/softwarecatalogus_register.json:916`), schemas `view` (:5299), `element` (:4130), `relation` (:6300) | through a new fragment `lib/Settings/register.d/architecture-views.json` | +| Service | `lib/Service/ViewService.php` `getViewsFromRegister` (:232) and `getViewFromRegister` (:307) | the drawn views filter | +| Service | `lib/Service/ArchiMateExportService.php` `getObjectsFromDatabase` (:808, the read at :844) | the drawn objects filter | +| Routes | none added. `appinfo/routes.php:185-187` stays as it is | | +| Pages | new `src/manifest.d/architecture-views.json` with `Views` (index) and `ViewEditor` (custom); `src/menu-layout.json` relocations | | +| Views | new `src/views/architecture/ArchitectureViewEditor.vue`, `src/views/architecture/ViewVersionCompare.vue` | registered in `src/customComponents.js` | +| Store and utils | new `src/store/modules/architectureView.js`, `src/utils/viewGraph.js`, `src/utils/viewDiff.js` | `src/store/modules/view.js` is left alone | + +The fragment is merged by `SettingsService::loadSettings` (`lib/Service/SettingsService.php:1653-1680`). `deepMergeConfig` (:7338) appends lists and replaces lists under `authorization`. So the fragment carries `components.schemas.view-version`, `components.registers.vng-gemma.schemas: ["view-version"]` and `components.registers.vng-gemma.configuration.schemas.view-version: {"magicMapping": true, "autoCreateTable": true}`, plus the new properties on `view`, `element` and `relation`. It bumps `view` to 0.0.8, `element` to 0.0.12 and `relation` to 0.0.9. + +## Decisions + +### D1. A drawn view is a `view` object with `origin: drawn` + +A user's view is stored in the same `view` schema as the imported GEMMA views, with `origin` set to `drawn`. The import writes `origin: imported`. A view imported before this change has no `origin` and counts as imported. Every reader that must see only GEMMA content keeps views whose `origin` is empty or `imported`, so a later origin value is left out by default. + +Rejected: a new schema in the `stackiq` register. `ViewService` and both ArchiMate exports read only the AMEF `view` schema, so a second store would split the views list and leave drawn views out of any later export. + +### D2. Imported views open read-only, and Copy to edit makes a drawn copy + +The import saves views with `@self.id` equal to the ArchiMate identifier (`lib/Service/ArchiMateImportService.php:5394`) through `saveObjects` (:1587), which updates the object with that id. An edit to an imported view would be overwritten without a word on the next GEMMA import. The editor therefore opens `origin: imported` views read-only and offers Copy to edit. The copy gets a fresh identifier `id-`, `origin: drawn`, `status: draft` and `basedOn` set to the source view's uuid. + +Rejected: editing in place with a "protected" flag the import respects. The import is 5,961 lines with three save paths (:1369, :1587, :1695), and a flag that one of them forgets fails silently. + +### D3. The editor saves the shape the import writes + +The editor writes `xml.viewNodes` and `xml.viewRelationships` in the import's shape: a flat node list with `viewNodeId`, `parent`, `elementRef`, `x`, `y`, `width`, `height`, `name` and `type` (:2886 to :3058), and connections with `viewRelationshipId`, `modelRelationshipId`, `sourceId`, `targetId`, `type` and `bendpoints` (:3364). It fills the required `nodes` and `connections` fields with the same lists. `ViewService::transformView` (`lib/Service/ViewService.php:1451`) and the exporter then read a drawn view the way they read an imported one. + +`src/utils/viewGraph.js` maps that shape to and from `CnGraphCanvas` nodes and edges. A node with a `parent` becomes a Vue Flow child node of that parent, so a grouping keeps its children. + +Rejected: storing the canvas's own JSON. Two shapes need two readers, and the export would miss drawn views. + +### D4. The canvas is `CnGraphCanvas` + +`CnGraphCanvas` (`@conduction/nextcloud-vue` 2.57.1, `src/components/CnGraphCanvas/CnGraphCanvas.vue`) takes `nodes`, `edges` and `readOnly`, emits a connection for pointer and keyboard input alike, and carries labelled zoom controls. The editor renders an ArchiMate element in the `node` slot: the element name, its ArchiMate type and the layer colour from Nextcloud CSS variables (ADR-003). + +Rejected: a separate diagram library. ADR-012 asks for the shared components, and `CnGraphCanvas` already carries the keyboard contract (ADR-059). + +### D5. A drawn connection references a `relation` object + +The ArchiMate exchange format needs every relationship connection to name a relationship. When the user connects two elements with a relation type, the store looks for a `relation` object of that type between the two elements (`source`, `target` and `type`, register.json:6346 and :6376) and reuses it, or creates one with `origin: drawn`. The connection stores its id as `modelRelationshipId`. + +A new element placed from the palette ("New application component", and the other ArchiMate element types) is created as an `element` object with `origin: drawn` and the ArchiMate `type`. An existing GEMMA element is placed by reference and is not changed. + +### D6. Versions are explicit snapshots in `view-version` + +Save version writes a `view-version` object with the view's uuid, a version number, a label, the saving user, the time and a copy of `xml.viewNodes` and `xml.viewRelationships`. Compare versions loads two snapshots, or one snapshot and the current view, and `src/utils/viewDiff.js` sorts every node and connection by id into added, removed, changed and unchanged. A node counts as changed when its name, element, parent, position, size or style differs. `ViewVersionCompare.vue` renders both sides on a read-only `CnGraphCanvas`, marks added in `--color-success`, removed in `--color-error` and changed in `--color-warning`, and lists the same result as text for screen readers. + +Rejected: OpenRegister's audit trail with `CnVersionHistory`. OpenRegister replaces any changed value over 65,536 bytes with a descriptor (`openregister-ro/lib/Db/AuditTrailPayloadHelper.php:51` and :150), so the node list of a large view cannot be rebuilt from its history. And `computeObjectDiff` (`@conduction/nextcloud-vue` `src/utils/computeObjectDiff.js:141`) compares arrays by index, so one node removed from the middle reads as every later node changed. The audit trail still records who saved what, in the sidebar History tab. + +### D7. Tags, status and owner filter the views list + +`view.tags` is a facetable string list. `view.status` is a facetable enum `draft`, `in review`, `published`, `retired` with default `draft`. `view.origin` is facetable. The `Views` index page is a `CnIndexPage` (manifest `type: index`) over `@resolve:amef_register` and `view`, with columns name, viewpoint, status, tags and origin, the schema facets in its sidebar, and quick filters All, Mine (`{"_owner": "@me"}`), Drawn and Imported. `@me` is resolved by `@conduction/nextcloud-vue` (`src/utils/resolveFilterTokens.js:119`). + +### D8. The menu gains a group, not an entry + +ADR-097 caps the main menu at six entries, and stackiq has 13 after relocation. The fragment adds a group `Architecture` (order 50, no route) and `src/menu-layout.json` relocates `Standaarden` and the new `Views` entry under it. The count of top-level entries stays 13. + +### D9. Drawn objects stay inside the organisation + +Drawn views, elements and relations are scoped to the organisation that created them through OpenRegister multitenancy. Three readers bypass that today and each gets a filter: + +- `ViewService::getViewsFromRegister` caches one list for all callers (`views_list`, :234, 30 minutes, :74). It keeps only views whose `origin` is empty or `imported`, so the cache only ever holds GEMMA views. +- `ViewService::getView` (:166) reads one view through `getViewFromRegister` (:307) with `_rbac: false` and `_multitenancy: false` (:328), so `GET /api/views/{viewId}` returns any view to any signed-in user. It answers a view whose `origin` is not empty or `imported` with a 404, the same answer as a missing view, so a uuid does not reveal that a drawn view exists. +- `ArchiMateExportService::getObjectsFromDatabase` reads with `_rbac: false` and `_multitenancy: false` (:844). The full model export keeps only objects whose `origin` is empty or `imported`. + +The editor and the index read drawn views through OpenRegister's objects API, which applies RBAC and multitenancy. + +### D10. One editor at a time + +The editor takes OpenRegister's object lock through `useObjectLock` (`@conduction/nextcloud-vue` `src/composables/useObjectLock.js:66`) before it enters edit mode, and shows who holds the lock otherwise (ADR-033). + +## Declarative versus imperative + +- The view status lifecycle is declared in the fragment as `configuration.x-openregister-lifecycle` on `view`, in the shape `usage` already uses (field `status`, `initial` draft, named transitions): submit (draft to in review), publish (in review to published), rework (in review to draft), retire (published to retired), reopen (retired to draft). The `from` and `to` values are the enum values exactly, because a lifecycle whose values match no row offers no transition and raises no error (register changelog 2.4.4, register.json:7). No PHP. +- Copy to edit, Save version and relation reuse write through OpenRegister's objects API from `src/store/modules/architectureView.js`. They add no controller and no service (ADR-022, config rule "Uses OpenRegister API directly from frontend"). +- The three backend filters in D9 are changes to existing readers, not new behaviour. + +## Seed data + +The fragment seeds a small drawn example so the Views page is not empty on a fresh install. All objects live in the `vng-gemma` register. + +### Schema: `element` + +| Field | Object 1 | Object 2 | Object 3 | +|---|---|---|---| +| slug | `seed-el-zaaksysteem` | `seed-el-dms` | `seed-el-zaakregistratie` | +| identifier | `id-seed-el-zaaksysteem` | `id-seed-el-dms` | `id-seed-el-zaakregistratie` | +| type | `ApplicationComponent` | `ApplicationComponent` | `ApplicationService` | +| name | Zaaksysteem | Documentbeheer | Zaakregistratie | +| origin | drawn | drawn | drawn | + +### Schema: `relation` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-rel-zaak-dms` | `seed-rel-zaak-registratie` | +| type | `Flow` | `Realization` | +| source | `id-seed-el-zaaksysteem` | `id-seed-el-zaaksysteem` | +| target | `id-seed-el-dms` | `id-seed-el-zaakregistratie` | +| origin | drawn | drawn | + +### Schema: `view` + +| Field | Object 1 | Object 2 | +|---|---|---| +| slug | `seed-view-zaakgericht-nu` | `seed-view-zaakgericht-concept` | +| name | Zaakgericht werken, huidige situatie | Zaakgericht werken, concept | +| status | published | draft | +| tags | zaakgericht, applicatielandschap | zaakgericht | +| origin | drawn | drawn | +| nodes | the three elements | the first two elements | + +### Schema: `view-version` + +| Field | Object 1 | +|---|---| +| slug | `seed-view-zaakgericht-nu-v1` | +| view | uuid of `seed-view-zaakgericht-nu` | +| versionNumber | 1 | +| label | Eerste opzet | +| nodes | the first two elements, so a compare with the current view shows one addition | + +## Risks + +- **Nested ArchiMate nodes on Vue Flow.** Vue Flow draws a child node relative to its parent, while the import stores absolute coordinates. `viewGraph.js` converts both ways and its vitest spec round-trips an imported GEMMA view without moving a node. +- **Partial saves.** A save that creates relations and then the view can fail between the two. The store writes relations first, reuses them on a retry, and reports a failed view write without leaving the canvas. +- **Large views.** A GEMMA view with a few hundred nodes is a large object. The editor loads one view, never the whole list with nodes, and the index columns do not include `xml`. +- **Schema versions.** The fragment bumps three AMEF schema versions. The register changelog entry 2.4.4 (register.json:7) records why: a deployed version equal to or above the declared one makes the import skip, and OpenRegister compares only properties, required and authorization, never `configuration`, where the lifecycle lives. diff --git a/openspec/changes/architecture-views-editor/proposal.md b/openspec/changes/architecture-views-editor/proposal.md new file mode 100644 index 000000000..bfcc11dca --- /dev/null +++ b/openspec/changes/architecture-views-editor/proposal.md @@ -0,0 +1,53 @@ +--- +kind: code +depends_on: [] +--- + +# Draw, tag and compare architecture views in stackiq + +## Summary + +An application owner can draw an ArchiMate view in stackiq: place elements, connect them, save the view and open it again. Views get tags, a status and an owner, and the views list filters on all three. An owner can save a named version of a view and compare two versions, with additions, removals and changes marked on the canvas and listed as text. Imported GEMMA views open read-only, and Copy to edit makes a drawn copy. + +## Why + +Three rows of the stackiq parity matrix are built by this change. + +- `stackiq:arch-modelling`, "Draw and edit architecture models yourself inside the tool." SAP LeanIX rates yes: "free draw and data flow diagrams edited in the diagram editor, with an ArchiMate 3.2 shape template" (https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams). BlueDolphin rates yes: "users create and edit ArchiMate architecture views in the view editor" (https://help.bluedolphin.io/en/articles/11967507-create-an-architecture-view). No tender or feature request names it. The lane decided build because two competitors rate yes and architecture is a core area. +- `stackiq:arch-diagram-version-compare`, "Compare two saved versions of an architecture diagram and see what was added, removed or changed." The demand is a changelog entry, https://updates.leanix.net/announcements/compare-the-content-of-different-diagram-versions. SAP LeanIX rates yes: "Selecting Compare Changes ... on a prior diagram version shows color-coded highlighting (green for additions, red for removals, yellow for modifications), a side-by-side view of differences, and an additional text-based summary". The matrix holds no evidence URL beyond the changelog entry. Decided build: core area. +- `stackiq:arch-view-tags`, "Tag saved diagrams and views and filter the view list by tag, owner or status to find them again." The demand is a changelog entry, https://help.bluedolphin.io/en/articles/16096602-discover-and-manage-views-in-the-views-list. BlueDolphin rates yes: "Bring structure to your Views list with tags", with view filters "Owner Contributors Tags Favorited Private Project Status Type" (https://bluedolphin.io/product-news/). Decided build: core area. + +The rated rows say stackiq renders no architecture view. The pending row `stackiq:arch-gemma-views` rates stackiq partial with state built. Both hold, because they describe different things. The API that serves enriched views exists (`appinfo/routes.php:185-187`), and so does the organisation export that draws applications into copies of GEMMA views (`lib/Service/ArchiMateExportService.php:2734`). No page draws a view: `src/store/modules/view.js:15` defines `useViewStore`, nothing under `src/` imports it, and nothing under `src/` reads `viewNodes` or `viewRelationships`. The pending row's own note says the same. + +## What stackiq has today + +- The AMEF register `vng-gemma` (`lib/Settings/softwarecatalogus_register.json:916`) holds the schemas `element` (:4130), `view` (:5299), `model` (:5689), `property-definition` (:6140) and `relation` (:6300). The `view` schema is at version 0.0.7 (:5304) and its authorization only grants public read (:5678). It has no tag, status or owner field of its own. +- The ArchiMate import writes each view with `@self.id` set to the ArchiMate identifier (`lib/Service/ArchiMateImportService.php:5394`) and stores the diagram under `xml.viewNodes` and `xml.viewRelationships`: a flat node list with `parent` references, `elementRef`, `x`, `y`, `width` and `height` (:2886 to :3058), and connections with `modelRelationshipId`, `sourceId` and `targetId` (:3364). It saves through `saveObjects` (:1587), which updates an object with the same id. A re-import overwrites every imported view. +- `lib/Controller/ViewController.php:82` and `lib/Service/ViewService.php:108` serve `GET /api/views`, and `ViewService::transformView` (:1451) turns `xml.viewNodes` into `viewNodes` with a `position` and a `style`. The list is cached for all callers under one key, `views_list` (:234), for 30 minutes (:74). +- The full ArchiMate export reads every AMEF object with `_rbac: false` and `_multitenancy: false` (`lib/Service/ArchiMateExportService.php:844`). +- `src/manifest.json` has one page over the AMEF register, Standaarden (:701), filtered to `gemmaType` standaard. The main menu has 15 entries next to Dashboard, 13 after `src/menu-layout.json` relocates two of them. +- OpenRegister keeps an audit trail per object and can revert to a version (`openregister-ro/appinfo/routes.php:1344` and :1453). It replaces any changed value over 65,536 bytes with a descriptor (`openregister-ro/lib/Db/AuditTrailPayloadHelper.php:51` and :150). +- `@conduction/nextcloud-vue` 2.57.1 ships `CnGraphCanvas` (`src/components/CnGraphCanvas/CnGraphCanvas.vue`, a Vue Flow canvas with `nodes`, `edges` and `readOnly` props), `CnVersionHistory` and the `useObjectLock` composable (`src/composables/useObjectLock.js:66`). + +## What this change builds + +- A register fragment `lib/Settings/register.d/architecture-views.json` that adds `tags`, `status`, `origin` and `basedOn` to `view`, adds `origin` to `element` and `relation`, declares the view status lifecycle, and adds a `view-version` schema to the `vng-gemma` register. +- A Views index page and a view editor page in `src/manifest.d/architecture-views.json`, reached from an Architecture menu group that takes the place of the top-level Standards entry. +- A canvas editor on `CnGraphCanvas` that places elements, draws connections backed by `relation` objects, and saves in the shape the import already writes. +- Copy to edit for imported views, which open read-only. +- Save version and Compare versions, with a diff keyed by node and connection id. +- Backend guards: drawn views stay out of the shared `/api/views` list, the single view read `/api/views/{viewId}` and the full ArchiMate export. + +## Out of scope + +- Drawing the organisation's own applications (stackiq `module` objects) into a view. The organisation export does that into copies of GEMMA views today, and the pending row `stackiq:arch-gemma-views` covers it. +- Drafting a view with an assistant. That is `architecture-assistant-drafted-views`, which depends on this change. +- Putting a view into Word or PowerPoint. That is `architecture-views-to-office-documents`. +- Business processes on a view. That is `architecture-process-mapping`. +- Editing an imported GEMMA view in place, and writing a drawn view back into the GEMMA model. +- Live co-editing. One editor holds the lock (ADR-033), others read. + +## Risks + +- A drawn view in the AMEF register sits next to VNG's GEMMA content. The `origin` field and the three backend guards keep them apart. A future import path that forgets the guard would mix them, so the guards get their own tests. +- Snapshots are copies of the node list. A view with many versions grows the register. Versions are only saved on an explicit action, not on every save. diff --git a/openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md b/openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md new file mode 100644 index 000000000..68c2f3fc9 --- /dev/null +++ b/openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md @@ -0,0 +1,127 @@ +# architecture-views-editor specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-views-editor + +## Purpose + +An application owner draws ArchiMate views in stackiq instead of only importing them from Archi. Views carry tags, a status and an owner, so a municipality finds its views again, and saved versions can be compared to see what changed. Imported GEMMA views stay as VNG publishes them. Data lives in OpenRegister's AMEF register (ADR-001), the canvas is `CnGraphCanvas` (ADR-012), and the status lifecycle is declared on the schema (ADR-031). + +## ADDED Requirements + +### Requirement: REQ-AVE-001 An application owner SHALL draw and save an architecture view + +Stackiq SHALL offer a view editor at `/views/:id` (page `ViewEditor`) on `CnGraphCanvas`. An application owner SHALL be able to create a view, place existing AMEF elements and new elements of an ArchiMate element type, connect two elements with an ArchiMate relation type, move and resize nodes, and save. The saved view SHALL be a `view` object in the `vng-gemma` register with `origin` set to `drawn`, and its diagram SHALL be stored in `xml.viewNodes` and `xml.viewRelationships` in the shape the ArchiMate import writes, so `ViewService` reads it unchanged. A new element SHALL be saved as an `element` object with `origin` set to `drawn`. + +#### Scenario: An application owner draws a view and opens it again +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** an application owner signed in to stackiq +- **WHEN** they open the Views page, choose New view, place two application components, connect them with a flow relation and save +- **THEN** the Views page SHALL list the view with status draft and origin drawn +- **AND** opening it again SHALL show both nodes at the saved positions and the connection between them + +#### Scenario: A saved drawn view reads like an imported one +@e2e exclude The shape is not visible in the browser; tests/vitest/viewGraph.spec.js round-trips a drawn canvas and an imported GEMMA view through the store's save shape, and tests/Unit/Service/ViewServiceDrawnViewTest.php reads a drawn view through ViewService::transformView. + +- **GIVEN** a drawn view saved by the editor +- **WHEN** `ViewService::transformView` reads it +- **THEN** every node SHALL carry `identifier`, `position` and `elementRef` as it does for an imported view +- **AND** every child node SHALL keep its `parent` + +### Requirement: REQ-AVE-002 Imported GEMMA views SHALL open read-only and SHALL be copied before editing + +A view with `origin` set to `imported` SHALL open in the editor without edit controls. Its Copy to edit action SHALL create a new `view` with a fresh identifier, `origin` set to `drawn`, `status` set to `draft` and `basedOn` set to the source view's uuid, and SHALL open the copy in edit mode. A later ArchiMate import SHALL NOT change a drawn view. + +#### Scenario: A municipal information manager copies a GEMMA view to adapt it +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** an imported GEMMA view on the Views page +- **WHEN** a municipal information manager opens it +- **THEN** the canvas SHALL show no edit controls and SHALL offer Copy to edit +- **AND** choosing Copy to edit SHALL open a new drawn view whose Based on field names the GEMMA view + +#### Scenario: A re-import leaves a drawn copy alone +@e2e exclude An import needs a GEMMA model file and minutes of runtime; tests/Unit/Service/ArchitectureViewsImportIsolationTest.php imports a view whose identifier differs from the copy's and asserts the copy's nodes are unchanged. + +- **GIVEN** a drawn copy of a GEMMA view +- **WHEN** a Nextcloud admin imports the GEMMA model again +- **THEN** the imported view SHALL be updated +- **AND** the drawn copy SHALL keep its nodes, connections and status + +### Requirement: REQ-AVE-003 A drawn connection SHALL reference a relation object + +When the user connects two elements, the editor SHALL reuse a `relation` object of the chosen type between the same source and target, or SHALL create one with `origin` set to `drawn`. The connection SHALL store that relation's id as `modelRelationshipId`, so the ArchiMate export writes a valid relationship reference. + +#### Scenario: Connecting the same two elements twice reuses one relation +@e2e exclude The relation lookup is a store concern; tests/vitest/architectureViewStore.spec.js asserts one relation is created for the first connection and reused for the second. + +- **GIVEN** a drawn view with a flow connection from Zaaksysteem to Documentbeheer +- **WHEN** the application owner draws a second flow connection between the same two elements on another view +- **THEN** no second `relation` object SHALL be created +- **AND** both connections SHALL carry the same `modelRelationshipId` + +### Requirement: REQ-AVE-004 The views list SHALL filter on tag, status and owner + +The `Views` page at `/views` SHALL be a `CnIndexPage` over the `view` schema with columns name, viewpoint, status, tags and origin. `tags` SHALL be a facetable list of strings, `status` a facetable enum of draft, in review, published and retired, and `origin` a facetable enum of imported and drawn. The page SHALL offer the quick filters All, Mine, Drawn and Imported, where Mine filters on the signed-in user as owner. The status transitions SHALL be declared as `x-openregister-lifecycle` on the `view` schema. + +#### Scenario: A municipal information manager finds views by tag +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** two drawn views, one tagged zaakgericht and one tagged financien +- **WHEN** a municipal information manager selects the tag zaakgericht in the Views sidebar +- **THEN** the list SHALL show only the view tagged zaakgericht + +#### Scenario: Mine shows only the signed-in user's views +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** a drawn view owned by the signed-in application owner and one owned by a colleague +- **WHEN** the application owner chooses the quick filter Mine +- **THEN** the list SHALL show only their own view + +#### Scenario: A published view moves through the declared lifecycle +@e2e exclude The transition engine is OpenRegister's; tests/Unit/Service/ArchitectureViewsRegisterShapeTest.php asserts every lifecycle from and to value is a member of the status enum and the view schema version is bumped. + +- **GIVEN** a drawn view in status in review +- **WHEN** the owner applies the publish transition +- **THEN** the view SHALL read published +- **AND** the transition SHALL appear in the view's History tab + +### Requirement: REQ-AVE-005 An owner SHALL save named versions and compare two of them + +The editor SHALL offer Save version, which SHALL write a `view-version` object with the view's uuid, the next version number, a label, the saving user, the time and a copy of the view's nodes and connections. Compare versions SHALL let the user pick two versions, or one version and the current view, and SHALL show both on read-only canvases with added items marked in the success colour, removed items in the error colour and changed items in the warning colour, next to a text list of the same changes. A node SHALL count as changed when its name, element, parent, position, size or style differs. + +#### Scenario: An application owner sees what changed since the last version +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** a drawn view with a saved version 1 holding two nodes +- **WHEN** the application owner adds a third node, saves, and compares version 1 with the current view +- **THEN** the third node SHALL be marked as added on the current side +- **AND** the text list SHALL read one added node, no removed nodes and no changed nodes + +#### Scenario: A reordered node list is not a change +@e2e exclude A pure function; tests/vitest/viewDiff.spec.js asserts that two snapshots with the same nodes in a different order give no added, removed or changed entries. + +- **GIVEN** two snapshots with the same nodes in a different order +- **WHEN** `viewDiff` compares them +- **THEN** it SHALL report no added, removed or changed nodes + +### Requirement: REQ-AVE-006 Drawn views SHALL stay inside the organisation that drew them + +Drawn views, elements and relations SHALL be scoped to the organisation that created them. `GET /api/views` SHALL return only views whose `origin` is empty or `imported`, because its list is cached for all callers. `GET /api/views/{viewId}` SHALL answer 404 for a view whose `origin` is neither, because it reads without RBAC. The full ArchiMate export (`POST /api/archimate/export`) SHALL keep only objects whose `origin` is empty or `imported`. The editor SHALL take OpenRegister's object lock before edit mode and SHALL show who holds the lock when another user has it. + +#### Scenario: Another municipality does not see a drawn view +@e2e exclude The CI instance has one organisation; tests/Unit/Service/ViewServiceDrawnViewTest.php asserts the views query keeps only an empty or imported origin and the single view read answers 404 for a drawn view, and tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php asserts the full export skips drawn objects. + +- **GIVEN** a drawn view of municipality A +- **WHEN** a user of municipality B calls `GET /api/views` or `GET /api/views/{viewId}` with its uuid, or a Nextcloud admin runs the full ArchiMate export +- **THEN** the drawn view SHALL NOT be in the response or in the exported file + +#### Scenario: A second editor sees the lock +@e2e tests/e2e/workflows/architecture-views.spec.ts + +- **GIVEN** an application owner editing a drawn view +- **WHEN** a colleague opens the same view +- **THEN** the colleague SHALL see the view read-only with a notice naming who is editing it diff --git a/openspec/changes/architecture-views-editor/tasks.md b/openspec/changes/architecture-views-editor/tasks.md new file mode 100644 index 000000000..b26534cdf --- /dev/null +++ b/openspec/changes/architecture-views-editor/tasks.md @@ -0,0 +1,101 @@ +# Tasks: architecture-views-editor + +## Implementation tasks + +### Task 1: Register fragment for drawn views and versions +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-004-the-views-list-shall-filter-on-tag-status-and-owner +- **files**: `lib/Settings/register.d/architecture-views.json`, `tests/Unit/Service/ArchitectureViewsRegisterShapeTest.php` +- **acceptance_criteria**: + - GIVEN the merged register WHEN it loads THEN `view` has `tags`, `status`, `origin` and `basedOn`, and `element` and `relation` have `origin` + - GIVEN the merged register WHEN it loads THEN `vng-gemma` lists `view-version` with magic mapping on + - GIVEN the view lifecycle WHEN the shape test reads it THEN every `from` and `to` value is a member of the status enum + - GIVEN the fragment WHEN it is compared with development THEN `view`, `element` and `relation` carry bumped versions +- [ ] Implement +- [ ] Test (PHPUnit `ArchitectureViewsRegisterShapeTest`, `RegisterFragmentMergeTest`) + +### Task 2: Views index page and Architecture menu group +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-004-the-views-list-shall-filter-on-tag-status-and-owner +- **files**: `src/manifest.d/architecture-views.json`, `src/menu-layout.json` +- **acceptance_criteria**: + - GIVEN the effective manifest WHEN it is built THEN `Views` is an index page at `/views` over `@resolve:amef_register` and `view` with the quick filters All, Mine, Drawn and Imported + - GIVEN the effective menu WHEN it renders THEN Standards and Views sit under an Architecture group and the top-level count is unchanged +- [ ] Implement +- [ ] Test (`tests/validate-manifest.js`, Playwright `tests/e2e/workflows/architecture-views.spec.ts` tag and Mine filters) + +### Task 3: Canvas shape mapping +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-001-an-application-owner-shall-draw-and-save-an-architecture-view +- **files**: `src/utils/viewGraph.js`, `tests/vitest/viewGraph.spec.js` +- **acceptance_criteria**: + - GIVEN an imported GEMMA view WHEN it is mapped to canvas nodes and back THEN every node keeps its absolute position, size and parent + - GIVEN a canvas with a child node WHEN it is mapped to the save shape THEN the child carries its parent's `viewNodeId` +- [ ] Implement +- [ ] Test (vitest `viewGraph.spec.js`) + +### Task 4: View editor page on CnGraphCanvas +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-001-an-application-owner-shall-draw-and-save-an-architecture-view +- **files**: `src/views/architecture/ArchitectureViewEditor.vue`, `src/store/modules/architectureView.js`, `src/customComponents.js`, `src/manifest.d/architecture-views.json` +- **acceptance_criteria**: + - GIVEN the editor WHEN the user places two elements, connects them and saves THEN a `view` with `origin` drawn holds both nodes and the connection + - GIVEN a new element from the palette WHEN the view is saved THEN an `element` with `origin` drawn and the chosen ArchiMate type exists + - GIVEN a colleague holds the lock WHEN the user opens the view THEN it is read-only with a notice naming the colleague +- [ ] Implement +- [ ] Test (Playwright `architecture-views.spec.ts` draw and reopen, lock notice) + +### Task 5: Relations behind connections +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-003-a-drawn-connection-shall-reference-a-relation-object +- **files**: `src/store/modules/architectureView.js`, `tests/vitest/architectureViewStore.spec.js` +- **acceptance_criteria**: + - GIVEN no relation of the chosen type between two elements WHEN they are connected THEN one `relation` with `origin` drawn is created + - GIVEN such a relation exists WHEN they are connected again THEN it is reused + - GIVEN the view write fails after the relation write WHEN the user saves again THEN no second relation is created +- [ ] Implement +- [ ] Test (vitest `architectureViewStore.spec.js`) + +### Task 6: Read-only imported views and Copy to edit +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-002-imported-gemma-views-shall-open-read-only-and-shall-be-copied-before-editing +- **files**: `src/views/architecture/ArchitectureViewEditor.vue`, `src/store/modules/architectureView.js`, `tests/Unit/Service/ArchitectureViewsImportIsolationTest.php` +- **acceptance_criteria**: + - GIVEN an imported view WHEN it opens THEN no edit control renders and Copy to edit is offered + - GIVEN Copy to edit WHEN it completes THEN a drawn view with a fresh identifier and `basedOn` opens in edit mode + - GIVEN a re-import WHEN it runs THEN the drawn copy is unchanged +- [ ] Implement +- [ ] Test (Playwright copy flow, PHPUnit `ArchitectureViewsImportIsolationTest`) + +### Task 7: Save version and compare versions +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-005-an-owner-shall-save-named-versions-and-compare-two-of-them +- **files**: `src/utils/viewDiff.js`, `src/views/architecture/ViewVersionCompare.vue`, `src/store/modules/architectureView.js`, `tests/vitest/viewDiff.spec.js` +- **acceptance_criteria**: + - GIVEN Save version WHEN it completes THEN a `view-version` holds the next number, the label and a copy of the nodes and connections + - GIVEN two snapshots WHEN they are compared THEN added, removed and changed are keyed by node and connection id, and order alone is no change + - GIVEN the compare page WHEN it renders THEN colours use `--color-success`, `--color-error` and `--color-warning` and the text list names every change +- [ ] Implement +- [ ] Test (vitest `viewDiff.spec.js`, Playwright compare scenario) + +### Task 8: Keep drawn objects out of the shared list, the single view read and the full export +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-006-drawn-views-shall-stay-inside-the-organisation-that-drew-them +- **files**: `lib/Service/ViewService.php`, `lib/Service/ArchiMateExportService.php`, `tests/Unit/Service/ViewServiceDrawnViewTest.php`, `tests/Unit/Service/ArchiMateExportServiceDrawnFilterTest.php` +- **acceptance_criteria**: + - GIVEN a drawn and an imported view WHEN `GET /api/views` runs THEN only the imported view is returned and cached + - GIVEN a drawn view WHEN `GET /api/views/{viewId}` is called with its uuid THEN the answer is 404 + - GIVEN drawn objects WHEN the full ArchiMate export runs THEN none of them is in the file + - GIVEN a view imported before this change with no origin WHEN either reader runs THEN it is kept as imported +- [ ] Implement +- [ ] Test (PHPUnit `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest`) + +### Task 9: Documentation and translations +- **spec_ref**: openspec/changes/architecture-views-editor/specs/architecture-views-editor/spec.md#requirement-req-ave-001-an-application-owner-shall-draw-and-save-an-architecture-view +- **files**: `docs/features/architecture-views.md`, `l10n/en.json`, `l10n/nl.json` +- **acceptance_criteria**: + - GIVEN the feature page WHEN it is read THEN it shows the editor, the tag filter and a compare, each with a screenshot + - GIVEN a Dutch instance WHEN the Views page renders THEN every new string reads in Dutch +- [ ] Implement +- [ ] Test (`tests/l10n` key parity, screenshots captured with Playwright) + +## Verification + +- `openspec validate architecture-views-editor --type change --strict` +- PHPUnit: `ArchitectureViewsRegisterShapeTest`, `ArchitectureViewsImportIsolationTest`, `ViewServiceDrawnViewTest`, `ArchiMateExportServiceDrawnFilterTest` +- vitest: `viewGraph.spec.js`, `viewDiff.spec.js`, `architectureViewStore.spec.js` +- Playwright: `tests/e2e/workflows/architecture-views.spec.ts` +- Documentation in `docs/features/architecture-views.md` with screenshots (ADR-010) +- English and Dutch strings for every new label (ADR-005) diff --git a/openspec/changes/architecture-views-to-office-documents/.openspec.yaml b/openspec/changes/architecture-views-to-office-documents/.openspec.yaml new file mode 100644 index 000000000..7f2ad572a --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/architecture-views-to-office-documents/design.md b/openspec/changes/architecture-views-to-office-documents/design.md new file mode 100644 index 000000000..2c8c42c2b --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/design.md @@ -0,0 +1,56 @@ +# Design: architecture-views-to-office-documents + +Read at development 49e65cb4. Line numbers below are from that sha. The view page (`ViewEditor`, `src/views/architecture/ArchitectureViewEditor.vue`) and the guard on `ViewService::getView` come from `architecture-views-editor` (its D3 and D9). + +## Where it fits + +| Layer | Touched | Read at | +|---|---|---| +| Service | new `lib/Service/ViewImageService.php` | reads `xml.viewNodes` and `xml.viewRelationships` in the import's shape (`lib/Service/ArchiMateImportService.php:2886` to :3058, :3364) | +| Service | new `lib/Service/ViewDocumentGateway.php` | the one place stackiq talks to filinq | +| Controller and routes | `lib/Controller/ViewController.php` gains `getViewImage` and `createViewDocument`; routes `view#getViewImage` at `GET /api/views/{viewId}/image.svg` and `view#createViewDocument` at `POST /api/views/{viewId}/document`, next to `view#getView` (`appinfo/routes.php:187`) | `ViewController::getView` (:218) is the pattern | +| Initial state | `lib/AppInfo/Application.php` provides `view_document_export` next to `amef_register` (:908) | the frontend reads it with `loadState` | +| View | `src/views/architecture/ArchitectureViewEditor.vue` gains an Export menu | | +| Register, pages | none | | + +## Decisions + +### D1. Stackiq draws the picture on the server, as SVG + +`ViewImageService::renderSvg(array $view): string` walks the stored nodes and connections and writes one SVG: a rectangle per node at its absolute `x`, `y`, `width` and `height`, children inside their parents, the element name and ArchiMate type as text, and a path per connection through its bendpoints with the arrowhead of its relation type. A caption holds the view name and the date. The palette is the ArchiMate layer convention (business, application, technology, motivation, grouping) in one constant, because an exported file is read outside Nextcloud where the theme's CSS variables do not resolve (ADR-003 governs the UI, not a file). + +Rejected: capturing the canvas in the browser. `CnGraphCanvas` draws nodes as HTML and edges as SVG, the library ships no image export, and adding one would mean a DOM snapshot library whose output depends on the browser, the zoom and the theme. The stored geometry gives the same picture on every call, and filinq can take it without a browser. + +Rejected: an SVG made from the canvas's own node list. It would picture what the canvas shows after pan and zoom; the stored view is what the user saved. + +### D2. The image route reads with RBAC on + +`GET /api/views/{viewId}/image.svg` (`#[NoAdminRequired]`) reads the view through OpenRegister's `ObjectService::find` with RBAC and multitenancy on, not through `ViewService::getView`, which reads with both off (`lib/Service/ViewService.php:328`). A reader gets imported GEMMA views and the drawn views of their own organisation; any other uuid gets a 404. The response is `image/svg+xml` with a download file name built from the view name. + +Text from the view (names, the caption) is escaped before it goes into the SVG, and the SVG holds no script, no external reference and no `foreignObject`, so a view name cannot inject markup into a file that Word or a browser opens. + +### D3. Word and PowerPoint through one gateway to filinq + +`ViewDocumentGateway::isAvailable(): bool` and `requestDocument(string $format, array $content, string $userId): array` are the only code that knows filinq. `$format` is `docx` or `pptx`. `$content` holds the view name, description, viewpoint, a legend of the element types on the view, the generation date, a link back to the view page, and the SVG from D1. The result is the Files path and file id of the new document, which the frontend opens. + +The gateway consumes filinq's published document contract, resolved the way ADR-075 Decision 2 prescribes: never a container lookup of filinq internals, never a loopback HTTP call to filinq routes, never a guessed endpoint. `isAvailable` is true only when that contract resolves; "filinq is installed" is not the probe (ADR-087 Decision 5 makes the same point for office suites). When it is false, `POST /api/views/{viewId}/document` answers 503 with a message, and nothing is written. + +The contract does not exist at the shas read (proposal, Risks). The gateway is written against the contract's declared operations, render-template-with-data (ADR-075 Decision 1), and its unit tests run against a stub of that contract, so the stackiq half is done and tested when filinq publishes. + +Rejected: generating the `.docx` or `.pptx` in stackiq with a PHP office library. ADR-075 gives document generation one owner and bans a second engine in a leaf app. + +Rejected: a typed `IEventDispatcher` event that stackiq defines and filinq would have to listen for (the ADR-041 route the contract approvals use). The event class belongs to the app that owns the command; stackiq inventing one would be a phantom contract that nothing dispatches to. + +### D4. The Export menu + +The view page gets an Export menu with Download SVG, Create Word document and Create PowerPoint slide. Download SVG fetches the image route. The two document actions read the initial state `view_document_export` (provided through `IInitialState`, as `amef_register` is at `lib/AppInfo/Application.php:908`); when it is false they are disabled with the text "Word and PowerPoint need the document app filinq", so the absence is visible (ADR-075 Decision 4). After a document is made, a toast names the file and offers Open in Files. + +## Declarative versus imperative + +The change adds no lifecycle, aggregation, notification, relation or widget behaviour. It is two read-only renderers and one outbound call, all imperative by nature. + +## Risks + +- **Missing contract.** Until filinq publishes its document contract, only the SVG download works. The spec keeps the document scenarios behind the gateway's availability, and the Playwright spec covers the disabled state. +- **Large views.** A GEMMA view with a few hundred nodes makes an SVG of a few hundred kilobytes. The route streams it and sets no cache header for drawn views, which can change. +- **Fidelity.** The SVG shows boxes, names, types and arrows, not Archi's icons. The caption says the picture is drawn by stackiq, and the ArchiMate export stays the exact exchange format. diff --git a/openspec/changes/architecture-views-to-office-documents/proposal.md b/openspec/changes/architecture-views-to-office-documents/proposal.md new file mode 100644 index 000000000..4dd9b6201 --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +depends_on: + - architecture-views-editor +--- + +# Put an architecture view into a Word or PowerPoint document + +## Summary + +An application owner who looks at a view in stackiq can download it as an SVG image, or ask for a Word document or a PowerPoint slide that holds the view with its name, description, legend and date. Stackiq draws the image. The document is made by filinq, the fleet's document app, and lands in the user's Files, where the office suite opens it. When filinq is not there, the two document actions say so and the image download still works. + +## Why + +This change builds one row of the stackiq parity matrix: `stackiq:arch-views-office`, "Put architecture views into Word or PowerPoint documents straight from the tool." It comes from the Helmond architecture repository tender, https://www.tenderned.nl/aankondigingen/overzicht/398728; the matrix note reads "Helmond REQ19 asks for architecture views embedded in office documents." + +No competitor rates yes. Three rate partial: +- GEMMA Softwarecatalogus: "Download de kaart met de knop [download SVG] ... De kaart volledig schaalbaar" (https://www.softwarecatalogus.nl/Hoe%20print%20ik%20een%20kaart%3F). +- SAP LeanIX: "Using the HTML Embed Code, you can embed and have live data from the SAP LeanIX inside a tool such as Confluence and PowerPoint" (https://help.sap.com/docs/leanix/ea/using-reports), with diagrams exported as PDF, SVG and PNG (https://help.sap.com/docs/leanix/ea/importing-and-exporting-diagrams). +- BlueDolphin: "To use the image of a view, for example, in a document, you can download the view as a file in PNG, SVG, PDF" (https://help.bluedolphin.io/en/articles/11967514-download-a-view). + +The lane decided build on tender demand and a core area. + +## What stackiq has today + +- The only view exports are ArchiMate exchange files: `POST /api/archimate/export` and `GET /api/archimate/export/organization/{organizationUuid}` (`appinfo/routes.php:97-98`). No image, Word or PowerPoint output exists in `lib/` or `src/`. +- No page draws a view today; `architecture-views-editor` adds the view page on `CnGraphCanvas`. `CnGraphCanvas` in `@conduction/nextcloud-vue` 2.57.1 has no image export, and the library has no image export dependency. +- The stored view holds everything a picture needs: `xml.viewNodes` with `x`, `y`, `width`, `height`, `parent`, `name` and `type`, and `xml.viewRelationships` with source, target, type and bendpoints (`lib/Service/ArchiMateImportService.php:2886` to :3058 and :3364). +- `ViewService::getView` reads one view without RBAC (`lib/Service/ViewService.php:307`, the read at :328); `architecture-views-editor` limits that path to imported views. +- Stackiq holds no filinq integration: `grep -rn -i "docudesk\|filinq" lib src` finds only a comment in `lib/Repair/MigrateSchemaApplicationId.php:26`. + +## What this change builds + +- `lib/Service/ViewImageService.php`, which draws a view's stored geometry as a standalone SVG, and `GET /api/views/{viewId}/image.svg`, which reads the view with RBAC on. +- `lib/Service/ViewDocumentGateway.php` and `POST /api/views/{viewId}/document`, which hand the SVG and the view's text to filinq for a Word or PowerPoint file in the user's Files. +- An Export menu on the view page with Download SVG, Create Word document and Create PowerPoint slide, with the last two disabled and explained when filinq cannot take the request. + +## Out of scope + +- Making the Word or PowerPoint file. That is filinq's (ADR-075, ADR-087): its template rendering, its office format codec and its conversions. This change needs filinq's published document contract (ADR-075 Decision 1) and does not define it. +- Inserting a view into a document that is already open in the office suite. ADR-087 Decision 4 allows that only as a suite-specific extra behind a probe. +- PNG and PDF downloads. Word and PowerPoint read SVG, and filinq can convert when a template needs a bitmap. +- Live embedding that updates when the view changes, as LeanIX offers. + +## Risks + +- filinq's document contract does not exist yet at the shas read: ADR-075 is Proposed, OpenRegister 4fee776 has no capability registry (`grep -rn "pdf-export" openregister-ro/lib` finds nothing), and `@conduction/nextcloud-vue` 2.57.1 has no `CnIntegrationGate`. The SVG download ships on its own; the two document actions stay disabled with a notice until the contract lands. This is the sibling half the change assumes. +- filinq's documented backends are Mpdf and PhpWord (ADR-075 Context). A PowerPoint file needs a presentation writer or a conversion through `IConversionManager` (ADR-087 Decision 1), which filinq has to confirm. diff --git a/openspec/changes/architecture-views-to-office-documents/specs/architecture-view-office-export/spec.md b/openspec/changes/architecture-views-to-office-documents/specs/architecture-view-office-export/spec.md new file mode 100644 index 000000000..fdec9be8b --- /dev/null +++ b/openspec/changes/architecture-views-to-office-documents/specs/architecture-view-office-export/spec.md @@ -0,0 +1,77 @@ +# architecture-view-office-export specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-views-to-office-documents + +## Purpose + +An application owner takes an architecture view out of stackiq into a document: as an SVG image they can place anywhere, or as a Word document or PowerPoint slide made for them. Stackiq draws the image from the stored view. The document is made by filinq, the fleet's document app (app id docudesk), through its published contract (ADR-075, ADR-087); stackiq never generates office files itself. + +## ADDED Requirements + +### Requirement: REQ-AVO-001 Stackiq SHALL draw a view as a standalone SVG from its stored geometry + +`GET /api/views/{viewId}/image.svg` SHALL return an SVG image of the view: every node at its stored position and size with its name and ArchiMate type, children inside their parents, every connection along its bendpoints with the arrowhead of its relation type, and a caption with the view name and date. It SHALL read the view with OpenRegister RBAC and multitenancy on and SHALL answer 404 for a view the caller may not read. Every text taken from the view SHALL be escaped, and the SVG SHALL contain no script, no external reference and no `foreignObject`. + +#### Scenario: An application owner downloads a view as SVG +@e2e tests/e2e/workflows/architecture-view-export.spec.ts + +- **GIVEN** the seeded drawn view Zaakgericht werken, huidige situatie +- **WHEN** an application owner opens it and chooses Export, then Download SVG +- **THEN** the browser SHALL receive an SVG file named after the view +- **AND** the file SHALL hold the text Zaaksysteem, Documentbeheer and Zaakregistratie + +#### Scenario: A view name cannot inject markup +@e2e exclude A rendering rule; tests/Unit/Service/ViewImageServiceTest.php renders a view named with a script tag and asserts the output escapes it and contains no script, external href or foreignObject. + +- **GIVEN** a drawn view whose name holds ` + + diff --git a/src/components/contracts/ApplicationContractsPanel.vue b/src/components/contracts/ApplicationContractsPanel.vue new file mode 100644 index 000000000..ff8955aeb --- /dev/null +++ b/src/components/contracts/ApplicationContractsPanel.vue @@ -0,0 +1,193 @@ + + + + + + diff --git a/src/components/contracts/ContractSeatsPanel.vue b/src/components/contracts/ContractSeatsPanel.vue new file mode 100644 index 000000000..fe92a9eb4 --- /dev/null +++ b/src/components/contracts/ContractSeatsPanel.vue @@ -0,0 +1,254 @@ + + + + + + diff --git a/src/components/maintenance/UpcomingMaintenanceWidget.vue b/src/components/maintenance/UpcomingMaintenanceWidget.vue new file mode 100644 index 000000000..9334b9049 --- /dev/null +++ b/src/components/maintenance/UpcomingMaintenanceWidget.vue @@ -0,0 +1,234 @@ + + + + + + diff --git a/src/components/portfolio/UsageRiskSignals.vue b/src/components/portfolio/UsageRiskSignals.vue new file mode 100644 index 000000000..424cf2586 --- /dev/null +++ b/src/components/portfolio/UsageRiskSignals.vue @@ -0,0 +1,231 @@ + + + + + + diff --git a/src/components/portfolio/ValueFitPlot.vue b/src/components/portfolio/ValueFitPlot.vue new file mode 100644 index 000000000..de6b28eda --- /dev/null +++ b/src/components/portfolio/ValueFitPlot.vue @@ -0,0 +1,296 @@ + + + + + + diff --git a/src/components/roadmap/ProductRoadmap.vue b/src/components/roadmap/ProductRoadmap.vue new file mode 100644 index 000000000..044b20f23 --- /dev/null +++ b/src/components/roadmap/ProductRoadmap.vue @@ -0,0 +1,185 @@ + + + + + + diff --git a/src/customComponents.js b/src/customComponents.js index dac0c48d3..02650884e 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -17,10 +17,16 @@ // - openspec/changes/stackiq-manifest-v1/design.md // - @conduction/nextcloud-vue → docs/migrating-to-manifest.md +import { generateUrl } from '@nextcloud/router' +import AiActChecklist from './components/ai/AiActChecklist.vue' import OrganisatieCard from './components/cards/OrganisatieCard.vue' +import ApplicationContractsPanel from './components/contracts/ApplicationContractsPanel.vue' import ContractApprovalPanel from './components/contracts/ContractApprovalPanel.vue' +import ContractSeatsPanel from './components/contracts/ContractSeatsPanel.vue' import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue' +import UsageRiskSignals from './components/portfolio/UsageRiskSignals.vue' import ReviewsPanel from './components/reviews/ReviewsPanel.vue' +import ProductRoadmap from './components/roadmap/ProductRoadmap.vue' import SbomComponentsPanel from './components/sbom/SbomComponentsPanel.vue' import VulnerabilityExposurePanel from './components/vulnerabilities/VulnerabilityExposurePanel.vue' import ComplianceMatrixView from './views/ComplianceMatrixView.vue' @@ -31,8 +37,19 @@ import LifecycleRoadmapView from './views/LifecycleRoadmapView.vue' import PortfolioReportView from './views/organisaties/PortfolioReport.vue' import StackiqSettingsPage from './views/settings/StackiqSettings.vue' import SuitesIndexView from './views/suites/SuitesIndexView.vue' +import { createConnectionHandlers } from './services/connectionRegistry.js' export default { + // Header-action handler: the Integrations page's Add integration + // (adopt-connection-registry). A FUNCTION, because it leaves the app for + // integriq's Connections overview and a header action's `navigate` only + // pushes a route inside this app. CnIndexPage resolves a handler name + // against this map. + ...createConnectionHandlers({ + generateUrl, + assign: (url) => window.location.assign(url), + }), + // OrganisatieCard — the bespoke card (inline contactpersoon toggle) used as // the `cardComponent` of the now-decomposed Organisaties type='index' page // (Phase 8). CnIndexPage's cardComponent config closed the prior lib gap. @@ -67,8 +84,30 @@ export default { // integration registry and projected back onto the contract; stackiq // owns no approval workflow. Stays a custom tab component because it surfaces // a cross-app outcome no built-in detail widget expresses. + // The contracts behind an application, a bodyWidgets section on ModuleDetail + // (landscape-application-page): two hops, so not an object-list widget. + ApplicationContractsPanel, + ContractApprovalPanel, + // Licences in use against licences bought (contracts-licence-seats). + ContractSeatsPanel, + + // The supplier's roadmap statement and the product's versions on a timeline, + // a bodyWidgets section on ModuleDetail (lifecycle-maintenance-and-supplier-roadmap): + // it reads the module and its versions, which no built-in widget combines. + ProductRoadmap, + + // End of support of the version a usage runs and the vulnerabilities of its + // application, next to the risk score on the usage page + // (lifecycle-application-value-assessment): it joins the usage, its version + // and the vulnerabilities, which no built-in widget does. + UsageRiskSignals, + + // AI Act evidence per tag on the AI system page (landscape-ai-system-inventory): + // it reads the object's files and their tags, which no built-in widget lists per tag. + AiActChecklist, + // --- Admin-triggered organisation-merge (VNG Softwarecatalogus #141). --- // Dry-run preview + confirm dialog + execute for folding a source // organisation into a target (gemeentelijke herindeling / diff --git a/src/formatters.js b/src/formatters.js new file mode 100644 index 000000000..66fcd17e9 --- /dev/null +++ b/src/formatters.js @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +// +// App cell formatters, handed to CnAppRoot as `formatters` and resolved by +// name from a manifest column's `formatter`. CnAppRoot spreads these OVER the +// library built-ins, so a name here must never equal a built-in's name: +// tests/vitest/connectionRegistry.spec.js holds that. + +import { friaStatus } from './utils/aiAct.js' + +export default { + // The FRIA column of the AI systems list (landscape-ai-system-inventory). + friaStatus, +} diff --git a/src/icons.js b/src/icons.js index f13e72b28..865c514c0 100644 --- a/src/icons.js +++ b/src/icons.js @@ -21,6 +21,7 @@ import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' import BookOpenVariant from 'vue-material-design-icons/BookOpenVariant.vue' import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue' import BriefcaseOutline from 'vue-material-design-icons/BriefcaseOutline.vue' +import Calendar from 'vue-material-design-icons/Calendar.vue' import ChartBar from 'vue-material-design-icons/ChartBar.vue' import ChartBoxOutline from 'vue-material-design-icons/ChartBoxOutline.vue' import ChartLine from 'vue-material-design-icons/ChartLine.vue' @@ -53,7 +54,10 @@ import OfficeBuildingOutline from 'vue-material-design-icons/OfficeBuildingOutli import Package from 'vue-material-design-icons/Package.vue' import PackageVariant from 'vue-material-design-icons/PackageVariant.vue' import PackageVariantClosed from 'vue-material-design-icons/PackageVariantClosed.vue' +import PowerPlugOutline from 'vue-material-design-icons/PowerPlugOutline.vue' import PuzzleOutline from 'vue-material-design-icons/PuzzleOutline.vue' +import RobotOutline from 'vue-material-design-icons/RobotOutline.vue' +import ScaleBalance from 'vue-material-design-icons/ScaleBalance.vue' import ShieldAlert from 'vue-material-design-icons/ShieldAlert.vue' import ShieldAlertOutline from 'vue-material-design-icons/ShieldAlertOutline.vue' import ShieldCheckOutline from 'vue-material-design-icons/ShieldCheckOutline.vue' @@ -79,6 +83,7 @@ export default { BookOpenVariant, BookOpenVariantOutline, BriefcaseOutline, + Calendar, ChartBar, ChartBoxOutline, ChartLine, @@ -111,7 +116,10 @@ export default { Package, PackageVariant, PackageVariantClosed, + PowerPlugOutline, PuzzleOutline, + RobotOutline, + ScaleBalance, ShieldAlert, ShieldAlertOutline, ShieldCheckOutline, diff --git a/src/main.js b/src/main.js index 3a27436eb..0e6041f91 100644 --- a/src/main.js +++ b/src/main.js @@ -30,6 +30,7 @@ import { createApp, h } from 'vue' import { createRouter, createWebHistory } from 'vue-router' import App from './App.vue' import CatalogPanels from './components/CatalogPanels.vue' +import UpcomingMaintenanceWidget from './components/maintenance/UpcomingMaintenanceWidget.vue' import customComponents from './customComponents.js' import appIcons from './icons.js' import bundledManifest from './manifest.json' @@ -72,6 +73,16 @@ registerDashboardWidget('catalog-panels', { icon: 'DatabaseOutline', card: true, }) +// Planned maintenance on the applications the active organisation uses +// (lifecycle-maintenance-and-supplier-roadmap): it joins the organisation's +// usages to the maintenance windows of their applications. +registerDashboardWidget('upcoming-maintenance', { + renderer: UpcomingMaintenanceWidget, + defaultContent: {}, + displayName: 'Planned maintenance', + icon: 'Calendar', + card: true, +}) try { registerTranslations() } catch (e) { diff --git a/src/manifest.d/ai-systems.json b/src/manifest.d/ai-systems.json new file mode 100644 index 000000000..3f8e3b611 --- /dev/null +++ b/src/manifest.d/ai-systems.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "_note": "landscape-ai-system-inventory: the aiSystem schema in lib/Settings/softwarecatalogus_register.json. A menu child of Applications (ADR-097). The High risk without FRIA quick filter sends friaDocumentRef=IS NULL, which OpenRegister's property filter reads as a null check; the FRIA column uses the app formatter friaStatus (src/utils/aiAct.js), so the list warns on the same rule.", + "menu": [ + { + "id": "Modules", + "children": [ + { "id": "AiSystems", "label": "AI systems", "icon": "RobotOutline", "route": "AiSystems", "order": 11 } + ] + } + ], + "pages": [ + { + "id": "AiSystems", + "route": "/ai-systems", + "type": "index", + "title": "AI systems", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "aiSystem", + "description": "The AI agents, AI models and AI features your organisation uses, with their EU AI Act risk category.", + "columns": [ + "name", + "kind", + "module", + "aiActRiskCategory", + "status", + { "key": "friaDocumentRef", "label": "FRIA", "formatter": "friaStatus", "widget": "badge" } + ], + "filterMenu": true, + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "High risk", "filter": { "aiActRiskCategory": "high risk" }, "icon": "ShieldAlert" }, + { "label": "High risk without FRIA", "filter": { "aiActRiskCategory": "high risk", "friaDocumentRef": "IS NULL" }, "icon": "AlertCircle" }, + { "label": "Limited risk", "filter": { "aiActRiskCategory": "limited risk" } }, + { "label": "Minimal risk", "filter": { "aiActRiskCategory": "minimal risk" } }, + { "label": "Not yet assessed", "filter": { "aiActRiskCategory": "not yet assessed" } }, + { "label": "Prohibited", "filter": { "aiActRiskCategory": "prohibited" } } + ], + "sidebar": { "enabled": true, "showMetadata": true }, + "documentationUrl": "https://stackiq.conduction.nl" + } + }, + { + "id": "AiSystemDetail", + "route": "/ai-systems/:id", + "type": "detail", + "title": "AI system", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "aiSystem", + "_note": "Data 8 wide with the AI Act fields, documents 4 wide (the four evidence tags), the related panel, then the evidence checklist as a body widget. Status transitions come from the schema's x-openregister-lifecycle (release, withdraw).", + "lifecycleActions": { "field": "status" }, + "widgets": [ + { "id": "ai-data", "type": "data", "title": "AI system", "icon": "RobotOutline", "content": { "columns": 2, "include": [ "name", "kind", "module", "provider", "purpose", "status", "aiActRiskCategory", "aiActRole", "assessedOn", "friaDocumentRef", "algorithmRegisterUrl", "description" ] } }, + { "id": "ai-files", "type": "integration", "integrationId": "files", "title": "Documents", "icon": "FolderOutline" }, + { "id": "ai-related", "type": "related", "title": "Application and supplier", "icon": "LinkVariant" } + ], + "layout": [ + { "id": "1", "widgetId": "ai-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 8 }, + { "id": "2", "widgetId": "ai-files", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }, + { "id": "3", "widgetId": "ai-related", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 4 } + ], + "bodyWidgets": [ + { "id": "ai-checklist", "component": "AiActChecklist", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 } + ], + "sidebar": { + "enabled": true, + "showMetadata": true, + "tabs": [ + { "id": "audit", "label": "History", "icon": "History", "widgets": [ { "type": "audit" } ] } + ] + }, + "documentationUrl": "https://stackiq.conduction.nl" + } + } + ] +} diff --git a/src/manifest.d/connection-registry.json b/src/manifest.d/connection-registry.json new file mode 100644 index 000000000..1b2f5e283 --- /dev/null +++ b/src/manifest.d/connection-registry.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "_note": "adopt-connection-registry (hydra connection-registry D8, hydra#667, hydra#673 and hydra#674). The rows are integriq's `app_connection` objects, synced from lib/Settings/connections.json; integriq works out each status. The app=stackiq preset is the menu entry's `query` (ADR-097 decision 5), which the index page merges into the fetch as a bare filter key. `showAdd` is false because a row nothing declared has nothing to check (D9). Add integration leaves for integriq's overview through the openIntegriqConnections handler in src/customComponents.js, because a header action's `navigate` only pushes a route inside this app. This page lists integriq's `app_connection`, never stackiq's own softwarecatalogus `connection` schema.", + "menu": [ + { + "id": "IntegrationsMenu", + "label": "Integrations", + "icon": "PowerPlugOutline", + "route": "Integrations", + "query": { + "app": "stackiq" + }, + "section": "settings", + "order": 98, + "permission": "admin", + "visibleIf": { + "appInstalled": "integriq" + } + } + ], + "pages": [ + { + "id": "Integrations", + "route": "/settings/integrations", + "type": "index", + "title": "Integrations", + "permission": "admin", + "requiresApp": { + "id": "integriq", + "name": "Integriq" + }, + "config": { + "register": "integriq", + "schema": "app_connection", + "showViewAction": false, + "showAdd": false, + "headerActions": [ + { + "id": "add-integration", + "label": "Add integration", + "icon": "PowerPlugOutline", + "handler": "openIntegriqConnections" + } + ], + "defaultSort": { + "field": "order", + "direction": "asc" + }, + "columns": [ + { + "key": "title", + "label": "Connection" + }, + { + "key": "status", + "label": "Status", + "formatter": "connectionStatus" + }, + { + "key": "statusMessage", + "label": "Status message", + "sortable": false + }, + { + "key": "checkedAt", + "label": "Last checked" + }, + { + "key": "settingsUrl", + "label": "Settings", + "sortable": false, + "formatter": "connectionSettingsLabel", + "widget": "link", + "widgetProps": { + "href": "{settingsUrl}" + } + } + ], + "folderSidebar": { + "source": "field", + "field": "status", + "allLabel": "All connections" + } + } + } + ] +} diff --git a/src/manifest.d/connections.json b/src/manifest.d/connections.json new file mode 100644 index 000000000..2e9072796 --- /dev/null +++ b/src/manifest.d/connections.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "_note": "connections-catalogue-pages: stackiq's own softwarecatalogus `connection` schema (koppelingen between applications, or to a national provision), which no page showed before. Not integriq's `app_connection`, which the Integrations page lists. The menu entry is a child of Applications (ADR-097: no new top-level entry). Status and type are enums and facetable, so filterMenu offers them as column filters; the status quick filters follow Contracten.", + "menu": [ + { + "id": "Modules", + "children": [ + { "id": "Koppelingen", "label": "Connections", "icon": "TransitConnectionVariant", "route": "Koppelingen", "order": 10 } + ] + } + ], + "pages": [ + { + "id": "Koppelingen", + "route": "/koppelingen", + "type": "index", + "title": "Connections", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "connection", + "description": "Every connection between applications, and from an application to a national provision.", + "columns": ["name", "type", "status", "moduleA", "moduleB", "nonMunicipalProvision", "dataExchangeDirection"], + "filterMenu": true, + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "In use", "filter": { "status": "in use" }, "icon": "CheckCircle" }, + { "label": "In development", "filter": { "status": "in development" } }, + { "label": "End of support", "filter": { "status": "end of support" }, "icon": "AlertCircle" }, + { "label": "Withdrawn", "filter": { "status": "withdrawn" } } + ], + "sidebar": { "enabled": true, "showMetadata": true }, + "documentationUrl": "https://stackiq.conduction.nl" + } + }, + { + "id": "KoppelingDetail", + "route": "/koppelingen/:id", + "type": "detail", + "title": "Connection", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "connection", + "_note": "A connection is read for what it links and how: data 8 wide (name, type, status, direction, both applications, the national provision, the intermediary, the lifecycle dates), documents 4 wide at the right, then the related panel with the standards. Status transitions come from the schema's x-openregister-lifecycle (release, sunset, withdraw).", + "lifecycleActions": { "field": "status" }, + "widgets": [ + { "id": "kp-data", "type": "data", "title": "Connection", "icon": "LinkVariant", "content": { "columns": 2, "include": [ "name", "type", "status", "dataExchangeDirection", "moduleA", "moduleB", "nonMunicipalProvision", "realisedWithIntermediaryModule", "shortDescription", "longDescription", "dateInDevelopment", "dateInUse", "dateEndSupport", "dateWithdrawn" ] } }, + { "id": "kp-files", "type": "integration", "integrationId": "files", "title": "Documents", "icon": "FolderOutline" }, + { "id": "kp-related", "type": "related", "title": "Applications and standards", "icon": "LinkVariant" } + ], + "layout": [ + { "id": "1", "widgetId": "kp-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 8 }, + { "id": "2", "widgetId": "kp-files", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }, + { "id": "3", "widgetId": "kp-related", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 4 } + ], + "sidebar": { + "enabled": true, + "showMetadata": true, + "tabs": [ + { "id": "audit", "label": "History", "icon": "History", "widgets": [ { "type": "audit" } ] } + ] + }, + "documentationUrl": "https://stackiq.conduction.nl" + } + } + ] +} diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json new file mode 100644 index 000000000..fac1de13e --- /dev/null +++ b/src/manifest.d/usages.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "_note": "landscape-usage-registration: the usage (gebruik) is an organisation's use of an application, with the version it runs, its lifecycle status and its business and technical owner. The data existed but no page created or edited it. The menu entry is a child of Applications (ADR-097: no new top-level entry). Status is an enum and facetable, so the quick filters and filterMenu work on it; the transitions come from the schema's x-openregister-lifecycle (plan, goLive, phaseOut, retire).", + "menu": [ + { + "id": "Modules", + "children": [ + { "id": "Gebruik", "label": "Applications in use", "icon": "OfficeBuilding", "route": "Gebruik", "order": 9 } + ] + } + ], + "pages": [ + { + "id": "Gebruik", + "route": "/gebruik", + "type": "index", + "title": "Applications in use", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "usage", + "description": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", + "columns": ["module", "moduleVersion", "status", "businessOwner", "technicalOwner", "timeClassification"], + "filterMenu": true, + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "Acquisition", "filter": { "status": "Acquisition" } }, + { "label": "Planned", "filter": { "status": "Planned" } }, + { "label": "In production", "filter": { "status": "In production" }, "icon": "CheckCircle" }, + { "label": "To be phased out", "filter": { "status": "To be phased out" }, "icon": "AlertCircle" }, + { "label": "Phased out", "filter": { "status": "Phased out" } } + ], + "sidebar": { "enabled": true, "showMetadata": true }, + "documentationUrl": "https://stackiq.conduction.nl" + } + }, + { + "id": "GebruikDetail", + "route": "/gebruik/:id", + "type": "detail", + "title": "Application in use", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "usage", + "_note": "A usage is read for what runs where and who owns it (lifecycle-application-value-assessment added the value assessment section with the scores and the suggested TIME class, and the risk signals after it): data 8 wide (application, organisation, version, status, owners, phase dates, cloud model, annotation), documents 4 wide at the right (DPIA, contract, processing agreement), then the related panel. Status transitions come from the schema's x-openregister-lifecycle.", + "lifecycleActions": { "field": "status" }, + "widgets": [ + { "id": "gb-data", "type": "data", "title": "Application in use", "icon": "OfficeBuilding", "content": { "columns": 2, "include": [ "module", "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner", "startDateAcquisition", "startDatePlanned", "startDateInProduction", "startDateOutPhasing", "startDateOutPhased", "cloudDienstverleningsmodel", "interneAnnotation" ] } }, + { "id": "gb-assessment", "type": "data", "title": "Value assessment", "icon": "ScaleBalance", "content": { "columns": 2, "include": [ "businessValue", "technicalFit", "riskScore", "scoredOn", "timeClassification", "suggestedTimeClassification", "timeRationale", "timeReviewDate" ] } }, + { "id": "gb-files", "type": "integration", "integrationId": "files", "title": "Documents", "icon": "FolderOutline" }, + { "id": "gb-related", "type": "related", "title": "Connections and services", "icon": "LinkVariant" } + ], + "layout": [ + { "id": "1", "widgetId": "gb-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 8 }, + { "id": "2", "widgetId": "gb-files", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }, + { "id": "3", "widgetId": "gb-related", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 4 }, + { "id": "4", "widgetId": "gb-assessment", "gridX": 0, "gridY": 8, "gridWidth": 8, "gridHeight": 5 } + ], + "bodyWidgets": [ + { "id": "gb-risk-signals", "component": "UsageRiskSignals", "props": { "objectId": "@objectId" }, "placement": "after-data", "colSpan": 12 } + ], + "sidebar": { + "enabled": true, + "showMetadata": true, + "tabs": [ + { "id": "audit", "label": "History", "icon": "History", "widgets": [ { "type": "audit" } ] } + ] + }, + "documentationUrl": "https://stackiq.conduction.nl" + } + } + ] +} diff --git a/src/manifest.json b/src/manifest.json index 9a614ce65..d690db613 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -329,6 +329,12 @@ "type": "catalog-panels", "title": "Object statistics", "_note": "The management info-box and the two per-object-type statistics tables, moved out of the former hand-written Dashboard view into src/components/CatalogPanels.vue. Registered as a widget TYPE via registerDashboardWidget() in main.js: CnDashboardPage resolves a widget's type against the LIBRARY catalog, not the app registry, and an unregistered type renders 'Widget not available' silently." + }, + { + "id": "upcoming-maintenance", + "type": "upcoming-maintenance", + "title": "Planned maintenance", + "_note": "lifecycle-maintenance-and-supplier-roadmap: planned maintenance in the next 30 days on the applications the active organisation uses. A widget TYPE registered in main.js, like catalog-panels, because it joins the organisation's usages to the maintenance windows of their applications, which no built-in widget does." } ], "layout": [ @@ -368,11 +374,19 @@ "gridHeight": 2, "showTitle": false }, + { + "id": "6", + "widgetId": "upcoming-maintenance", + "gridX": 0, + "gridY": 2, + "gridWidth": 12, + "gridHeight": 4 + }, { "id": "5", "widgetId": "catalog-panels", "gridX": 0, - "gridY": 2, + "gridY": 6, "gridWidth": 12, "gridHeight": 8, "showTitle": false, @@ -415,6 +429,7 @@ { "id": "org-stats-contact-persons", "type": "stats-block", "title": "Contact persons", "icon": "ChartBar", "content": { "entries": [ { "title": "Contact persons", "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "metric": "count", "filter": { "organization": "@objectId" } } ] } }, { "id": "org-diensten", "type": "object-list", "title": "Services", "icon": "HandshakeOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "catalogService", "filter": { "provider": "@objectId" }, "columns": [ { "key": "type", "label": "Type" } ], "limit": 25, "emptyText": "No services registered for this organisation yet" } }, { "id": "org-modules", "type": "object-list", "title": "Applications", "icon": "Package", "content": { "register": "@resolve:voorzieningen_register", "schema": "module", "filter": { "provider": "@objectId" }, "columns": [ { "key": "licentietype", "label": "License type" }, { "key": "bbnLevel", "label": "BBN level" } ], "limit": 25, "rowRoute": "ModuleDetail", "emptyText": "No applications registered for this organisation yet" } }, + { "id": "org-usages", "type": "object-list", "title": "Applications in use", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "consumer": "@objectId" }, "columns": [ { "key": "module", "label": "Application" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "consumer": "@objectId" }, "allowCreate": false, "emptyText": "No applications in use recorded for this organisation yet" } }, { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } } ], "layout": [ @@ -424,7 +439,8 @@ { "id": "8", "widgetId": "org-stats-contact-persons", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 2 }, { "id": "3", "widgetId": "org-diensten", "gridX": 0, "gridY": 6, "gridWidth": 6, "gridHeight": 4 }, { "id": "4", "widgetId": "org-modules", "gridX": 6, "gridY": 6, "gridWidth": 6, "gridHeight": 4 }, - { "id": "5", "widgetId": "org-contactpersonen", "gridX": 0, "gridY": 10, "gridWidth": 12, "gridHeight": 4 } + { "id": "5", "widgetId": "org-contactpersonen", "gridX": 0, "gridY": 10, "gridWidth": 12, "gridHeight": 4 }, + { "id": "9", "widgetId": "org-usages", "gridX": 0, "gridY": 14, "gridWidth": 12, "gridHeight": 4 } ], "bodyWidgets": [ { "id": "org-merge", "component": "OrganisationMergePanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 } @@ -497,20 +513,32 @@ "schema": "module", "_note": "bio-compliance-assessment: catalog archetype (an offered application). Body: application data 8-wide top-left (naam, descriptions, website, contactpersoon, hosting/licence fields, then the new BBN level and DPIA status/dates/document-reference/verwerkingsregister fields) with a Documentation files panel 4-wide top-right. Below, a Related panel surfaces the vendor (aanbieder) and linked services/couplings, then two object-lists: compliance claims (compliancy records — both GEMMA standards and, since this change, BIO measures — filtered by module=@objectId) and application versions (moduleVersie filtered by module=@objectId). An application does not communicate, so per the comms hard-rule NO Emails/Meetings widgets appear. Audit trail stays a sidebar tab.", "widgets": [ - { "id": "md-data", "type": "data", "title": "Application", "icon": "Package", "content": { "columns": 2, "include": [ "name", "beschrijvingKort", "beschrijvingLang", "website", "provider", "contactpersoon", "cloudDienstverleningsmodel", "hostingJurisdiction", "hostingLocation", "licentietype", "licence", "bbnLevel", "dpiaStatus", "dpiaDate", "dpiaNextAssessment", "dpiaDocumentRef", "verwerkingsregisterRef" ] } }, + { "id": "md-data", "type": "data", "title": "Application", "icon": "Package", "content": { "columns": 2, "include": [ "name", "shortDescription", "longDescription", "website", "provider", "contactPerson", "cloudDienstverleningsmodel", "hostingJurisdiction", "hostingLocation", "licentietype", "licence", "bbnLevel", "dpiaStatus", "dpiaDate", "dpiaNextAssessment", "dpiaDocumentRef", "verwerkingsregisterRef" ] } }, { "id": "md-files", "type": "integration", "integrationId": "files", "title": "Documentation", "icon": "BookOpenVariant" }, { "id": "md-related", "type": "related", "title": "Vendor & services", "icon": "LinkVariant" }, - { "id": "md-compliance", "type": "object-list", "title": "Compliance claims", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "module": "@objectId" }, "columns": [ { "key": "standardVersion", "label": "Standard" }, { "key": "bioMaatregel", "label": "BIO measure" }, { "key": "url", "label": "Evidence" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } }, - { "id": "md-versions", "type": "object-list", "title": "Application versions", "icon": "SourceBranch", "content": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "filter": { "module": "@objectId" }, "columns": [ { "key": "version", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "ModuleversieDetail", "allowCreate": false, "emptyText": "No versions registered yet" } } + { "id": "md-compliance", "type": "object-list", "title": "Compliance claims", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "module": "@objectId" }, "columns": [ { "key": "standardVersion", "label": "Standard" }, { "key": "bioMeasure", "label": "BIO measure" }, { "key": "url", "label": "Evidence" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } }, + { "id": "md-versions", "type": "object-list", "title": "Application versions", "icon": "SourceBranch", "content": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "filter": { "module": "@objectId" }, "columns": [ { "key": "version", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "ModuleversieDetail", "allowCreate": false, "emptyText": "No versions registered yet" } }, + { "id": "md-usages", "type": "object-list", "title": "Usages", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "module": "@objectId" }, "columns": [ { "key": "consumer", "label": "Organisation" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 50, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "module": "@objectId" }, "addLabel": "Add to our landscape", "formIncludeFields": [ "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner" ], "emptyText": "No organisation registered a usage yet" } }, + { "id": "md-connections-out", "type": "object-list", "title": "Connections from this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleA": "@objectId" }, "columns": [ { "key": "moduleB", "label": "To application" }, { "key": "nonMunicipalProvision", "label": "To national provision" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleA": "@objectId" }, "allowCreate": false, "emptyText": "No connections start at this application" } }, + { "id": "md-connections-in", "type": "object-list", "title": "Connections to this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleB": "@objectId" }, "columns": [ { "key": "moduleA", "label": "From application" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleB": "@objectId" }, "allowCreate": false, "emptyText": "No connections end at this application" } }, + { "id": "md-ai-systems", "type": "object-list", "title": "AI systems", "icon": "RobotOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "aiSystem", "filter": { "module": "@objectId" }, "columns": [ { "key": "kind", "label": "Kind" }, { "key": "aiActRiskCategory", "label": "AI Act risk category" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "AiSystemDetail", "viewAllRoute": "AiSystems", "viewAllQuery": { "module": "@objectId" }, "allowCreate": false, "emptyText": "No AI systems registered for this application" } }, + { "id": "md-maintenance", "type": "object-list", "title": "Planned maintenance", "icon": "Calendar", "content": { "register": "@resolve:voorzieningen_register", "schema": "maintenanceWindow", "filter": { "module": "@objectId" }, "sort": { "field": "startsAt", "dir": "asc" }, "columns": [ { "key": "title", "label": "Title" }, { "key": "startsAt", "label": "Starts at" }, { "key": "endsAt", "label": "Ends at" }, { "key": "impact", "label": "Impact" }, { "key": "status", "label": "Status" } ], "limit": 25, "addLabel": "Announce maintenance", "formIncludeFields": [ "title", "description", "moduleVersion", "startsAt", "endsAt", "impact" ], "emptyText": "No maintenance planned on this application" } } ], "layout": [ { "id": "1", "widgetId": "md-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 8 }, { "id": "2", "widgetId": "md-files", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }, { "id": "3", "widgetId": "md-related", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 4 }, - { "id": "4", "widgetId": "md-compliance", "gridX": 0, "gridY": 8, "gridWidth": 6, "gridHeight": 4 }, - { "id": "5", "widgetId": "md-versions", "gridX": 6, "gridY": 8, "gridWidth": 6, "gridHeight": 4 } + { "id": "4", "widgetId": "md-versions", "gridX": 0, "gridY": 8, "gridWidth": 6, "gridHeight": 4 }, + { "id": "5", "widgetId": "md-usages", "gridX": 6, "gridY": 8, "gridWidth": 6, "gridHeight": 4 }, + { "id": "6", "widgetId": "md-compliance", "gridX": 0, "gridY": 12, "gridWidth": 12, "gridHeight": 4 }, + { "id": "7", "widgetId": "md-connections-out", "gridX": 0, "gridY": 16, "gridWidth": 6, "gridHeight": 4 }, + { "id": "8", "widgetId": "md-connections-in", "gridX": 6, "gridY": 16, "gridWidth": 6, "gridHeight": 4 }, + { "id": "9", "widgetId": "md-ai-systems", "gridX": 0, "gridY": 20, "gridWidth": 12, "gridHeight": 4 }, + { "id": "10", "widgetId": "md-maintenance", "gridX": 0, "gridY": 24, "gridWidth": 12, "gridHeight": 4 } ], "bodyWidgets": [ + { "id": "md-roadmap", "component": "ProductRoadmap", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 }, + { "id": "md-contracts", "component": "ApplicationContractsPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 }, { "id": "md-reviews", "component": "ReviewsPanel", "props": { "objectId": "@objectId", "subjectType": "module" }, "placement": "end", "colSpan": 12 } ], "sidebar": { @@ -576,7 +604,8 @@ { "id": "5", "widgetId": "ct-decisions", "gridX": 0, "gridY": 10, "gridWidth": 12, "gridHeight": 4 } ], "bodyWidgets": [ - { "id": "ct-approval", "component": "ContractApprovalPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 } + { "id": "ct-approval", "component": "ContractApprovalPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 }, + { "id": "ct-seats", "component": "ContractSeatsPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 } ], "sidebar": { "enabled": true, @@ -598,6 +627,7 @@ "config": { "register": "@resolve:voorzieningen_register", "schema": "module", + "detailRoute": "ModuleDetail", "description": "Browse the application (module) catalogue, filtered by GEMMA architecture dimension.", "columns": [ "name", @@ -680,7 +710,7 @@ "schema": "suite", "_note": "suite-wizard: catalog archetype (a bundled product made up of one or more existing applications). Body: suite data 8-wide (naam, beschrijvingKort, beschrijvingLang, website, logo, contactpersoon) with a Related panel 4-wide surfacing both the contact person and the attached applications (`applicaties`) — the latter via the platform's generic bidirectional relation index (the same `/uses`+`/used` mechanism `md-related` on ModuleDetail already relies on, which is also how a module's own detail page surfaces which suite(s) include it, with no ModuleDetail change needed). A suite does not communicate, so per the comms hard-rule NO Emails/Meetings widgets appear. Audit trail stays a sidebar tab.", "widgets": [ - { "id": "suite-data", "type": "data", "title": "Suite", "icon": "Package", "content": { "columns": 2, "include": [ "name", "beschrijvingKort", "beschrijvingLang", "website", "logo", "contactpersoon" ] } }, + { "id": "suite-data", "type": "data", "title": "Suite", "icon": "Package", "content": { "columns": 2, "include": [ "name", "shortDescription", "longDescription", "website", "logo", "contactPerson" ] } }, { "id": "suite-related", "type": "related", "title": "Applications & contact", "icon": "LinkVariant" } ], "layout": [ @@ -922,9 +952,14 @@ "columns": [ "version", "module", + "dateInDevelopment", "dateInUse", "status" ], + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "Planned releases", "filter": { "status": "in development" }, "icon": "MapMarkerPath" } + ], "sidebar": { "enabled": true, "showMetadata": true diff --git a/src/modals/object/MergeOrganisationConfirmDialog.vue b/src/modals/object/MergeOrganisationConfirmDialog.vue index 9278df03e..1dcb6b58f 100644 --- a/src/modals/object/MergeOrganisationConfirmDialog.vue +++ b/src/modals/object/MergeOrganisationConfirmDialog.vue @@ -122,7 +122,7 @@ export default { default: '', }, - /** Dry-run `counts` object: `{gebruik, contract, contactpersoon, aanbod, compliancy, groupMembers}`. */ + /** Dry-run `counts` object, keyed as MergeOrganisatieService counts: `{usage, contactPerson, aanbod, module, catalogService, catalogContract, compliancy, moduleOwnership, catalogServiceOwnership, groupMembers}`. */ counts: { type: Object, default: () => ({}), @@ -152,9 +152,13 @@ export default { countRows() { const labels = { usage: t('stackiq', 'Usage records'), - contract: t('stackiq', 'Contracts'), + catalogContract: t('stackiq', 'Contracts'), contactPerson: t('stackiq', 'Contact persons'), aanbod: t('stackiq', 'Offerings'), + module: t('stackiq', 'Applications supplied'), + catalogService: t('stackiq', 'Services supplied'), + moduleOwnership: t('stackiq', 'Applications managed'), + catalogServiceOwnership: t('stackiq', 'Services managed'), compliancy: t('stackiq', 'Compliance records'), groupMembers: t('stackiq', 'Group members'), } diff --git a/src/services/connectionRegistry.js b/src/services/connectionRegistry.js new file mode 100644 index 000000000..af895e94a --- /dev/null +++ b/src/services/connectionRegistry.js @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +/** + * The Integrations page's Add integration handler. + * + * The rows on that page are integriq's `app_connection` objects (hydra change + * connection-registry, design D8). Its two formatters, `connectionStatus` and + * `connectionSettingsLabel`, are built into @conduction/nextcloud-vue from + * 3.2.0, so CnAppRoot supplies them and stackiq no longer carries a copy. + * + * Pure: the URL builder and the navigation are passed in, so + * the module runs under vitest's node environment with nothing mocked. + * + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ + +/** + * Where Add integration lands: integriq's Connections overview, preset to this + * app and opening the link-a-source dialog (hydra connection-registry D9). + */ +export const INTEGRIQ_CONNECTIONS_PATH = + '/apps/integriq/connections?app=stackiq&link=1' + +/** + * Build the Add integration header-action handler. + * + * A FUNCTION handler because a header action's `navigate` keyword only pushes + * a route inside this app's router, which cannot leave the app. + * + * @param {{generateUrl: function(string): string, assign: function(string): void}} deps Builds the instance URL and navigates to it. + * @return {{openIntegriqConnections: function(): void}} The handler, keyed by its manifest name. + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ +export function createConnectionHandlers({ generateUrl, assign }) { + return { + /** + * Open integriq's Connections overview on the link-a-source dialog. + * + * @return {void} + * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page + */ + openIntegriqConnections() { + assign(generateUrl(INTEGRIQ_CONNECTIONS_PATH)) + }, + } +} diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js index d8e7eb7c0..9c178e6a9 100644 --- a/src/store/modules/settings.js +++ b/src/store/modules/settings.js @@ -1944,54 +1944,6 @@ export const useSettingsStore = defineStore('settings', { } }, - /** - * Test ArchiMate round-trip functionality - * - * @return {Promise} Test result - * @spec openspec/specs/fe-stores/spec.md - */ - async testRoundTrip() { - try { - const response = await fetch( - '/index.php/apps/stackiq/api/archimate/test-round-trip', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - }, - ) - - if (!response.ok) { - throw new Error( - `HTTP ${response.status}: ${response.statusText}`, - ) - } - - const result = await response.json() - - if (result.success) { - showSuccess('Round-trip test completed successfully') - } else { - showError( - 'Round-trip test failed: ' - + (result.message || 'Unknown error'), - ) - } - - return result - } catch (error) { - console.error('Round-trip test failed:', error) - const errorResult = { - success: false, - message: 'Round-trip test failed: ' + error.message, - } - showError(errorResult.message) - return errorResult - } - }, - /** * Cleanup method to stop polling when store is destroyed * diff --git a/src/utils/aiAct.js b/src/utils/aiAct.js new file mode 100644 index 000000000..f17c37960 --- /dev/null +++ b/src/utils/aiAct.js @@ -0,0 +1,67 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * EU AI Act helpers for the AI systems pages: the missing-FRIA rule the list + * warns on, and the evidence checklist the detail page shows. + * + * @spec openspec/specs/ai-system-inventory/spec.md#requirement-req-ais-003-a-high-risk-ai-system-without-a-fundamental-rights-impact-assessment-is-flagged + */ + +import { translate as t } from '@nextcloud/l10n' + +/** + * The evidence tags a deployer of a high-risk AI system keeps, in the order + * the checklist shows them. The same list is the schema's `allowedTags`. + */ +export const EVIDENCE_TAGS = [ + 'FRIA', + 'Technical documentation', + 'Human oversight', + 'Logging', +] + +/** + * Whether an AI system is high risk and has no FRIA reference. + * + * @param {object} system The aiSystem object. + * @return {boolean} True when the FRIA is missing. + * @spec openspec/specs/ai-system-inventory/spec.md#requirement-req-ais-003-a-high-risk-ai-system-without-a-fundamental-rights-impact-assessment-is-flagged + */ +export function friaMissing(system) { + if (!system || system.aiActRiskCategory !== 'high risk') { + return false + } + const ref = system.friaDocumentRef + return typeof ref !== 'string' || ref.trim() === '' +} + +/** + * Cell formatter for the FRIA column of the AI systems list: reads + * "FRIA missing" on a flagged system and nothing otherwise. + * + * @param {unknown} _value The friaDocumentRef value (the row is read instead). + * @param {object} row The aiSystem row. + * @return {string} The warning, or an empty string. + * @spec openspec/specs/ai-system-inventory/spec.md#requirement-req-ais-003-a-high-risk-ai-system-without-a-fundamental-rights-impact-assessment-is-flagged + */ +export function friaStatus(_value, row) { + return friaMissing(row) ? t('stackiq', 'FRIA missing') : '' +} + +/** + * The evidence checklist: one entry per tag, present when a file carries it. + * + * @param {Array|undefined} files The object's files, each with `labels`. + * @return {Array<{tag: string, present: boolean, files: Array}>} The checklist. + * @spec openspec/specs/ai-system-inventory/spec.md#requirement-req-ais-003-a-high-risk-ai-system-without-a-fundamental-rights-impact-assessment-is-flagged + */ +export function evidenceChecklist(files) { + const list = Array.isArray(files) ? files : [] + return EVIDENCE_TAGS.map((tag) => { + const names = list + .filter((f) => Array.isArray(f?.labels) && f.labels.includes(tag)) + .map((f) => f.name) + return { tag, present: names.length > 0, files: names } + }) +} diff --git a/src/utils/applicationContracts.js b/src/utils/applicationContracts.js new file mode 100644 index 000000000..dbbca28b6 --- /dev/null +++ b/src/utils/applicationContracts.js @@ -0,0 +1,79 @@ +/** + * The contracts behind an application, and routing a catalogue row to its page. + * + * A contract points at a usage and a service, not at the application, so the + * application's contracts are two hops away: through a usage of it, or + * through a service that offers it. + * + * @spec openspec/specs/application-page/spec.md#requirement-req-apg-003-the-application-page-lists-the-contracts-behind-the-application + */ + +const PAGE = 500 + +/** + * The id of an object row, wherever the API put it. + * + * @param {object} row The row + * @return {string|null} The id + */ +function rowId(row) { + return row?.id ?? row?.['@self']?.id ?? row?.uuid ?? null +} + +/** + * Load every readable contract behind an application, once each. + * + * @param {string} applicationId The application (module) id + * @param {Function} fetchList (type, params) resolving to the rows the user may read + * @return {Promise} The contracts + * @spec openspec/specs/application-page/spec.md#requirement-req-apg-003-the-application-page-lists-the-contracts-behind-the-application + */ +export async function loadApplicationContracts(applicationId, fetchList) { + const usages = await fetchList('usage', { module: applicationId, _limit: PAGE }) + const services = await fetchList('catalogService', { + modules: applicationId, + _limit: PAGE, + }) + const usageIds = (usages ?? []).map(rowId).filter(Boolean) + const serviceIds = (services ?? []).map(rowId).filter(Boolean) + + const lists = [] + if (usageIds.length > 0) { + lists.push( + await fetchList('catalogContract', { usage: usageIds, _limit: PAGE }), + ) + } + if (serviceIds.length > 0) { + lists.push( + await fetchList('catalogContract', { + service: serviceIds, + _limit: PAGE, + }), + ) + } + + const byId = new Map() + for (const contract of lists.flat()) { + const id = rowId(contract) + if (id && !byId.has(id)) { + byId.set(id, contract) + } + } + return [...byId.values()] +} + +/** + * Where a click on a catalogue row goes: the page's detail route, or nowhere. + * + * @param {string} detailRoute The named route of the detail page, or empty + * @param {object} row The clicked row + * @return {{name: string, params: {id: string}}|null} The router location + * @spec openspec/specs/application-page/spec.md#requirement-req-apg-004-the-applications-list-opens-the-application-page + */ +export function rowDetailLocation(detailRoute, row) { + const id = rowId(row) + if (!detailRoute || !id) { + return null + } + return { name: detailRoute, params: { id: String(id) } } +} diff --git a/src/utils/archiMateImportProgress.js b/src/utils/archiMateImportProgress.js new file mode 100644 index 000000000..74884e1cb --- /dev/null +++ b/src/utils/archiMateImportProgress.js @@ -0,0 +1,137 @@ +/** + * Follow and cancel a running ArchiMate import from the settings page. + * + * The import is one long request, so the page names the operation up front + * and reads its progress from a second request while the first is running. + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-003-the-settings-page-shall-show-the-progress-and-offer-a-cancel + */ + +import { t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' + +/** Operation ids the server accepts for an ArchiMate import. */ +export const OPERATION_ID_PATTERN = /^archimate_import_[A-Za-z0-9]{8,64}$/ + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + +/** + * Make a fresh operation id for an import. + * + * @return {string} The id + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-001-a-running-import-shall-record-its-phase-and-the-objects-saved-so-far + */ +export function makeOperationId() { + const bytes = new Uint8Array(24) + globalThis.crypto.getRandomValues(bytes) + let suffix = '' + for (const byte of bytes) { + suffix += ALPHABET[byte % ALPHABET.length] + } + return 'archimate_import_' + suffix +} + +/** + * The words for each import phase the server records. + * + * @param {string} phase The phase key + * @return {string} The label + */ +function phaseLabel(phase) { + switch (phase) { + case 'initializing': + case 'validating': + return t('stackiq', 'Checking the file') + case 'parsing': + return t('stackiq', 'Reading the model') + case 'analyzing': + return t('stackiq', 'Converting the model') + case 'processing_elements': + return t('stackiq', 'Saving objects') + case 'finalizing': + return t('stackiq', 'Finishing the import') + default: + return t('stackiq', 'Importing') + } +} + +/** + * What the page shows for a progress snapshot. + * + * @param {object|null} progress The snapshot from the progress endpoint + * @return {{label: string, percentage: number, detail: string}|null} The view, or null before any progress + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-003-the-settings-page-shall-show-the-progress-and-offer-a-cancel + */ +export function progressView(progress) { + if (!progress) { + return null + } + const processed = Number(progress.processed_items) || 0 + const total = Number(progress.total_items) || 0 + return { + label: phaseLabel(progress.phase), + percentage: Number(progress.percentage) || 0, + detail: + total > 0 + ? t('stackiq', '{processed} of {total} objects saved', { + processed, + total, + }) + : '', + } +} + +/** + * Read the progress of an operation every two seconds until stopped. + * + * An operation that is not readable yet (the import has not started it) is + * skipped quietly; the next tick tries again. + * + * @param {object} options The options + * @param {string} options.operationId The operation to follow + * @param {object} options.http An axios-like client with get + * @param {Function} options.onProgress Called with each snapshot + * @param {Function} [options.setIntervalFn] Timer, for tests + * @param {Function} [options.clearIntervalFn] Timer, for tests + * @return {Function} Stops the polling + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-003-the-settings-page-shall-show-the-progress-and-offer-a-cancel + */ +export function startProgressPolling({ + operationId, + http, + onProgress, + setIntervalFn = setInterval, + clearIntervalFn = clearInterval, +}) { + const url = generateUrl('/apps/stackiq/api/progress/{operationId}', { + operationId, + }) + const handle = setIntervalFn(async () => { + try { + const response = await http.get(url) + if (response?.data?.progress) { + onProgress(response.data.progress) + } + } catch { + // Not readable yet or briefly unavailable: try again on the next tick. + } + }, 2000) + return () => clearIntervalFn(handle) +} + +/** + * Ask the server to cancel a running import. + * + * @param {object} options The options + * @param {string} options.operationId The operation to cancel + * @param {object} options.http An axios-like client with post + * @return {Promise} The server's answer + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-002-an-admin-shall-be-able-to-cancel-a-running-import + */ +export async function cancelImport({ operationId, http }) { + const response = await http.post( + generateUrl('/apps/stackiq/api/archimate/import/cancel'), + { operationId }, + ) + return response.data +} diff --git a/src/utils/archiMateImportProgress.spec.js b/src/utils/archiMateImportProgress.spec.js new file mode 100644 index 000000000..1d307fc7d --- /dev/null +++ b/src/utils/archiMateImportProgress.spec.js @@ -0,0 +1,120 @@ +/** + * Unit tests for following and cancelling a running ArchiMate import. + * + * @spec openspec/specs/archimate-import-progress/spec.md#requirement-req-aip-003-the-settings-page-shall-show-the-progress-and-offer-a-cancel + */ + +import { + cancelImport, + makeOperationId, + OPERATION_ID_PATTERN, + progressView, + startProgressPolling, +} from './archiMateImportProgress.js' + +describe('makeOperationId', () => { + it('makes an id the server accepts, different each time', () => { + const a = makeOperationId() + const b = makeOperationId() + expect(a).toMatch(OPERATION_ID_PATTERN) + expect(b).toMatch(OPERATION_ID_PATTERN) + expect(a).not.toBe(b) + }) +}) + +describe('progressView', () => { + it('shows the phase and the percentage the server reports', () => { + const view = progressView({ + phase: 'processing_elements', + percentage: 40, + processed_items: 120, + total_items: 300, + status: 'running', + }) + expect(view.label).toBe('Saving objects') + expect(view.percentage).toBe(40) + expect(view.detail).toBe('120 of 300 objects saved') + }) + + it('has no detail before any object is counted', () => { + expect( + progressView({ + phase: 'parsing', + percentage: 10, + processed_items: 0, + total_items: 0, + }).detail, + ).toBe('') + }) + + it('shows nothing when there is no progress yet', () => { + expect(progressView(null)).toBeNull() + }) +}) + +describe('startProgressPolling', () => { + it('reads the progress endpoint every interval and hands each answer on until stopped', async () => { + let tick = null + const setIntervalFn = jest.fn((fn) => { + tick = fn + return 7 + }) + const clearIntervalFn = jest.fn() + const get = jest.fn().mockResolvedValue({ + data: { progress: { phase: 'processing_elements', percentage: 40 } }, + }) + const seen = [] + + const stop = startProgressPolling({ + operationId: 'archimate_import_abc12345', + http: { get }, + onProgress: (p) => seen.push(p.percentage), + setIntervalFn, + clearIntervalFn, + }) + expect(setIntervalFn.mock.calls[0][1]).toBe(2000) + + await tick() + expect(get.mock.calls[0][0]).toContain( + '/apps/stackiq/api/progress/archimate_import_abc12345', + ) + expect(seen).toEqual([40]) + + stop() + expect(clearIntervalFn).toHaveBeenCalledWith(7) + }) + + it('keeps polling quietly when the operation is not readable yet', async () => { + let tick = null + const get = jest.fn().mockRejectedValue({ response: { status: 404 } }) + const seen = [] + startProgressPolling({ + operationId: 'archimate_import_abc12345', + http: { get }, + onProgress: (p) => seen.push(p), + setIntervalFn: (fn) => { + tick = fn + return 1 + }, + clearIntervalFn: () => {}, + }) + await tick() + expect(seen).toEqual([]) + }) +}) + +describe('cancelImport', () => { + it('posts the operation id to the cancel endpoint', async () => { + const post = jest.fn().mockResolvedValue({ data: { success: true } }) + await cancelImport({ + operationId: 'archimate_import_abc12345', + http: { post }, + }) + expect(post.mock.calls[0][0]).toContain( + '/apps/stackiq/api/archimate/import/cancel', + ) + expect(post.mock.calls[0][1]).toEqual({ + operationId: 'archimate_import_abc12345', + }) + }) +}) diff --git a/src/utils/licensePosture.js b/src/utils/licensePosture.js index cec479d13..86af517c6 100644 --- a/src/utils/licensePosture.js +++ b/src/utils/licensePosture.js @@ -286,3 +286,113 @@ export function perOrganisationPosture(orgId, modules, usages) { closedContributors: [...acc.closedContributors], } } + +/** + * Licence metrics that have no seat to count. A contract on one of these gets + * no seat comparison (contracts-licence-seats, design D3). + * + * @type {ReadonlyArray} + */ +export const UNCOUNTED_METRICS = Object.freeze(['Per organisation', 'Other']) + +/** + * Seat states a counted licence contract can be in. + * + * @type {{WITHIN: string, OVER: string, UNKNOWN: string, NOT_COUNTED: string}} + */ +export const SEAT_STATE = Object.freeze({ + WITHIN: 'within', + OVER: 'over', + UNKNOWN: 'unknown', + NOT_COUNTED: 'not-counted', +}) + +/** + * Read a licence count: a whole number of zero or more, or null when empty. + * + * @param {string|number|null|undefined} value The raw field value. + * @return {number|null} The count, or null. + */ +function seatCount(value) { + if (value === null || value === undefined || value === '') { + return null + } + const n = Number(value) + return Number.isInteger(n) && n >= 0 ? n : null +} + +/** + * Where one contract stands on its licences: in use against bought. + * + * @param {object} contract A catalogContract record (envelope or data bag). + * @return {{state: string, metric: string, bought: (number|null), inUse: (number|null), over: number}} + * The seat position; `over` is how many licences are in use above what was bought. + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + */ +export function seatPosition(contract) { + const data = dataOf(contract) + const metric = typeof data.licenceMetric === 'string' ? data.licenceMetric : '' + const bought = seatCount(data.licencesBought) + const inUse = seatCount(data.licencesInUse) + const position = { state: SEAT_STATE.UNKNOWN, metric, bought, inUse, over: 0 } + + if (UNCOUNTED_METRICS.includes(metric)) { + return { ...position, state: SEAT_STATE.NOT_COUNTED } + } + if (metric === '' || bought === null || inUse === null) { + return position + } + if (inUse > bought) { + return { ...position, state: SEAT_STATE.OVER, over: inUse - bought } + } + return { ...position, state: SEAT_STATE.WITHIN } +} + +/** + * One row per counted licence contract for the Seats section of the License + * posture page, over-licence rows first (most over first). A contract is left + * out when its metric is uncounted or empty, or when `licencesBought` is empty. + * + * @param {Array} contracts catalogContract records. + * @param {Array} usages Usage records, to find the application and the organisation. + * @return {Array<{contractId: string, contractNumber: string, moduleId: string, consumerId: string, metric: string, bought: number, inUse: (number|null), state: string, over: number}>} + * The seat rows. + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first + */ +export function seatRows(contracts, usages) { + const usageIndex = {} + for (const u of usages || []) { + const id = resolveUuid(u?.id ?? u?.uuid ?? u?.['@self']?.id ?? '') + if (id !== '') { + usageIndex[id] = dataOf(u) + } + } + + const rows = [] + for (const c of contracts || []) { + const position = seatPosition(c) + if ( + position.state === SEAT_STATE.NOT_COUNTED + || position.metric === '' + || position.bought === null + ) { + continue + } + const data = dataOf(c) + const usage = usageIndex[resolveUuid(data.usage)] || {} + rows.push({ + contractId: resolveUuid(c?.id ?? c?.uuid ?? c?.['@self']?.id ?? ''), + contractNumber: data.contractNumber || '', + moduleId: resolveUuid(usage.module), + consumerId: resolveUuid(usage.consumer), + metric: position.metric, + bought: position.bought, + inUse: position.inUse, + state: position.state, + over: position.over, + }) + } + + const rank = (row) => (row.state === SEAT_STATE.OVER ? 0 : 1) + return rows.sort((a, b) => rank(a) - rank(b) || b.over - a.over) +} diff --git a/src/utils/maintenance.js b/src/utils/maintenance.js new file mode 100644 index 000000000..107e96609 --- /dev/null +++ b/src/utils/maintenance.js @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Planned maintenance and the supplier roadmap: which maintenance windows an + * organisation should see, and a product's versions as timeline events. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md + */ + +const PAGE = 500 +const DAY_MS = 24 * 60 * 60 * 1000 + +/** + * The id of a row or a relation value. + * + * @param {object|string|null} value A row, a relation object or an id. + * @return {string|null} The id. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-002-organisations-that-use-a-product-see-its-planned-maintenance + */ +export function refId(value) { + if (typeof value === 'string') { + return value || null + } + return value?.id ?? value?.['@self']?.id ?? value?.uuid ?? null +} + +/** + * The planned windows on the given products that start within `days` days. + * + * @param {Array} windows Maintenance windows. + * @param {Array} moduleIds The products the organisation uses. + * @param {Date} [now] The current moment. + * @param {number} [days] How far ahead to look. + * @return {Array} The windows, earliest first. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-002-organisations-that-use-a-product-see-its-planned-maintenance + */ +export function upcomingMaintenance( + windows, + moduleIds, + now = new Date(), + days = 30, +) { + const used = new Set(moduleIds) + const from = now.getTime() + const until = from + days * DAY_MS + return (windows ?? []) + .filter((w) => w?.status === 'planned' && used.has(refId(w.module))) + .filter((w) => { + const start = Date.parse(w.startsAt) + const end = Date.parse(w.endsAt || w.startsAt) + return !Number.isNaN(start) && end >= from && start <= until + }) + .sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt)) +} + +/** + * Load the upcoming maintenance on the products an organisation uses. + * + * @param {string} organisationId The active organisation. + * @param {(schema: string, params: object) => Promise>} fetchList Reads a list of objects. + * @param {Date} [now] The current moment. + * @return {Promise>} The windows, earliest first. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-002-organisations-that-use-a-product-see-its-planned-maintenance + */ +export async function loadUpcomingMaintenance( + organisationId, + fetchList, + now = new Date(), +) { + if (!organisationId) { + return [] + } + const usages = await fetchList('usage', { + consumer: organisationId, + _limit: PAGE, + }) + const moduleIds = [ + ...new Set((usages ?? []).map((u) => refId(u.module)).filter(Boolean)), + ] + if (moduleIds.length === 0) { + return [] + } + const windows = await fetchList('maintenanceWindow', { + module: moduleIds, + status: 'planned', + _limit: PAGE, + }) + return upcomingMaintenance(windows, moduleIds, now) +} + +/** + * A product's versions as timeline events, placed on the date they go or went + * into use, or the date development started when no go-live date is set. + * + * @param {Array} versions Module versions. + * @return {Array} Events for CnTimelineView. + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-004-a-product-page-shows-the-supplier-s-roadmap + */ +export function roadmapEvents(versions) { + return (versions ?? []) + .map((v) => ({ + id: refId(v) ?? v.version, + title: v.version || '', + start: v.dateInUse || v.dateInDevelopment || '', + description: v.shortDescription || '', + kind: v.status === 'in development' ? 'planned' : 'released', + status: v.status || '', + })) + .filter((e) => e.start && !Number.isNaN(Date.parse(e.start))) +} diff --git a/src/utils/organisationStatus.js b/src/utils/organisationStatus.js new file mode 100644 index 000000000..21fa3c19c --- /dev/null +++ b/src/utils/organisationStatus.js @@ -0,0 +1,38 @@ +/** + * Organisation status values the concept-organisations dashboard widget reads + * and writes. They must be members of the `status` enum of the organization + * schema in lib/Settings/softwarecatalogus_register.json, or OpenRegister + * refuses the accept and the widget never lists anything. + * + * @spec openspec/specs/fe-organizations/spec.md + */ + +/** Status of an organisation still waiting for review. */ +export const CONCEPT_STATUS = 'Draft' + +/** Status an organisation gets once it is accepted. */ +export const ACCEPTED_STATUS = 'Active' + +/** + * Whether an organisation is still a concept, compared case-insensitively. + * + * @param {object} organisation The organisation object from the store + * @return {boolean} True when its status is the concept status + * @spec openspec/specs/fe-organizations/spec.md + */ +export function isConceptOrganisation(organisation) { + return ( + String(organisation?.status ?? '').toLowerCase() + === CONCEPT_STATUS.toLowerCase() + ) +} + +/** + * The patch payload that accepts an organisation. + * + * @return {{status: string}} The payload for objectStore.patchObject() + * @spec openspec/specs/fe-organizations/spec.md + */ +export function acceptPayload() { + return { status: ACCEPTED_STATUS } +} diff --git a/src/utils/seatLabels.js b/src/utils/seatLabels.js new file mode 100644 index 000000000..efe114171 --- /dev/null +++ b/src/utils/seatLabels.js @@ -0,0 +1,53 @@ +/** + * seatLabels: the words the seats panel and the Seats section show for a + * licence metric and a seat state, in the reader's language. + * + * @module utils/seatLabels + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + */ + +import { translate as t } from '@nextcloud/l10n' +import { SEAT_STATE } from './licensePosture.js' + +/** + * The translated name of a licence metric value. + * + * @param {string} metric A licenceMetric enum value. + * @return {string} The translated name, or the raw value when unknown. + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use + */ +export function licenceMetricLabel(metric) { + const labels = { + 'Per named user': t('stackiq', 'Per named user'), + 'Per concurrent user': t('stackiq', 'Per concurrent user'), + 'Per device': t('stackiq', 'Per device'), + 'Per inhabitant': t('stackiq', 'Per inhabitant'), + 'Per organisation': t('stackiq', 'Per organisation'), + Other: t('stackiq', 'Other'), + } + return labels[metric] ?? metric ?? '' +} + +/** + * The translated seat state of a seatPosition() result. + * + * @param {{state: string, over: number}} position A seatPosition() result. + * @return {string} Within licence, Over licence by N, Not counted or Unknown. + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + */ +export function seatStateLabel(position) { + switch (position?.state) { + case SEAT_STATE.WITHIN: + return t('stackiq', 'Within licence') + case SEAT_STATE.OVER: + return t('stackiq', 'Over licence by {count}', { + count: Number(position.over).toLocaleString(), + }) + case SEAT_STATE.NOT_COUNTED: + return t('stackiq', 'Not counted') + default: + return t('stackiq', 'Unknown') + } +} diff --git a/src/utils/valueAssessment.js b/src/utils/valueAssessment.js new file mode 100644 index 000000000..b0ea6c3fc --- /dev/null +++ b/src/utils/valueAssessment.js @@ -0,0 +1,112 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The value assessment of the applications an organisation uses: the risk + * signals next to the risk score, and the value against fit plot and the + * mismatch filter of the portfolio report. + * + * @spec openspec/specs/application-value-assessment/spec.md + */ +import { endOfSupportState } from './lifecyclePhase.js' +import { refId } from './maintenance.js' + +const MIN_RADIUS = 6 +const MAX_RADIUS = 22 +const SPREAD = 0.18 + +/** + * The risk signals of a usage: end of support of the version it runs and the + * number of vulnerabilities linked to its application. + * + * @param {object|null} version The moduleVersion the usage runs. + * @param {Array|null} vulnerabilities Vulnerabilities to count. + * @param {string} moduleId The application of the usage. + * @param {Date} [now] The current moment. + * @return {{endOfSupportPassed: boolean, endOfSupportDate: (string|null), withdrawn: boolean, vulnerabilityCount: number}} The signals. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score + */ +export function riskSignals(version, vulnerabilities, moduleId, now = new Date()) { + const eol = endOfSupportState(version || {}, now) + const count = (Array.isArray(vulnerabilities) ? vulnerabilities : []).filter( + (vulnerability) => { + const data = vulnerability?.object || vulnerability || {} + const modules = Array.isArray(data.modules) ? data.modules : [] + return modules.some((module) => refId(module) === moduleId) + }, + ).length + return { + endOfSupportPassed: eol.passed, + endOfSupportDate: eol.endDate, + withdrawn: eol.withdrawn, + vulnerabilityCount: count, + } +} + +/** + * Whether a report row has both scores the plot needs. + * + * @param {object} row A portfolio report row. + * @return {boolean} True when value and fit are set. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ +function isScored(row) { + return ( + Number.isInteger(row?.businessValue) && Number.isInteger(row?.technicalFit) + ) +} + +/** + * The points of the value against fit plot: one per scored usage, its radius + * by annualised cost (square root, so the area follows the cost), and a small + * offset for points that share a cell. + * + * @param {Array|null} rows Portfolio report rows. + * @return {{points: Array, notScored: number}} The points and the count of usages without both scores. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ +export function valueFitPoints(rows) { + const list = Array.isArray(rows) ? rows : [] + const scored = list.filter(isScored) + const maxCost = Math.max( + 0, + ...scored.map((row) => Number(row.annualisedCost) || 0), + ) + const seen = {} + const points = scored.map((row) => { + const cost = Math.max(0, Number(row.annualisedCost) || 0) + const share = maxCost > 0 ? Math.sqrt(cost / maxCost) : 0 + const cell = `${row.technicalFit}:${row.businessValue}` + const index = seen[cell] || 0 + seen[cell] = index + 1 + const angle = index * 2.4 + return { + uuid: row.uuid, + label: row.moduleName, + fit: row.technicalFit, + value: row.businessValue, + cost, + radius: MIN_RADIUS + share * (MAX_RADIUS - MIN_RADIUS), + offset: + index === 0 + ? [0, 0] + : [Math.cos(angle) * SPREAD, Math.sin(angle) * SPREAD], + recorded: row.timeClassification || null, + suggested: row.suggestedTimeClassification || null, + } + }) + return { points, notScored: list.length - scored.length } +} + +/** + * The rows whose recorded TIME class differs from the class the scores suggest. + * + * @param {Array|null} rows Portfolio report rows. + * @return {Array} The mismatching rows. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ +export function mismatchRows(rows) { + return (Array.isArray(rows) ? rows : []).filter( + (row) => row?.timeMismatch === true, + ) +} diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index 326bc2e1f..cac15d0eb 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -114,7 +114,10 @@ generic route-query-to-filter passthrough never sees it (see the :filter="listFilter" :quickFilters="quickFilters" :quickFilterMode="quickFilterMode" - :quickFilterMultiple="quickFilterMultiple" /> + :quickFilterMultiple="quickFilterMultiple" + :rowClickToView="Boolean(detailRoute)" + @rowClick="openDetail" + @view="openDetail" /> @@ -143,6 +146,7 @@ import FolderOutline from 'vue-material-design-icons/FolderOutline.vue' import FolderStarOutline from 'vue-material-design-icons/FolderStarOutline.vue' import SaveFacetViewModal from '../modals/SaveFacetViewModal.vue' import { useFacetStore } from '../store/modules/facets.js' +import { rowDetailLocation } from '../utils/applicationContracts.js' import { buildFacetDimensionSchema } from '../utils/facetSchema.js' /** Dimension key -> translated label, matching `FacetController`'s query params. */ @@ -175,6 +179,15 @@ export default { }, props: { + /** + * Named route of the page one row opens, for example `ModuleDetail`. + * Empty keeps rows where they are (the Services list has no detail page). + */ + detailRoute: { + type: String, + default: '', + }, + /** `module` or `dienst` — which GEMMA-faceted listing this page renders. */ schema: { type: String, @@ -349,6 +362,20 @@ export default { }, methods: { + /** + * Open the detail page of a clicked row, or of the row whose View action was used. + * + * @param {object} row The row + * @return {void} + * @spec openspec/specs/application-page/spec.md#requirement-req-apg-004-the-applications-list-opens-the-application-page + */ + openDetail(row) { + const location = rowDetailLocation(this.detailRoute, row) + if (location) { + this.$router.push(location) + } + }, + /** * Re-fetch this schema's facet counts (search + active filters * already live in the store). diff --git a/src/views/LicensePostureView.vue b/src/views/LicensePostureView.vue index 6f3871058..eabc9a299 100644 --- a/src/views/LicensePostureView.vue +++ b/src/views/LicensePostureView.vue @@ -151,6 +151,58 @@ + + +
+

+ {{ t('stackiq', 'Seats') }} +

+ + + + + + + + + + + + + + + + + + + + + + +
{{ t('stackiq', 'Application') }}{{ t('stackiq', 'Organisation') }}{{ t('stackiq', 'Licence metric') }} + {{ t('stackiq', 'Licences bought') }} + + {{ t('stackiq', 'Licences in use') }} + {{ t('stackiq', 'Status') }}
+ + {{ row.applicationName }} + + + {{ row.organisationName }}{{ row.metricLabel }}{{ row.boughtLabel }}{{ row.inUseLabel }}{{ row.stateLabel }}
+
@@ -166,8 +218,11 @@ import { perOrganisationPosture, perVendorRollup, portfolioPosture, + SEAT_STATE, + seatRows, } from '../utils/licensePosture.js' import { resolveUuid } from '../utils/lifecyclePhase.js' +import { licenceMetricLabel, seatStateLabel } from '../utils/seatLabels.js' /** * @class LicensePostureView @@ -253,6 +308,28 @@ export default { return objectStore.getCollection('catalogContract')?.results || [] }, + /** + * The Seats section rows, names resolved and labels translated. + * + * @return {Array} One row per counted licence contract, over-licence first. + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first + */ + seatTableRows() { + return seatRows(this.contracts, this.usages).map((row) => ({ + ...row, + applicationName: + this.moduleNameIndex[row.moduleId] + || row.contractNumber + || t('stackiq', 'Contract'), + organisationName: this.organisatieIndex[row.consumerId] || '', + metricLabel: licenceMetricLabel(row.metric), + boughtLabel: row.bought.toLocaleString(), + inUseLabel: row.inUse === null ? '' : row.inUse.toLocaleString(), + stateLabel: seatStateLabel(row), + isOver: row.state === SEAT_STATE.OVER, + })) + }, + /** * Organisation UUID → display name. * @@ -544,6 +621,10 @@ export default { font-size: 13px; } +.pv-row--over td { + color: var(--color-error-text); +} + .pv-table { width: 100%; border-collapse: collapse; diff --git a/src/views/organisaties/PortfolioReport.vue b/src/views/organisaties/PortfolioReport.vue index e62e4636c..6482c4387 100644 --- a/src/views/organisaties/PortfolioReport.vue +++ b/src/views/organisaties/PortfolioReport.vue @@ -121,6 +121,14 @@ :height="260" /> + +
+

+ {{ t('stackiq', 'Business value against technical fit') }} +

+ +
+

@@ -176,6 +184,13 @@

{{ t('stackiq', 'Applications in use') }}

+ + {{ t('stackiq', 'Recorded class differs from scores') }} +

{{ t('stackiq', 'Application') }} + + {{ t('stackiq', 'Suggested by scores') }} + + + {{ t('stackiq', 'Value / fit / risk') }} + {{ t('stackiq', 'Rationale') }} @@ -220,6 +241,19 @@ :key="row.uuid" data-testid="pr-row"> {{ row.moduleName }} + + + {{ + quadrantLabel( + row.suggestedTimeClassification, + ) + }} + + — + + {{ scoresLabel(row) }} {{ row.timeRationale || '—' }} {{ row.timeReviewDate || '—' }} {{ row.lifecyclePhase }} @@ -262,6 +296,7 @@ import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { NcButton, + NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon, NcNoteCard, @@ -270,6 +305,7 @@ import { import ChartBoxOutline from 'vue-material-design-icons/ChartBoxOutline.vue' import Download from 'vue-material-design-icons/Download.vue' import Refresh from 'vue-material-design-icons/Refresh.vue' +import ValueFitPlot from '../../components/portfolio/ValueFitPlot.vue' import { useLiveCollections } from '../../composables/useLiveCollections.js' import { objectStore } from '../../store/store.js' import { resolveUuid } from '../../utils/lifecyclePhase.js' @@ -281,6 +317,7 @@ import { QUADRANT_ORDER, quadrantColor, } from '../../utils/portfolioReport.js' +import { mismatchRows } from '../../utils/valueAssessment.js' /** * @class PortfolioReport @@ -300,11 +337,13 @@ export default { name: 'PortfolioReport', components: { NcButton, + NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcEmptyContent, NcNoteCard, CnChartWidget, + ValueFitPlot, Refresh, Download, ChartBoxOutline, @@ -330,6 +369,7 @@ export default { error: null, selectedOrg: null, report: null, + onlyMismatch: false, } }, @@ -434,12 +474,14 @@ export default { * * @return {Array<{key: string, rows: Array}>} Grouped rows. * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict */ groupedRows() { if (!this.report) { return [] } - return groupRowsByQuadrant(this.report.rows || []) + const rows = this.report.rows || [] + return groupRowsByQuadrant(this.onlyMismatch ? mismatchRows(rows) : rows) }, }, @@ -595,6 +637,25 @@ export default { return map[key] || key }, + /** + * The three scores of a row as "value / fit / risk", a dash for a missing one. + * + * @param {object} row A report row. + * @return {string} The label. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + scoresLabel(row) { + const scores = [row.businessValue, row.technicalFit, row.riskScore] + if (scores.every((score) => score === null || score === undefined)) { + return '—' + } + return scores + .map((score) => + score === null || score === undefined ? '-' : score, + ) + .join(' / ') + }, + // Exposed to the template as methods — thin re-exports of the pure // `portfolioReport.js` utils (vitest-covered), since Vue 2 Options // API templates cannot call a bare imported function directly. @@ -712,6 +773,15 @@ export default { color: var(--color-text-maxcontrast); } +.pr-mismatchFilter { + margin-bottom: 12px; +} + +.pr-mismatch { + font-weight: bold; + color: var(--color-warning-text); +} + .pr-loading { margin: 40px auto; display: block; diff --git a/src/views/settings/sections/ArchiMateImportExport.vue b/src/views/settings/sections/ArchiMateImportExport.vue index 6ab9d584f..aba236d94 100644 --- a/src/views/settings/sections/ArchiMateImportExport.vue +++ b/src/views/settings/sections/ArchiMateImportExport.vue @@ -527,6 +527,30 @@

+ +
+

{{ importProgressView.label }}

+ +

+ {{ importProgressView.detail }} +

+
+ + + {{ + t( + 'stackiq', + 'Import cancelled. {count} objects were saved before it stopped.', + { count: importCancelled.objects_saved || 0 }, + ) + }} + +
+ + + {{ t('stackiq', 'Cancel import') }} + +