diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml index d3e40cad8..3d7f6785d 100644 --- a/.github/workflows/branch-protection.yml +++ b/.github/workflows/branch-protection.yml @@ -7,7 +7,7 @@ on: - beta jobs: - protect: + branch-protection: # The callee's `check-branch` job is a single bash comparison of # `github.base_ref` against `github.head_ref` — no checkout, no network, no # API call. It needs nothing, so it gets nothing. `{}` is an explicit empty diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 026f5a693..81c61d62d 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -132,14 +132,14 @@ jobs: app-name: stackiq php-version: "8.3" # THE LIST IS THE WHOLE DECLARED RANGE. appinfo/info.xml declares - # , so 32, 33 and 34 each get - # a leg. Adopting NC 34 by REPLACING the list left 32 and 33 advertised to - # the App Store with no job touching them — the declared floor became the + # , so 32, 33, 34 and 35 each + # get a leg. Adopting NC 34 by REPLACING the list left 32 and 33 advertised + # to the App Store with no job touching them — the declared floor became the # untested end, which is the same drift as never testing 34, reversed. - # stable34 leads because newman, playwright and journeydoc-capture all read - # `fromJSON(inputs.nextcloud-test-refs)[0]` as their single server, and 34 - # is the major this app had never been exercised on. - nextcloud-test-refs: '["stable34", "stable32", "stable33"]' + # stable35 leads because newman, playwright and journeydoc-capture all read + # `fromJSON(inputs.nextcloud-test-refs)[0]` as their single server, and 35 + # is the newest major this app declares (same order as thematiq). + nextcloud-test-refs: '["stable35", "stable32", "stable33", "stable34"]' enable-psalm: true enable-phpstan: true enable-phpmetrics: true @@ -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/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 98db0b547..4c664413e 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -14,6 +14,13 @@ on: jobs: deploy: + # Permission CEILING for the called workflow, not a grant: the callee's + # deploy job declares `contents: write` (gh-pages push) and its fallback + # image job `packages: write` (GHCR). A ceiling below what the callee + # declares makes the call fail to start. + permissions: + contents: write + packages: write uses: ConductionNL/.github/.github/workflows/documentation.yml@main # A reusable workflow receives NO secrets by default. Without this block the # callee's publish step finds CF_API_TOKEN empty, skips itself on its own diff --git a/CHANGELOG.md b/CHANGELOG.md index 9771ab106..87505881a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Added +- CMDB import: a Nextcloud admin imports a TOPdesk CMDB export (xlsx) for one + municipality from the admin settings. Every application row of the two CMDB + sheets becomes or updates a module, its vendor, a usage for the municipality + and a contact person for its owner; a repeat import matches on APPID, so it + updates instead of duplicating. See `docs/features/cmdb-import.md`. + ### Changed - EOL feed: the register and schema slugs the feature reads from are corrected. `EOL_DEFAULT_REGISTER` still said `openconnector`, a register renamed to diff --git a/appinfo/info.xml b/appinfo/info.xml index 15874ec6c..aa70380b6 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.4-unstable.20261001120000 EUPL-1.2 Conduction Stackiq @@ -91,7 +91,7 @@ Vrij en open source onder de EUPL-licentie. pgsql sqlite mysql - + @@ -224,6 +224,7 @@ Vrij en open source onder de EUPL-licentie. OCA\Stackiq\Repair\InitializeSettings OCA\Stackiq\Repair\MigrateContactsToNc OCA\Stackiq\Repair\BackfillContractApprovalState + OCA\Stackiq\Repair\BackfillModuleVersionPublication + +# 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/cmdb-import.md b/docs/features/cmdb-import.md new file mode 100644 index 000000000..b9d505abc --- /dev/null +++ b/docs/features/cmdb-import.md @@ -0,0 +1,365 @@ + + +# CMDB import + +Imports a TOPdesk CMDB export (an Excel workbook, `.xlsx`) for one +municipality. Every application row of the two CMDB sheets becomes, or +updates: + +- a **module** (the application, `schema:SoftwareApplication`); +- its vendor (the maker of the software) as an **organisation** of type + Supplier; +- a **usage** that links the application to the municipality; +- a **contact person** of the municipality for its owner, with the identity + in Nextcloud Contacts. Owners are never readable by the public. + +All of it is stored as OpenRegister objects in the stackiq register. Import a +newer export later and the same applications are updated, not duplicated. + +Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import)). + +## Who can import + +Only Nextcloud administrators. The import writes into the catalogue of +any municipality, past OpenRegister's access rules and organisation +boundaries, so it is not open to the groups an administrator delegated +stackiq's admin settings to (**Administration settings → Administration +privileges**), nor to members of `software-catalog-admins`. The section is +part of stackiq's admin settings, under **Administration settings → Stackiq +→ CMDB import**. + +## Before you start + +The import itself only needs stackiq and OpenRegister. To see the imported +applications in the other apps, two things must be set up there. The import +does not change either of them. + +**OpenCatalogi (search).** OpenCatalogi lists an application only through a +catalogue that includes the register `stackiq` and the schema `module`. In +OpenCatalogi, open the catalogue that should show the municipality's +applications and add that register and schema. A newly imported module gets +a publication date (the moment the import started), so it is listed from then +on, unless you turn off **Publish the applications this import creates** +(see the steps below). + +**Portaliq ("Software we use").** Portaliq shows an application to a +municipality through a usage whose consumer is that municipality. The portal +account of the municipality needs the claim `stackiq.organisationId` set to +the uuid of the municipality organisation the import used. The uuid is in +the import result (the municipality line) and on the organisation's detail +page in stackiq. + +## Steps + +1. Open **Administration settings → Stackiq** and scroll to **CMDB import**. +2. **Municipality.** Pick an existing organisation of type Municipality from + the list, or type the name of a new one and press Enter. The list shows + only active municipalities, not merged or inactive ones; municipalities + with the same name show the start of their id after the name. A typed name + is sent to the server as a name, and a name that matches one existing + municipality (ignoring case and extra spaces) uses that municipality; otherwise a new organisation of type Municipality with + status Active is created during the import, and the result warns about + it. When more than one municipality has the typed name, the import is + refused (`MUNICIPALITY_AMBIGUOUS`): pick the right one from the list. +3. **File.** Choose the TOPdesk export (`.xlsx`, at most 10 MB by default; see + [Limits](#limits)). +4. **Update existing records.** On by default. Turn it off to import only + applications that are new for this municipality; rows that match an + existing application are then reported as *skipped* with reason `exists` + and nothing about them changes. +5. **Publish the applications this import creates.** On by default. A + published application is visible to anyone, including anonymous visitors + of OpenCatalogi. Turn it off to create the new applications without a + publication date; they stay unpublished until you publish them by hand, + and the summary counts them as *created unpublished*. Applications that + were imported before keep their publication as it is, whichever you + choose. +6. Press **Import**. A progress bar shows how many rows have been processed. + **Cancel import** stops the import before the next row; rows that were + already processed stay imported. The section says whether the server + accepted the cancel; one pressed before the server has started on the + rows cannot take effect yet, and the section says so. + +One import runs at a time. While an import is running, a second one, from +another administrator or another browser tab, is refused with +`IMPORT_IN_PROGRESS` and reads nothing; start it again when the first has +finished. + +When the import finishes, the section shows: + +- the **summary**: rows read, created, updated, unchanged, skipped, failed + and warnings, and, when publishing was off, how many applications were + created unpublished; +- **warnings for the whole file**, for example an optional column that is + missing; +- the **rows** table: sheet, row number, APPID, application, outcome, + and the reasons and warnings for that row. Filter it with **Show rows with + outcome**, and sort it by clicking a column header. It shows 100 rows at a + time; **Show 100 more rows** adds the next ones. The application name links + to the module in stackiq. + +The municipality stays selected after an import, so a second import goes to +the same organisation. + +## The file + +The import reads the two CMDB sheets of the export and ignores all others, +including the `Invoer` sheets they are derived from: + +| Sheet | What it holds | Recorded on the usage | +|---|---|---| +| `Onbeh Applicaties CMDB` | applications **without** arranged maintenance (from the AIA export) | `Beheer geregeld: nee` | +| `Beheerde Applicaties CMDB` | applications **with** arranged maintenance (from the APP export) | `Beheer geregeld: ja` | + +At least one of the two must be present. Row 1 of each sheet holds the +column names. Columns are found by name per sheet, not by position: case, +surrounding spaces and a trailing `:` or `⚡` do not matter, and the order of +the columns does not matter. A column that one sheet has and the other has +not (such as `Nickname`, only on `Beheerde Applicaties CMDB`) is optional on +the sheet that lacks it. Empty rows, including formatted rows below the data, +are ignored and not counted. A sheet may hold at most 10,000 rows with data +by default (see [Limits](#limits)). + +Two columns are **required** on every CMDB sheet that is present: `APPID` +and `Applicatie Naam`. Every other column is optional; when one is missing, +the import names it once in the warnings for the whole file. + +**Formulas.** The CMDB sheets are formulas that read the `Invoer` sheets. +The import reads the value Excel stored with each formula cell; formulas are +never calculated. Save the workbook in Excel before importing it, so every +formula has a stored value. A formula without a stored value is read as an +empty cell and the row carries the warning `Column "…": formula without a +cached value, read as empty`; the row is still imported. A stored `0` is what +Excel shows for a reference to an empty cell, and is read as empty too. +External data connections, Power Query queries and links in the workbook are +never opened. Macro-enabled workbooks (`.xlsm`), old Excel files (`.xls`) and +CSV files are not accepted. + +**Placeholder values.** The CMDB sheets fill an empty `BNN Classificatie` +with `NB` ("niet bekend"); that is read as empty. Dates are stored as the +file has them: the CMDB's 2036-01-01 in `End-of-Life Functioneel` is imported +as 2036-01-01. The CMDB writes that date when TOPdesk has no end-of-life date, +so it is a stand-in for "no date". It is stored as the phase-out date of the +usage and shown like any real one, and the usage reads as "Phased out" +once that date is reached. A re-import with **Update existing records** on +fills the date where it was left empty before. It also replaces a different +date stored there, including one set by hand. + +### Columns and where they go + +| Column | Goes to | Rule | +|---|---|---| +| APPID | module external number, and the match key | required, see [Repeat imports](#repeat-imports) | +| Applicatie Naam | module name | required | +| Applicatie Code | module external id | reference only; it can change in TOPdesk, so it is not the match key | +| Roepnaam, Nickname | module short description | Roepnaam when filled, otherwise Nickname (only on `Beheerde Applicaties CMDB`) | +| Functionele Omschrijving | module long description | | +| Applicatiesoort | module application type (`applicationType`) and hosting model (`cloudDienstverleningsmodel`) | stored as is as the application type (Webapplicatie, Client/server, Saas, …); `Saas` → SaaS, `PaaS` → PaaS, `IaaS` → IaaS, `On-premise(s)` → On-premises (self-managed) also set the hosting model, any other kind leaves it empty | +| BNN Classificatie | module BBN level | `1`, `2`, `2+`, `3` (and `BBN1`/`BBN 1`/`BNN1` etc.) become `BBN1`, `BBN2`, `BBN2+`, `BBN3`; `NB` is empty; another value is dropped with a warning | +| Datum | module external creation date | Excel date | +| Referentie datum wijziging | module external modification date | Excel date | +| Vendor | Supplier organisation, set as provider on the module and the usage | one organisation per name, see below | +| Applicatie Status | usage status | follows the export on every import (see [Repeat imports](#repeat-imports)); In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren and Moet verwijderd worden → To be phased out, Uitgefaseerd and Verwijderd → Phased out, Besteld and Wordt getest → Acquisition, Stand-by voor continuïteit → In production; another value is dropped with a warning | +| Classificatie | usage TIME classification | set only when the usage is new or its TIME classification is empty; Tolereren/Tolerate (also `1. Tolereren (wordt ingelezen)`), Investeren/Invest, Migreren/Migrate, Elimineren/Eliminate | +| End-of-Life Functioneel | usage phase-out date | Excel date, stored as is | +| (the sheet), Cluster, Applicatie Eigenaar (Afdeling) | usage internal annotation | `Beheer geregeld: ja` or `nee`, the cluster and the department, joined with ` / `; written only when the usage is new or the note is empty | +| Applicatie Eigenaar (Persoon), Applicatie Eigenaar (Functie) | usage business owner (contact person) | see [Owners](#owners) | + +Columns not in this table are not read at all. That includes Hostingpartij +and Leverancier (not mapped yet), the BIV and value columns (Beschikbaarheid, +Integriteit, Vertrouwelijkheid, Applicatienut and the like), Behandelgroep, +Cloud, Rappeldatum, Rappelreden, Locatie BIOToets, Software Suite, Standaard, +Top5, COTS and Applicatie Nummer. + +**Vendors.** Names are compared after trimming, collapsing spaces and +ignoring case, so `Fabfrikant`, `Fabfrikant ` and `FABFRIKANT` are one +Supplier. An existing organisation with the same name is reused: a +Municipality first, then a Supplier. A municipality that builds its own +applications (Vendor `Gemeente Rotterdam`) is therefore its own provider, +not a second organisation. Only a name no organisation has creates a +Supplier. A row without a vendor is imported without a provider. + +## Repeat imports + +An application is recognised by its **APPID within the municipality**: the +match key is `topdesk::`. The APPID (TOPdesk's ICT +Applicatienummer) stays the same when TOPdesk changes the Applicatie Code +(Middel-ID). Two municipalities can each have an APPID `101` without +colliding. + +- **New APPID**: a module and a usage are created. With **Publish the + applications this import creates** on, the module gets a publication date + (the moment the import started), so OpenCatalogi lists it; with it off, + the module has no publication date and is not public. +- **Known APPID, values changed**: only the fields in the column table + are updated, and of those, the usage's TIME classification and internal + note only when they are empty: a classification set in stackiq stays, + whatever the export says. A re-import does overwrite the application's + name, descriptions, application type, hosting model, BBN level, source + fields and supplier, and the usage's status, phase-out date and business + owner, so a status change in TOPdesk comes through. Everything else on the module stays as it is, for example a + website an administrator added. The publication date and the depublication + date are never changed: a module an administrator depublished stays + depublished. The row is reported as *updated*. +- **Known APPID, status changed in TOPdesk**: the usage gets the new status, + also where the regular steps of the usage lifecycle (Acquisition → + Planned → In production → To be phased out → Phased out) do not lead + there. The usage schema allows this jump to administrators only; the + import is open to Nextcloud administrators only, so it may always make + it. Other users still follow the regular steps. +- **Known APPID, nothing changed**: nothing is saved; the row is reported + as *unchanged*. Importing the same export twice creates nothing the second + time. +- **APPID missing from a newer export**: the application, its usage and + its contact persons are left as they are. They are not changed, depublished + or deleted. +- Each application keeps exactly one usage for the municipality. +- **Known APPID, but the application belongs to another organisation**: an + application found by its match key is only updated when the municipality + already uses it, or when no organisation uses it yet. When only other + organisations use it, the row is *skipped* with reason `conflict: the + application with this import key is used by another organisation, so it + is not changed`; nothing is changed and no second application is created. + Check the application's import key in stackiq. Only a Nextcloud + administrator can change an import key. + +Rows are **skipped** when the APPID is empty (`missing APPID`), when the +Applicatie Naam is empty (`missing Applicatie Naam`), when an APPID appears a +second time in the same upload (`duplicate APPID in file`), or, with **Update +existing records** off, when the application already exists (`exists`). On +one sheet the first occurrence is imported. When an APPID is on both sheets, +the `Beheerde Applicaties CMDB` row is imported and the `Onbeh Applicaties +CMDB` row is skipped, with a warning naming the APPID. + +An application that moves from `Onbeh Applicaties CMDB` to `Beheerde +Applicaties CMDB` keeps its module and usage (same APPID); its internal note +is not rewritten when it already has one. + +Every row is processed on its own. When one row fails, for example because +OpenRegister refuses to save it, that row is reported as *failed* with the +step that failed, and the other rows are imported. Importing again completes +the failed row. + +## Owners + +The owner becomes a **contact person of the municipality**, never a +Nextcloud user account. It comes from `Applicatie Eigenaar (Persoon)`; its +function (`Applicatie Eigenaar (Functie)`) is stored as the contact person's +role, and the department (`Applicatie Eigenaar (Afdeling)`) goes into the +usage's internal note. When TOPdesk has no owner, the CMDB sheet shows the +owner's function in the person column; the import then uses that function as +the contact's name. No technical owner is imported: the functional +administrator (FB contactpersoon) is not read. + +The identity is kept in **Nextcloud Contacts**, in a dedicated address book, +**Stackiq CMDB owners**, of the administrator who runs the import; the import +creates that address book the first time it needs it. The import looks for +an existing contact only in that address book, never in the administrator's +own address books: a personal contact who happens to have the owner's name +is not linked to the application. The CMDB sheets have no e-mail address, so +a contact is found by an exact match on the name in **Stackiq CMDB owners**, +and created there when there is none. The +stackiq contact person object only holds the link to that contact, the role +and the municipality. The same owner on several rows is one contact person. + +When the Contacts app is disabled, applications and usages are still +imported; the owners are skipped and each affected row carries a warning. + +**Never public.** Contact persons and usages have no public read rule, so an +anonymous visitor cannot read them through OpenRegister, and a published +module in an OpenCatalogi search result refers to them by id at most. The +import report and the Nextcloud log never contain owner names. + +## Errors and what to do + +When the file or the request cannot be imported at all, nothing is written +and the section shows the reason and the error code. + +| Error code | What it means | What to do | +|---|---|---| +| `NOT_XLSX` | The file is not an Excel workbook: wrong extension, or the content is not an `.xlsx` package. | Save the export as Excel workbook (`.xlsx`). | +| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read, or split the export. | +| `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. | +| `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. | +| `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. | +| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. | +| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. | +| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other. | +| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import them one after the other. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. | +| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. | +| `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. | +| `FIELD_INVALID` | A form field of the request has a value the import does not accept, for example an `updateExisting` or `publish` that is neither `true` nor `false`. The message names the field. | Not reachable from the section; reported for API callers. | +| `UPLOAD_FAILED` | The file reached the server but could not be stored there. | Try again; the Nextcloud log has the details. | +| `MISSING_RECORDS_UNSUPPORTED` | The request asked to mark or remove records missing from the export. Only keeping them is supported. | Not reachable from the section; reported for API callers. | +| `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine is missing, or one of the mapping files is invalid. | Update OpenRegister. If you changed a mapping file, check it against the Nextcloud log. | +| `READER_UNAVAILABLE` | The Excel reader that ships with OpenRegister cannot be loaded. | Make sure OpenRegister is installed and enabled. | +| `NOT_CONFIGURED` | The stackiq register or its schemas cannot be found. | Run **Auto Configure** at the top of the stackiq admin settings. | +| `SCHEMA_OUTDATED` | A stackiq schema lacks a property the import recognises records by, for example `externalKey` on the module schema, or the module schema is older than version 0.3.8: it has no `applicationType`, or `externalKey` lacks the rule that only an administrator may change it. Importing anyway would create every application again, or leave the import key open to every editor. The message names the schema. | Press **Force Update** at the top of the stackiq admin settings to import the register configuration again. | +| `IMPORT_FAILED` | Something unexpected went wrong. | The Nextcloud log has the details. | + +**The connection was cut off.** The import runs in one request. When that +request ends without an answer from the import (the connection drops, or a +proxy gives up waiting with a 502, 503 or 504), the import may still be +running on the server. The section then keeps following the import's +progress and shows its report when it finishes. When it cannot find out, +it shows `IMPORT_INTERRUPTED`: wait a few minutes and check the +municipality's applications before importing again. Importing the same file +again creates no duplicates. + +A message that you are not signed in, not an administrator, or that your +session expired comes from Nextcloud itself: sign in again, use an +administrator account (see [Who can import](#who-can-import)), or reload the +page. + +## Limits + +Five limits are read from `lib/Settings/cmdb-import/topdesk-profile.json` on +every import: + +| Setting | Default | What it limits | +|---|---|---| +| `maxFileBytes` | `10485760` (10 MB) | the size of the uploaded file | +| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet | +| `maxUncompressedBytes` | `52428800` (50 MB) | the size of the workbook once unpacked, checked before a sheet is parsed | +| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet | +| `maxSharedStrings` | `200000` | the number of different texts in the workbook's shared-strings table, each formatted piece of a text with mixed formatting counted as one more, also in text written in a cell itself, counted before a sheet is parsed | +| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per CMDB sheet before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated | + +The section's help text shows the defaults; when the server refuses a file, +the message shows the limit the server applied. A larger file also has to +pass PHP's `upload_max_filesize` and `post_max_size` and the web server's +request size limit. Like the mapping files, the profile is part of the app: +a change made on the server is overwritten by the next app update. + +## Adjusting the mapping + +The mapping from columns to fields is not in code. It is a set of JSON files +in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine: + +| File | What it maps | +|---|---| +| `topdesk-profile.json` | the sheets, the constant each sheet adds to its rows (`Beheer`) and the columns it is known to lack, the match column, the required, date and id columns, the placeholder values that mean empty, the limits, and which pack is used for which target | +| `topdesk-module.json` | a row to the module (hosting model and BBN lookups) | +| `topdesk-manufacturer.json` | "Vendor" to the Supplier organisation | +| `topdesk-municipality.json` | a typed municipality name to a new organisation | +| `topdesk-usage.json` | a row to the usage (status and TIME lookups, dates, annotation) | +| `topdesk-business-owner.json` | the owner columns | + +Each pack has a list of `fieldMappings`, one per column: `source` (the column +name in the export), `target` (the field), optionally `required`, and a +`transform` such as `trim`, `date` or a `lookup` with a `map` of export values +to stored values. For example, to accept a new "Applicatiesoort" value, add +it to the `map` of the hosting-model lookup in `topdesk-module.json`: + +```json +"Cloud": ["SaaS"] +``` + +To accept a new "Applicatie Status" value, add it to the `map` of the status +lookup in `topdesk-usage.json`. The packs are checked by OpenRegister when an import +starts; an invalid pack stops the import with `MAPPING_UNAVAILABLE` before +any row is read. A mapping file changed on the server is overwritten by the +next app update, so propose lasting changes to the app itself. 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/docs/features/service-desk-exchange.md b/docs/features/service-desk-exchange.md new file mode 100644 index 000000000..d1d3f6b46 --- /dev/null +++ b/docs/features/service-desk-exchange.md @@ -0,0 +1,57 @@ + + +# Service desk exchange + +Stackiq is your CMDB for applications. It keeps the applications you use, their connections, licences and contracts in step with TOPdesk or ServiceNow, in both directions. Stackiq never holds the service desk password: integriq does. + +Specification: [`openspec/changes/sharing-itsm-exchange`](https://github.com/ConductionNL/stackiq/blob/development/openspec/changes/sharing-itsm-exchange/). + +## Who owns which field + +Each field has one owner, written in integriq's mapping. The owner's value wins. A timestamp never decides. + +| Field | Owner | +|---|---| +| Name, supplier, installed version, status, the record id and link | The service desk | +| BBN level, TIME class, publication | Stackiq | +| Licence and contract fields: number, supplier reference, type, start, end, cost, currency, metric, licences bought | Stackiq | +| Relations between applications | The service desk | + +The import only writes fields the service desk owns on a record stackiq already has. The export only sends fields stackiq owns on a record the service desk already has. A new record gets every field, from whichever side creates it. + +A licence or contract the service desk knows and stackiq does not is created with all its fields. After that stackiq owns its licence and contract fields. + +## Set it up + +1. In integriq, open the TOPdesk or ServiceNow source. Fill in your tenant address, the login name, and the password as a credential. +2. In the admin settings, open **Service desk exchange**. +3. Choose the service desk and your organisation. For TOPdesk, also enter the id of your Application asset template: TOPdesk needs it to create an asset. +4. Choose **Set up the exchange**. + +Stackiq checks every flow with OpenRegister before it saves any. If one is refused, nothing is created and the message names the step and the reason. Setting up again updates the same flows. + +The flows show on stackiq's **Flows** page. The imports run every night at 02:00. A change to a stackiq-owned field goes to the service desk within a minute. + +## What the import does + +- It reads applications, relations, licences and contracts from the service desk. +- It matches an application on its service desk record first, then on name and supplier. +- It creates the supplier, the application and your organisation's use of it when there is no match. +- A second run updates. It never adds a second copy. + +A near-duplicate, such as a spelling variant of an application that already exists, is created as a new application. It then shows up on OpenRegister's duplicate candidates page, where a steward merges the two. + +## Why nothing echoes back + +Each direction only writes its own fields, and each skips a record whose own fields did not change. So an import never triggers an export call, and an export never comes back as an import write. + +## Import a file + +No service desk API? On the **CMDB** page, import a CSV or XLSX file. Use these columns: `recordId`, `name`, `supplierName`, `installedVersion`, `status`. Every row needs a `recordId`. Importing the same file again updates the applications instead of adding them twice. + +## Next + +Open the **CMDB** page under Applications to see what stackiq records, and connect your service desk from there. 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..70c3bfcef 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,411 @@ 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.", + "CMDB": "CMDB", + "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.", + "What stackiq records": "What stackiq records", + "What stackiq does not record": "What stackiq does not record", + "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.", + "Service desk exchange": "Service desk exchange", + "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.", + "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.", + "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.", + "Change the exchange": "Change the exchange", + "Connect a service desk": "Connect a service desk", + "Import a file": "Import a file", + "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.", + "File to import": "File to import", + "Import file": "Import file", + "What your organisation runs, which version, and who owns it.": "What your organisation runs, which version, and who owns it.", + "Applications and their components": "Applications and their components", + "The products, from which supplier, and what they consist of.": "The products, from which supplier, and what they consist of.", + "Which application exchanges data with which.": "Which application exchanges data with which.", + "Licences and contracts": "Licences and contracts", + "What you bought, how many licences, until when and at what cost.": "What you bought, how many licences, until when and at what cost.", + "The import of {rows} rows has started. The applications appear as the run goes.": "The import of {rows} rows has started. The applications appear as the run goes.", + "The import did not start.": "The import did not start.", + "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.", + "Loading the service desk exchange…": "Loading the service desk exchange…", + "OpenRegister's flow engine is not available, so the exchange cannot run.": "OpenRegister's flow engine is not available, so the exchange cannot run.", + "Set up for {desk} on {date}. The import runs every night at 02:00.": "Set up for {desk} on {date}. The import runs every night at 02:00.", + "Service desk": "Service desk", + "Choose TOPdesk or ServiceNow": "Choose TOPdesk or ServiceNow", + "Your organisation": "Your organisation", + "The organisation whose applications are exchanged": "The organisation whose applications are exchanged", + "Set up again": "Set up again", + "Set up the exchange": "Set up the exchange", + "{count} flows are set up. The first import runs tonight.": "{count} flows are set up. The first import runs tonight.", + "The service desk this record is kept in step with.": "The service desk this record is kept in step with.", + "TOPdesk": "TOPdesk", + "ServiceNow": "ServiceNow", + "File import": "File import", + "Service desk record": "Service desk record", + "The record id in the service desk. The import matches on it first.": "The record id in the service desk. The import matches on it first.", + "Service desk link": "Service desk link", + "Opens the record in the service desk.": "Opens the record in the service desk.", + "Last synchronised": "Last synchronised", + "When the service desk exchange last wrote this record.": "When the service desk exchange last wrote this record.", + "Installed version": "Installed version", + "The version the service desk records as installed. The service desk owns it.": "The version the service desk records as installed. The service desk owns it.", + "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.", + "Supplier reference": "Supplier reference", + "The supplier's own number for this contract or licence agreement.": "The supplier's own number for this contract or licence agreement.", + "Currency": "Currency", + "The currency of the costs, as a three-letter ISO 4217 code.": "The currency of the costs, as a three-letter ISO 4217 code.", + "The organisation this contract or licence was bought from.": "The organisation this contract or licence was bought from.", + "TOPdesk asset template id": "TOPdesk asset template id", + "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.", + "{processed} of {total} rows processed": "{processed} of {total} rows processed", + "{size} KB": "{size} KB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "A new municipality \"{name}\" is created, unless one with this name already exists.", + "A sheet has more rows than the import can process.": "A sheet has more rows than the import can process.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.", + "All outcomes": "All outcomes", + "Check the connection and try again.": "Check the connection and try again.", + "Choose a municipality first.": "Choose a municipality first.", + "Choose or type a municipality": "Choose or type a municipality", + "Choose the TOPdesk export": "Choose the TOPdesk export", + "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.", + "CMDB import": "CMDB import", + "Created": "Created", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.", + "Error code: {code}": "Error code: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.", + "Failed": "Failed", + "Import": "Import", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import progress": "Import progress", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.", + "Importing the export…": "Importing the export…", + "Importing…": "Importing…", + "APPID": "APPID", + "Municipality": "Municipality", + "No file was uploaded.": "No file was uploaded.", + "No rows with this outcome": "No rows with this outcome", + "Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.", + "Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.", + "Outcome": "Outcome", + "Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.", + "Reasons and warnings": "Reasons and warnings", + "Records missing from the export can only be kept.": "Records missing from the export can only be kept.", + "Reload the page and try again.": "Reload the page and try again.", + "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.", + "Row": "Row", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.", + "Rows": "Rows", + "Rows read": "Rows read", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.", + "Sheet": "Sheet", + "Show rows with outcome": "Show rows with outcome", + "Sign in again and retry the import.": "Sign in again and retry the import.", + "Skipped": "Skipped", + "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", + "The Excel reader is not available.": "The Excel reader is not available.", + "The file": "The file", + "The file is larger than 10 MB.": "The file is larger than 10 MB.", + "The import failed unexpectedly.": "The import failed unexpectedly.", + "The import mapping cannot run.": "The import mapping cannot run.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.", + "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.", + "The server could not be reached.": "The server could not be reached.", + "The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.", + "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.", + "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).", + "This import is no longer running.": "This import is no longer running.", + "Unchanged": "Unchanged", + "Update existing records": "Update existing records", + "Updated": "Updated", + "Warnings": "Warnings", + "Warnings for the whole file": "Warnings for the whole file", + "When off, applications imported before are left as they are and reported as skipped.": "When off, applications imported before are left as they are and reported as skipped.", + "You are not signed in.": "You are not signed in.", + "Your session has expired.": "Your session has expired.", + "Stackiq is not configured for the import.": "Stackiq is not configured for the import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "The sheet \"{sheet}\" has more rows than the import can process.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.", + "Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.", + "No running CMDB import has this id.": "No running CMDB import has this id.", + "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.", + "Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "The Excel reader is not available: OpenRegister is missing or incomplete.", + "The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.", + "The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.", + "The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.", + "Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optional column \"%s\" not found", + "Owner from column \"%s\" could not be resolved": "Owner from column \"%s\" could not be resolved", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Owner from column \"%s\" could not be resolved in Nextcloud Contacts", + "Owners skipped: Nextcloud Contacts is unavailable": "Owners skipped: Nextcloud Contacts is unavailable", + "duplicate %s in file": "duplicate %s in file", + "exists": "exists", + "missing %s": "missing %s", + "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", + "step \"%s\" failed": "step \"%s\" failed", + "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.", + "Source id": "Source id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.", + "Source number": "Source number", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.", + "Import key": "Import key", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.", + "Created in source": "Created in source", + "The date the application was registered in the source system.": "The date the application was registered in the source system.", + "Changed in source": "Changed in source", + "The date the application was last changed in the source system.": "The date the application was last changed in the source system.", + "Application published": "Application published", + "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.", + "Application registered by": "Application registered by", + "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.", + "The file could not be sent. Check your connection and try again.": "The file could not be sent. Check your connection and try again.", + "The organisation register is not configured, so there are no organisations to choose from.": "The organisation register is not configured, so there are no organisations to choose from.", + "The organisations could not be loaded. Reload the page to try again.": "The organisations could not be loaded. Reload the page to try again.", + "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Whether a service desk is connected could not be loaded. Reload the page to try again.", + "Application type": "Application type", + "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.", + "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.", + "Field \"%1$s\" must be one of: %2$s.": "Field \"%1$s\" must be one of: %2$s.", + "Field \"%s\" has an invalid value.": "Field \"%s\" has an invalid value.", + "The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.", + "The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.", + "The file is larger than the server accepts.": "The file is larger than the server accepts.", + "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.", + "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.", + "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.", + "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.", + "The page got no answer from the import.": "The page got no answer from the import.", + "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.", + "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.", + "The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}", + "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.", + "Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.", + "Show {count} more rows": "Show {count} more rows", + "The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.", + "Accepted values: {accepted}. Reload the page and try again.": "Accepted values: {accepted}. Reload the page and try again.", + "The server could not store the uploaded file.": "The server could not store the uploaded file.", + "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.", + "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.", + "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported", + "Stackiq CMDB owners": "Stackiq CMDB owners", + "step \"%1$s\" failed (%2$s)": "step \"%1$s\" failed (%2$s)", + "The workbook is too large to read once unpacked.": "The workbook is too large to read once unpacked.", + "The stackiq register is out of date; import its configuration again.": "The stackiq register is out of date; import its configuration again.", + "Another CMDB import is running; try again when it has finished.": "Another CMDB import is running; try again when it has finished.", + "Several municipalities have this name; choose one from the list.": "Several municipalities have this name; choose one from the list.", + "Unpacked, the workbook is larger than {size}, the most the import reads.": "Unpacked, the workbook is larger than {size}, the most the import reads.", + "The \"{schema}\" schema of the stackiq register is out of date.": "The \"{schema}\" schema of the stackiq register is out of date.", + "The stackiq register is out of date.": "The stackiq register is out of date.", + "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.", + "Another CMDB import is running.": "Another CMDB import is running.", + "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.", + "{count} municipalities have this name.": "{count} municipalities have this name.", + "Several municipalities have this name.": "Several municipalities have this name.", + "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Choose the municipality from the list instead of typing its name. Nothing was imported.", + "Publish the applications this import creates": "Publish the applications this import creates", + "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.", + "Created unpublished": "Created unpublished", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.", + "conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed", + "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.", + "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.", + "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.", + "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.", + "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.", + "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.", + "The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.", + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 81aaa9c63..c1996692c 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,410 @@ "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.", + "CMDB": "CMDB", + "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.", + "What stackiq records": "What stackiq records", + "What stackiq does not record": "What stackiq does not record", + "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.", + "Service desk exchange": "Service desk exchange", + "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.", + "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.", + "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.", + "Change the exchange": "Change the exchange", + "Connect a service desk": "Connect a service desk", + "Import a file": "Import a file", + "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.", + "File to import": "File to import", + "Import file": "Import file", + "What your organisation runs, which version, and who owns it.": "What your organisation runs, which version, and who owns it.", + "Applications and their components": "Applications and their components", + "The products, from which supplier, and what they consist of.": "The products, from which supplier, and what they consist of.", + "Which application exchanges data with which.": "Which application exchanges data with which.", + "Licences and contracts": "Licences and contracts", + "What you bought, how many licences, until when and at what cost.": "What you bought, how many licences, until when and at what cost.", + "The import of {rows} rows has started. The applications appear as the run goes.": "The import of {rows} rows has started. The applications appear as the run goes.", + "The import did not start.": "The import did not start.", + "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.", + "Loading the service desk exchange…": "Loading the service desk exchange…", + "OpenRegister's flow engine is not available, so the exchange cannot run.": "OpenRegister's flow engine is not available, so the exchange cannot run.", + "Set up for {desk} on {date}. The import runs every night at 02:00.": "Set up for {desk} on {date}. The import runs every night at 02:00.", + "Service desk": "Service desk", + "Choose TOPdesk or ServiceNow": "Choose TOPdesk or ServiceNow", + "Your organisation": "Your organisation", + "The organisation whose applications are exchanged": "The organisation whose applications are exchanged", + "Set up again": "Set up again", + "Set up the exchange": "Set up the exchange", + "{count} flows are set up. The first import runs tonight.": "{count} flows are set up. The first import runs tonight.", + "The service desk this record is kept in step with.": "The service desk this record is kept in step with.", + "TOPdesk": "TOPdesk", + "ServiceNow": "ServiceNow", + "File import": "File import", + "Service desk record": "Service desk record", + "The record id in the service desk. The import matches on it first.": "The record id in the service desk. The import matches on it first.", + "Service desk link": "Service desk link", + "Opens the record in the service desk.": "Opens the record in the service desk.", + "Last synchronised": "Last synchronised", + "When the service desk exchange last wrote this record.": "When the service desk exchange last wrote this record.", + "Installed version": "Installed version", + "The version the service desk records as installed. The service desk owns it.": "The version the service desk records as installed. The service desk owns it.", + "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.", + "Supplier reference": "Supplier reference", + "The supplier's own number for this contract or licence agreement.": "The supplier's own number for this contract or licence agreement.", + "Currency": "Currency", + "The currency of the costs, as a three-letter ISO 4217 code.": "The currency of the costs, as a three-letter ISO 4217 code.", + "The organisation this contract or licence was bought from.": "The organisation this contract or licence was bought from.", + "TOPdesk asset template id": "TOPdesk asset template id", + "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.", + "{processed} of {total} rows processed": "{processed} of {total} rows processed", + "{size} KB": "{size} KB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "A new municipality \"{name}\" is created, unless one with this name already exists.", + "A sheet has more rows than the import can process.": "A sheet has more rows than the import can process.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.", + "All outcomes": "All outcomes", + "Check the connection and try again.": "Check the connection and try again.", + "Choose a municipality first.": "Choose a municipality first.", + "Choose or type a municipality": "Choose or type a municipality", + "Choose the TOPdesk export": "Choose the TOPdesk export", + "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.", + "CMDB import": "CMDB import", + "Created": "Created", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.", + "Error code: {code}": "Error code: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.", + "Failed": "Failed", + "Import": "Import", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import progress": "Import progress", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.", + "Importing the export…": "Importing the export…", + "Importing…": "Importing…", + "APPID": "APPID", + "Municipality": "Municipality", + "No file was uploaded.": "No file was uploaded.", + "No rows with this outcome": "No rows with this outcome", + "Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.", + "Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.", + "Outcome": "Outcome", + "Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.", + "Reasons and warnings": "Reasons and warnings", + "Records missing from the export can only be kept.": "Records missing from the export can only be kept.", + "Reload the page and try again.": "Reload the page and try again.", + "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.", + "Row": "Row", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.", + "Rows": "Rows", + "Rows read": "Rows read", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.", + "Sheet": "Sheet", + "Show rows with outcome": "Show rows with outcome", + "Sign in again and retry the import.": "Sign in again and retry the import.", + "Skipped": "Skipped", + "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", + "The Excel reader is not available.": "The Excel reader is not available.", + "The file": "The file", + "The file is larger than 10 MB.": "The file is larger than 10 MB.", + "The import failed unexpectedly.": "The import failed unexpectedly.", + "The import mapping cannot run.": "The import mapping cannot run.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.", + "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.", + "The server could not be reached.": "The server could not be reached.", + "The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.", + "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.", + "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).", + "This import is no longer running.": "This import is no longer running.", + "Unchanged": "Unchanged", + "Update existing records": "Update existing records", + "Updated": "Updated", + "Warnings": "Warnings", + "Warnings for the whole file": "Warnings for the whole file", + "When off, applications imported before are left as they are and reported as skipped.": "When off, applications imported before are left as they are and reported as skipped.", + "You are not signed in.": "You are not signed in.", + "Your session has expired.": "Your session has expired.", + "Stackiq is not configured for the import.": "Stackiq is not configured for the import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "The sheet \"{sheet}\" has more rows than the import can process.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.", + "Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.", + "No running CMDB import has this id.": "No running CMDB import has this id.", + "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.", + "Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "The Excel reader is not available: OpenRegister is missing or incomplete.", + "The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.", + "The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.", + "The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.", + "Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optional column \"%s\" not found", + "Owner from column \"%s\" could not be resolved": "Owner from column \"%s\" could not be resolved", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Owner from column \"%s\" could not be resolved in Nextcloud Contacts", + "Owners skipped: Nextcloud Contacts is unavailable": "Owners skipped: Nextcloud Contacts is unavailable", + "duplicate %s in file": "duplicate %s in file", + "exists": "exists", + "missing %s": "missing %s", + "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", + "step \"%s\" failed": "step \"%s\" failed", + "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.", + "Source id": "Source id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.", + "Source number": "Source number", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.", + "Import key": "Import key", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.", + "Created in source": "Created in source", + "The date the application was registered in the source system.": "The date the application was registered in the source system.", + "Changed in source": "Changed in source", + "The date the application was last changed in the source system.": "The date the application was last changed in the source system.", + "Application published": "Application published", + "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.", + "Application registered by": "Application registered by", + "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.", + "The file could not be sent. Check your connection and try again.": "The file could not be sent. Check your connection and try again.", + "The organisation register is not configured, so there are no organisations to choose from.": "The organisation register is not configured, so there are no organisations to choose from.", + "The organisations could not be loaded. Reload the page to try again.": "The organisations could not be loaded. Reload the page to try again.", + "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Whether a service desk is connected could not be loaded. Reload the page to try again.", + "Application type": "Application type", + "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.", + "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.", + "Field \"%1$s\" must be one of: %2$s.": "Field \"%1$s\" must be one of: %2$s.", + "Field \"%s\" has an invalid value.": "Field \"%s\" has an invalid value.", + "The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.", + "The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.", + "The file is larger than the server accepts.": "The file is larger than the server accepts.", + "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.", + "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.", + "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.", + "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.", + "The page got no answer from the import.": "The page got no answer from the import.", + "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.", + "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.", + "The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}", + "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.", + "Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.", + "Show {count} more rows": "Show {count} more rows", + "The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.", + "Accepted values: {accepted}. Reload the page and try again.": "Accepted values: {accepted}. Reload the page and try again.", + "The server could not store the uploaded file.": "The server could not store the uploaded file.", + "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.", + "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.", + "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported", + "Stackiq CMDB owners": "Stackiq CMDB owners", + "step \"%1$s\" failed (%2$s)": "step \"%1$s\" failed (%2$s)", + "The workbook is too large to read once unpacked.": "The workbook is too large to read once unpacked.", + "The stackiq register is out of date; import its configuration again.": "The stackiq register is out of date; import its configuration again.", + "Another CMDB import is running; try again when it has finished.": "Another CMDB import is running; try again when it has finished.", + "Several municipalities have this name; choose one from the list.": "Several municipalities have this name; choose one from the list.", + "Unpacked, the workbook is larger than {size}, the most the import reads.": "Unpacked, the workbook is larger than {size}, the most the import reads.", + "The \"{schema}\" schema of the stackiq register is out of date.": "The \"{schema}\" schema of the stackiq register is out of date.", + "The stackiq register is out of date.": "The stackiq register is out of date.", + "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.", + "Another CMDB import is running.": "Another CMDB import is running.", + "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.", + "{count} municipalities have this name.": "{count} municipalities have this name.", + "Several municipalities have this name.": "Several municipalities have this name.", + "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Choose the municipality from the list instead of typing its name. Nothing was imported.", + "Publish the applications this import creates": "Publish the applications this import creates", + "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.", + "Created unpublished": "Created unpublished", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.", + "conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed", + "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.", + "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.", + "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.", + "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.", + "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.", + "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.", + "The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.", + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads." } } diff --git a/l10n/nl.js b/l10n/nl.js index 6c52495c3..b2c3a6257 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,10 +1,30 @@ 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", - "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Elke kaart heeft een eigen knop Laden. Kies \"Geen\" op een productieomgeving.", "None, I will set this up myself": "Geen, ik richt dit zelf in", "Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.", "Example data": "Voorbeeldgegevens", @@ -777,7 +797,408 @@ 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.", + "CMDB": "CMDB", + "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is je configuratiebeheerdatabase voor applicaties. Het legt vast wat je organisatie draait, waaruit het bestaat, hoe het samenhangt en wat je ervoor betaalt.", + "What stackiq records": "Wat stackiq vastlegt", + "What stackiq does not record": "Wat stackiq niet vastlegt", + "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops en netwerkapparatuur leg je hier niet vast, en stackiq ontdekt geen hardware op je netwerk. Meldingen en incidenten blijven in je servicedesk.", + "Service desk exchange": "Uitwisseling met de servicedesk", + "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq en {desk} lopen gelijk. De import draait elke nacht om 02:00, en stackiq stuurt zijn eigen wijzigingen meteen door.", + "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "Er is nog geen servicedesk gekoppeld. Een beheerder koppelt TOPdesk of ServiceNow in de beheerinstellingen.", + "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "De servicedesk is eigenaar van de naam, leverancier, geïnstalleerde versie en status van een applicatie. Stackiq is eigenaar van licenties, contracten, BBN-niveau, TIME-klasse en publicatie. Elke kant overschrijft alleen de velden waarvan hij eigenaar is.", + "Change the exchange": "Uitwisseling wijzigen", + "Connect a service desk": "Servicedesk koppelen", + "Import a file": "Bestand importeren", + "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "Geen API op je servicedesk? Importeer een CSV- of XLSX-bestand met de kolommen recordId, name, supplierName, installedVersion en status. Importeer je hetzelfde bestand opnieuw, dan worden de applicaties bijgewerkt in plaats van dubbel toegevoegd.", + "File to import": "Te importeren bestand", + "Import file": "Bestand importeren", + "What your organisation runs, which version, and who owns it.": "Wat je organisatie draait, welke versie, en wie eigenaar is.", + "Applications and their components": "Applicaties en hun onderdelen", + "The products, from which supplier, and what they consist of.": "De producten, van welke leverancier, en waaruit ze bestaan.", + "Which application exchanges data with which.": "Welke applicatie gegevens uitwisselt met welke.", + "Licences and contracts": "Licenties en contracten", + "What you bought, how many licences, until when and at what cost.": "Wat je hebt gekocht, hoeveel licenties, tot wanneer en tegen welke kosten.", + "The import of {rows} rows has started. The applications appear as the run goes.": "De import van {rows} rijen is gestart. De applicaties verschijnen terwijl de run loopt.", + "The import did not start.": "De import is niet gestart.", + "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Houd applicaties, koppelingen, licenties en contracten gelijk met TOPdesk of ServiceNow. Voeg eerst de servicedeskbron toe in integriq; stackiq bewaart het wachtwoord nooit.", + "Loading the service desk exchange…": "Uitwisseling met de servicedesk laden…", + "OpenRegister's flow engine is not available, so the exchange cannot run.": "De flow-engine van OpenRegister is niet beschikbaar, dus de uitwisseling kan niet draaien.", + "Set up for {desk} on {date}. The import runs every night at 02:00.": "Ingesteld voor {desk} op {date}. De import draait elke nacht om 02:00.", + "Service desk": "Servicedesk", + "Choose TOPdesk or ServiceNow": "Kies TOPdesk of ServiceNow", + "Your organisation": "Je organisatie", + "The organisation whose applications are exchanged": "De organisatie waarvan de applicaties worden uitgewisseld", + "Set up again": "Opnieuw instellen", + "Set up the exchange": "Uitwisseling instellen", + "{count} flows are set up. The first import runs tonight.": "{count} flows zijn ingesteld. De eerste import draait vannacht.", + "The service desk this record is kept in step with.": "De servicedesk waarmee dit record gelijk wordt gehouden.", + "TOPdesk": "TOPdesk", + "ServiceNow": "ServiceNow", + "File import": "Bestandsimport", + "Service desk record": "Servicedeskrecord", + "The record id in the service desk. The import matches on it first.": "Het record-id in de servicedesk. De import zoekt hier eerst op.", + "Service desk link": "Link naar de servicedesk", + "Opens the record in the service desk.": "Opent het record in de servicedesk.", + "Last synchronised": "Laatst gesynchroniseerd", + "When the service desk exchange last wrote this record.": "Wanneer de uitwisseling met de servicedesk dit record het laatst heeft geschreven.", + "Installed version": "Geïnstalleerde versie", + "The version the service desk records as installed. The service desk owns it.": "De versie die de servicedesk als geïnstalleerd vastlegt. De servicedesk is er eigenaar van.", + "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publiceer deze applicatie in gebruik vanaf deze datum in de openbare catalogus. Laat leeg om haar intern te houden.", + "Supplier reference": "Referentie van de leverancier", + "The supplier's own number for this contract or licence agreement.": "Het eigen nummer van de leverancier voor dit contract of deze licentieovereenkomst.", + "Currency": "Valuta", + "The currency of the costs, as a three-letter ISO 4217 code.": "De valuta van de kosten, als ISO 4217-code van drie letters.", + "The organisation this contract or licence was bought from.": "De organisatie waarvan dit contract of deze licentie is gekocht.", + "TOPdesk asset template id": "Id van het TOPdesk-assetsjabloon", + "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk heeft een sjabloon nodig om een asset aan te maken. Kopieer het id van je sjabloon Applicatie uit TOPdesk.", + "{processed} of {total} rows processed": "{processed} van {total} rijen verwerkt", + "{size} KB": "{size} kB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "Er wordt een nieuwe gemeente \"{name}\" aangemaakt, tenzij er al een gemeente met deze naam bestaat.", + "A sheet has more rows than the import can process.": "Een tabblad heeft meer rijen dan de import kan verwerken.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens 10.000 rijen bevatten. Splits de export en importeer de delen na elkaar.", + "All outcomes": "Alle resultaten", + "Check the connection and try again.": "Controleer de verbinding en probeer het opnieuw.", + "Choose a municipality first.": "Kies eerst een gemeente.", + "Choose or type a municipality": "Kies of typ een gemeente", + "Choose the TOPdesk export": "Kies de TOPdesk-export", + "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.", + "CMDB import": "CMDB-import", + "Created": "Aangemaakt", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de leverancier van de software (Vendor) en een gebruik dat de applicatie aan de gekozen gemeente koppelt. De applicatie-eigenaar wordt een contactpersoon van de gemeente in Nextcloud Contacten; eigenaren worden nooit openbaar getoond.", + "Error code: {code}": "Foutcode: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.", + "Failed": "Mislukt", + "Import": "Importeren", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voltooid. De gemeente {name} is aangemaakt. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import progress": "Voortgang van de import", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", + "Importing the export…": "De export wordt geïmporteerd…", + "Importing…": "Importeren…", + "APPID": "APPID", + "Municipality": "Gemeente", + "No file was uploaded.": "Er is geen bestand geüpload.", + "No rows with this outcome": "Geen rijen met dit resultaat", + "Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.", + "Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.", + "Outcome": "Resultaat", + "Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.", + "Reasons and warnings": "Redenen en waarschuwingen", + "Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.", + "Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.", + "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.", + "Row": "Rij", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", + "Rows": "Rijen", + "Rows read": "Rijen gelezen", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.", + "Sheet": "Tabblad", + "Show rows with outcome": "Toon rijen met resultaat", + "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.", + "Skipped": "Overgeslagen", + "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"APPID\" en \"Applicatie Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", + "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.", + "The file": "Het bestand", + "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.", + "The import failed unexpectedly.": "De import is onverwacht mislukt.", + "The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.", + "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.", + "The server could not be reached.": "De server is niet bereikbaar.", + "The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"Onbeh Applicaties CMDB\" (applicaties zonder geregeld beheer) en \"Beheerde Applicaties CMDB\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.", + "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.", + "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).", + "This import is no longer running.": "Deze import loopt niet meer.", + "Unchanged": "Ongewijzigd", + "Update existing records": "Bestaande records bijwerken", + "Updated": "Bijgewerkt", + "Warnings": "Waarschuwingen", + "Warnings for the whole file": "Waarschuwingen voor het hele bestand", + "When off, applications imported before are left as they are and reported as skipped.": "Als dit uit staat, blijven eerder geïmporteerde applicaties zoals ze zijn en worden ze als overgeslagen gemeld.", + "You are not signed in.": "U bent niet aangemeld.", + "Your session has expired.": "Uw sessie is verlopen.", + "Stackiq is not configured for the import.": "Stackiq is niet ingesteld voor de import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "Het tabblad \"{sheet}\" heeft meer rijen dan de import kan verwerken.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.", + "Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.", + "No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.", + "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.", + "Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "De Excel-lezer is niet beschikbaar: OpenRegister ontbreekt of is onvolledig.", + "The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.", + "The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.", + "The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.", + "Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden", + "Owner from column \"%s\" could not be resolved": "Eigenaar uit kolom \"%s\" kon niet worden gevonden", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Eigenaar uit kolom \"%s\" kon niet worden gevonden in Nextcloud Contacten", + "Owners skipped: Nextcloud Contacts is unavailable": "Eigenaren overgeslagen: Nextcloud Contacten is niet beschikbaar", + "duplicate %s in file": "dubbele %s in het bestand", + "exists": "bestaat al", + "missing %s": "%s ontbreekt", + "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", + "step \"%s\" failed": "stap \"%s\" mislukt", + "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert.", + "Source id": "Bron-id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "De code van de applicatie in het bronsysteem waaruit ze is geïmporteerd, zoals de TOPdesk Applicatie Code (het Middel-ID). Ter informatie; ze kan in de bron veranderen, dus records worden er niet op gekoppeld.", + "Source number": "Bronnummer", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "Het applicatienummer in het bronsysteem, zoals het APPID van TOPdesk (ICT Applicatienummer). Een herhaalde CMDB-import koppelt erop, via de importsleutel.", + "Import key": "Importsleutel", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; niet aanpassen.", + "Created in source": "Aangemaakt in de bron", + "The date the application was registered in the source system.": "De datum waarop de applicatie in het bronsysteem is geregistreerd.", + "Changed in source": "Gewijzigd in de bron", + "The date the application was last changed in the source system.": "De datum waarop de applicatie in het bronsysteem het laatst is gewijzigd.", + "Application published": "Applicatie gepubliceerd", + "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "De publicatiedatum van de applicatie waar deze versie bij hoort, daarvan overgenomen. Een versie is alleen openbaar zolang haar applicatie dat is.", + "Application registered by": "Applicatie geregistreerd door", + "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Wie de applicatie registreerde waar deze versie bij hoort, daarvan overgenomen. De applicatie van een leverancier is openbaar, en haar versies ook.", + "The file could not be sent. Check your connection and try again.": "Het bestand kon niet worden verstuurd. Controleer uw verbinding en probeer het opnieuw.", + "The organisation register is not configured, so there are no organisations to choose from.": "Het organisatieregister is niet ingesteld, dus er zijn geen organisaties om uit te kiezen.", + "The organisations could not be loaded. Reload the page to try again.": "De organisaties konden niet worden geladen. Herlaad de pagina om het opnieuw te proberen.", + "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Kon niet worden geladen of er een servicedesk is gekoppeld. Herlaad de pagina om het opnieuw te proberen.", + "Application type": "Applicatiesoort", + "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "Het soort applicatie zoals het bronsysteem het vastlegt, zoals de TOPdesk-Applicatiesoort (Webapplicatie, Client/server, Saas). Overgenomen zoals de bron het heeft.", + "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "Het BBN-niveau (Baseline Informatiebeveiliging Overheid) waarop deze applicatie is geclassificeerd. BBN2+ is een niveau dat de TOPdesk-CMDB-export gebruikt.", + "Field \"%1$s\" must be one of: %2$s.": "Veld \"%1$s\" moet een van deze waarden hebben: %2$s.", + "Field \"%s\" has an invalid value.": "Veld \"%s\" heeft een ongeldige waarde.", + "The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.", + "The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.", + "The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.", + "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.", + "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.", + "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.", + "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.", + "The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.", + "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.", + "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.", + "The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}", + "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.", + "Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.", + "Show {count} more rows": "Toon nog {count} rijen", + "The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.", + "Accepted values: {accepted}. Reload the page and try again.": "Toegestane waarden: {accepted}. Laad de pagina opnieuw en probeer het nog eens.", + "The server could not store the uploaded file.": "De server kon het geüploade bestand niet opslaan.", + "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Probeer het opnieuw. Blijft het mislukken, dan staan de details in het Nextcloud-logboek; controleer de vrije ruimte en de uploadinstellingen van de server.", + "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "Er is geen gemeente met de naam \"%s\" gevonden, dus die is aangemaakt. Controleer de naam als u een bestaande gemeente bedoelde.", + "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s staat ook op het tabblad \"%2$s\", dat voorgaat; deze rij wordt niet geïmporteerd", + "Stackiq CMDB owners": "Stackiq CMDB-eigenaren", + "step \"%1$s\" failed (%2$s)": "stap \"%1$s\" mislukt (%2$s)", + "The workbook is too large to read once unpacked.": "Het werkboek is uitgepakt te groot om te lezen.", + "The stackiq register is out of date; import its configuration again.": "Het stackiq-register is verouderd; importeer de configuratie ervan opnieuw.", + "Another CMDB import is running; try again when it has finished.": "Er loopt al een andere CMDB-import; probeer het opnieuw wanneer die klaar is.", + "Several municipalities have this name; choose one from the list.": "Meerdere gemeenten hebben deze naam; kies er een uit de lijst.", + "Unpacked, the workbook is larger than {size}, the most the import reads.": "Uitgepakt is het werkboek groter dan {size}, het maximum dat de import leest.", + "The \"{schema}\" schema of the stackiq register is out of date.": "Het schema \"{schema}\" van het stackiq-register is verouderd.", + "The stackiq register is out of date.": "Het stackiq-register is verouderd.", + "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "Het mist eigenschappen waarop de import records herkent. Klik bovenaan deze pagina op Force Update om de registerconfiguratie opnieuw te importeren en probeer het daarna opnieuw. Er is niets geïmporteerd.", + "Another CMDB import is running.": "Er loopt al een andere CMDB-import.", + "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Er loopt maar één import tegelijk. Wacht tot die klaar is en probeer het opnieuw. Er is niets geïmporteerd.", + "{count} municipalities have this name.": "{count} gemeenten hebben deze naam.", + "Several municipalities have this name.": "Meerdere gemeenten hebben deze naam.", + "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Kies de gemeente uit de lijst in plaats van de naam te typen. Er is niets geïmporteerd.", + "Publish the applications this import creates": "De applicaties die deze import aanmaakt publiceren", + "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "Een gepubliceerde applicatie is voor iedereen zichtbaar, ook voor anonieme bezoekers van OpenCatalogi. Staat dit uit, dan blijven de applicaties die deze import aanmaakt ongepubliceerd tot u ze zelf publiceert. Eerder geïmporteerde applicaties houden hun publicatie zoals die is.", + "Created unpublished": "Ongepubliceerd aangemaakt", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; alleen een Nextcloud-beheerder kan deze wijzigen.", + "conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd", + "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.", + "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.", + "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.", + "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.", + "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.", + "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.", + "The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.", + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 0848195e0..e226a52ca 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,9 +1,29 @@ { "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", - "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt wat je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Elke kaart heeft een eigen knop Laden. Kies \"Geen\" op een productieomgeving.", "None, I will set this up myself": "Geen, ik richt dit zelf in", "Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.", "Example data": "Voorbeeldgegevens", @@ -776,6 +796,407 @@ "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.", + "CMDB": "CMDB", + "Stackiq is your configuration management database for applications. It records what your organisation runs, what it is made of, how it connects and what you pay for it.": "Stackiq is je configuratiebeheerdatabase voor applicaties. Het legt vast wat je organisatie draait, waaruit het bestaat, hoe het samenhangt en wat je ervoor betaalt.", + "What stackiq records": "Wat stackiq vastlegt", + "What stackiq does not record": "Wat stackiq niet vastlegt", + "Servers, laptops and network devices are not recorded here, and stackiq does not discover hardware on your network. Tickets and incidents stay in your service desk.": "Servers, laptops en netwerkapparatuur leg je hier niet vast, en stackiq ontdekt geen hardware op je netwerk. Meldingen en incidenten blijven in je servicedesk.", + "Service desk exchange": "Uitwisseling met de servicedesk", + "Stackiq and {desk} are in step. The import runs every night at 02:00, and stackiq sends its own changes straight away.": "Stackiq en {desk} lopen gelijk. De import draait elke nacht om 02:00, en stackiq stuurt zijn eigen wijzigingen meteen door.", + "No service desk is connected yet. An admin connects TOPdesk or ServiceNow in the admin settings.": "Er is nog geen servicedesk gekoppeld. Een beheerder koppelt TOPdesk of ServiceNow in de beheerinstellingen.", + "The service desk owns the name, supplier, installed version and status of an application. Stackiq owns licences, contracts, BBN level, TIME class and publication. Each side only overwrites the fields it owns.": "De servicedesk is eigenaar van de naam, leverancier, geïnstalleerde versie en status van een applicatie. Stackiq is eigenaar van licenties, contracten, BBN-niveau, TIME-klasse en publicatie. Elke kant overschrijft alleen de velden waarvan hij eigenaar is.", + "Change the exchange": "Uitwisseling wijzigen", + "Connect a service desk": "Servicedesk koppelen", + "Import a file": "Bestand importeren", + "No service desk API? Import a CSV or XLSX file with the columns recordId, name, supplierName, installedVersion and status. Importing the same file again updates the applications instead of adding them twice.": "Geen API op je servicedesk? Importeer een CSV- of XLSX-bestand met de kolommen recordId, name, supplierName, installedVersion en status. Importeer je hetzelfde bestand opnieuw, dan worden de applicaties bijgewerkt in plaats van dubbel toegevoegd.", + "File to import": "Te importeren bestand", + "Import file": "Bestand importeren", + "What your organisation runs, which version, and who owns it.": "Wat je organisatie draait, welke versie, en wie eigenaar is.", + "Applications and their components": "Applicaties en hun onderdelen", + "The products, from which supplier, and what they consist of.": "De producten, van welke leverancier, en waaruit ze bestaan.", + "Which application exchanges data with which.": "Welke applicatie gegevens uitwisselt met welke.", + "Licences and contracts": "Licenties en contracten", + "What you bought, how many licences, until when and at what cost.": "Wat je hebt gekocht, hoeveel licenties, tot wanneer en tegen welke kosten.", + "The import of {rows} rows has started. The applications appear as the run goes.": "De import van {rows} rijen is gestart. De applicaties verschijnen terwijl de run loopt.", + "The import did not start.": "De import is niet gestart.", + "Keep applications, connections, licences and contracts in step with TOPdesk or ServiceNow. Add the service desk source in integriq first; stackiq never holds its password.": "Houd applicaties, koppelingen, licenties en contracten gelijk met TOPdesk of ServiceNow. Voeg eerst de servicedeskbron toe in integriq; stackiq bewaart het wachtwoord nooit.", + "Loading the service desk exchange…": "Uitwisseling met de servicedesk laden…", + "OpenRegister's flow engine is not available, so the exchange cannot run.": "De flow-engine van OpenRegister is niet beschikbaar, dus de uitwisseling kan niet draaien.", + "Set up for {desk} on {date}. The import runs every night at 02:00.": "Ingesteld voor {desk} op {date}. De import draait elke nacht om 02:00.", + "Service desk": "Servicedesk", + "Choose TOPdesk or ServiceNow": "Kies TOPdesk of ServiceNow", + "Your organisation": "Je organisatie", + "The organisation whose applications are exchanged": "De organisatie waarvan de applicaties worden uitgewisseld", + "Set up again": "Opnieuw instellen", + "Set up the exchange": "Uitwisseling instellen", + "{count} flows are set up. The first import runs tonight.": "{count} flows zijn ingesteld. De eerste import draait vannacht.", + "The service desk this record is kept in step with.": "De servicedesk waarmee dit record gelijk wordt gehouden.", + "TOPdesk": "TOPdesk", + "ServiceNow": "ServiceNow", + "File import": "Bestandsimport", + "Service desk record": "Servicedeskrecord", + "The record id in the service desk. The import matches on it first.": "Het record-id in de servicedesk. De import zoekt hier eerst op.", + "Service desk link": "Link naar de servicedesk", + "Opens the record in the service desk.": "Opent het record in de servicedesk.", + "Last synchronised": "Laatst gesynchroniseerd", + "When the service desk exchange last wrote this record.": "Wanneer de uitwisseling met de servicedesk dit record het laatst heeft geschreven.", + "Installed version": "Geïnstalleerde versie", + "The version the service desk records as installed. The service desk owns it.": "De versie die de servicedesk als geïnstalleerd vastlegt. De servicedesk is er eigenaar van.", + "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.": "Publiceer deze applicatie in gebruik vanaf deze datum in de openbare catalogus. Laat leeg om haar intern te houden.", + "Supplier reference": "Referentie van de leverancier", + "The supplier's own number for this contract or licence agreement.": "Het eigen nummer van de leverancier voor dit contract of deze licentieovereenkomst.", + "Currency": "Valuta", + "The currency of the costs, as a three-letter ISO 4217 code.": "De valuta van de kosten, als ISO 4217-code van drie letters.", + "The organisation this contract or licence was bought from.": "De organisatie waarvan dit contract of deze licentie is gekocht.", + "TOPdesk asset template id": "Id van het TOPdesk-assetsjabloon", + "TOPdesk needs a template to create an asset. Copy the id of your Application template from TOPdesk.": "TOPdesk heeft een sjabloon nodig om een asset aan te maken. Kopieer het id van je sjabloon Applicatie uit TOPdesk.", + "{processed} of {total} rows processed": "{processed} van {total} rijen verwerkt", + "{size} KB": "{size} kB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "Er wordt een nieuwe gemeente \"{name}\" aangemaakt, tenzij er al een gemeente met deze naam bestaat.", + "A sheet has more rows than the import can process.": "Een tabblad heeft meer rijen dan de import kan verwerken.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens 10.000 rijen bevatten. Splits de export en importeer de delen na elkaar.", + "All outcomes": "Alle resultaten", + "Check the connection and try again.": "Controleer de verbinding en probeer het opnieuw.", + "Choose a municipality first.": "Kies eerst een gemeente.", + "Choose or type a municipality": "Kies of typ een gemeente", + "Choose the TOPdesk export": "Kies de TOPdesk-export", + "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.", + "CMDB import": "CMDB-import", + "Created": "Aangemaakt", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de leverancier van de software (Vendor) en een gebruik dat de applicatie aan de gekozen gemeente koppelt. De applicatie-eigenaar wordt een contactpersoon van de gemeente in Nextcloud Contacten; eigenaren worden nooit openbaar getoond.", + "Error code: {code}": "Foutcode: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.", + "Failed": "Mislukt", + "Import": "Importeren", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voltooid. De gemeente {name} is aangemaakt. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import progress": "Voortgang van de import", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", + "Importing the export…": "De export wordt geïmporteerd…", + "Importing…": "Importeren…", + "APPID": "APPID", + "Municipality": "Gemeente", + "No file was uploaded.": "Er is geen bestand geüpload.", + "No rows with this outcome": "Geen rijen met dit resultaat", + "Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.", + "Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.", + "Outcome": "Resultaat", + "Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.", + "Reasons and warnings": "Redenen en waarschuwingen", + "Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.", + "Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.", + "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.", + "Row": "Rij", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", + "Rows": "Rijen", + "Rows read": "Rijen gelezen", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.", + "Sheet": "Tabblad", + "Show rows with outcome": "Toon rijen met resultaat", + "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.", + "Skipped": "Overgeslagen", + "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"APPID\" en \"Applicatie Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", + "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.", + "The file": "Het bestand", + "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.", + "The import failed unexpectedly.": "De import is onverwacht mislukt.", + "The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.", + "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.", + "The server could not be reached.": "De server is niet bereikbaar.", + "The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"Onbeh Applicaties CMDB\" (applicaties zonder geregeld beheer) en \"Beheerde Applicaties CMDB\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.", + "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.", + "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).", + "This import is no longer running.": "Deze import loopt niet meer.", + "Unchanged": "Ongewijzigd", + "Update existing records": "Bestaande records bijwerken", + "Updated": "Bijgewerkt", + "Warnings": "Waarschuwingen", + "Warnings for the whole file": "Waarschuwingen voor het hele bestand", + "When off, applications imported before are left as they are and reported as skipped.": "Als dit uit staat, blijven eerder geïmporteerde applicaties zoals ze zijn en worden ze als overgeslagen gemeld.", + "You are not signed in.": "U bent niet aangemeld.", + "Your session has expired.": "Uw sessie is verlopen.", + "Stackiq is not configured for the import.": "Stackiq is niet ingesteld voor de import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "Het tabblad \"{sheet}\" heeft meer rijen dan de import kan verwerken.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.", + "Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.", + "No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.", + "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.", + "Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "De Excel-lezer is niet beschikbaar: OpenRegister ontbreekt of is onvolledig.", + "The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.", + "The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.", + "The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.", + "Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden", + "Owner from column \"%s\" could not be resolved": "Eigenaar uit kolom \"%s\" kon niet worden gevonden", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Eigenaar uit kolom \"%s\" kon niet worden gevonden in Nextcloud Contacten", + "Owners skipped: Nextcloud Contacts is unavailable": "Eigenaren overgeslagen: Nextcloud Contacten is niet beschikbaar", + "duplicate %s in file": "dubbele %s in het bestand", + "exists": "bestaat al", + "missing %s": "%s ontbreekt", + "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", + "step \"%s\" failed": "stap \"%s\" mislukt", + "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert.", + "Source id": "Bron-id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "De code van de applicatie in het bronsysteem waaruit ze is geïmporteerd, zoals de TOPdesk Applicatie Code (het Middel-ID). Ter informatie; ze kan in de bron veranderen, dus records worden er niet op gekoppeld.", + "Source number": "Bronnummer", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "Het applicatienummer in het bronsysteem, zoals het APPID van TOPdesk (ICT Applicatienummer). Een herhaalde CMDB-import koppelt erop, via de importsleutel.", + "Import key": "Importsleutel", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; niet aanpassen.", + "Created in source": "Aangemaakt in de bron", + "The date the application was registered in the source system.": "De datum waarop de applicatie in het bronsysteem is geregistreerd.", + "Changed in source": "Gewijzigd in de bron", + "The date the application was last changed in the source system.": "De datum waarop de applicatie in het bronsysteem het laatst is gewijzigd.", + "Application published": "Applicatie gepubliceerd", + "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.": "De publicatiedatum van de applicatie waar deze versie bij hoort, daarvan overgenomen. Een versie is alleen openbaar zolang haar applicatie dat is.", + "Application registered by": "Applicatie geregistreerd door", + "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.": "Wie de applicatie registreerde waar deze versie bij hoort, daarvan overgenomen. De applicatie van een leverancier is openbaar, en haar versies ook.", + "The file could not be sent. Check your connection and try again.": "Het bestand kon niet worden verstuurd. Controleer uw verbinding en probeer het opnieuw.", + "The organisation register is not configured, so there are no organisations to choose from.": "Het organisatieregister is niet ingesteld, dus er zijn geen organisaties om uit te kiezen.", + "The organisations could not be loaded. Reload the page to try again.": "De organisaties konden niet worden geladen. Herlaad de pagina om het opnieuw te proberen.", + "Whether a service desk is connected could not be loaded. Reload the page to try again.": "Kon niet worden geladen of er een servicedesk is gekoppeld. Herlaad de pagina om het opnieuw te proberen.", + "Application type": "Applicatiesoort", + "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.": "Het soort applicatie zoals het bronsysteem het vastlegt, zoals de TOPdesk-Applicatiesoort (Webapplicatie, Client/server, Saas). Overgenomen zoals de bron het heeft.", + "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.": "Het BBN-niveau (Baseline Informatiebeveiliging Overheid) waarop deze applicatie is geclassificeerd. BBN2+ is een niveau dat de TOPdesk-CMDB-export gebruikt.", + "Field \"%1$s\" must be one of: %2$s.": "Veld \"%1$s\" moet een van deze waarden hebben: %2$s.", + "Field \"%s\" has an invalid value.": "Veld \"%s\" heeft een ongeldige waarde.", + "The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.", + "The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.", + "The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.", + "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.", + "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.", + "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.", + "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.", + "The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.", + "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.", + "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.", + "The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}", + "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.", + "Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.", + "Show {count} more rows": "Toon nog {count} rijen", + "The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.", + "Accepted values: {accepted}. Reload the page and try again.": "Toegestane waarden: {accepted}. Laad de pagina opnieuw en probeer het nog eens.", + "The server could not store the uploaded file.": "De server kon het geüploade bestand niet opslaan.", + "Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.": "Probeer het opnieuw. Blijft het mislukken, dan staan de details in het Nextcloud-logboek; controleer de vrije ruimte en de uploadinstellingen van de server.", + "No municipality named \"%s\" was found, so it was created. Check the name if you meant an existing one.": "Er is geen gemeente met de naam \"%s\" gevonden, dus die is aangemaakt. Controleer de naam als u een bestaande gemeente bedoelde.", + "APPID %1$s is also on sheet \"%2$s\", which wins; this row is not imported": "APPID %1$s staat ook op het tabblad \"%2$s\", dat voorgaat; deze rij wordt niet geïmporteerd", + "Stackiq CMDB owners": "Stackiq CMDB-eigenaren", + "step \"%1$s\" failed (%2$s)": "stap \"%1$s\" mislukt (%2$s)", + "The workbook is too large to read once unpacked.": "Het werkboek is uitgepakt te groot om te lezen.", + "The stackiq register is out of date; import its configuration again.": "Het stackiq-register is verouderd; importeer de configuratie ervan opnieuw.", + "Another CMDB import is running; try again when it has finished.": "Er loopt al een andere CMDB-import; probeer het opnieuw wanneer die klaar is.", + "Several municipalities have this name; choose one from the list.": "Meerdere gemeenten hebben deze naam; kies er een uit de lijst.", + "Unpacked, the workbook is larger than {size}, the most the import reads.": "Uitgepakt is het werkboek groter dan {size}, het maximum dat de import leest.", + "The \"{schema}\" schema of the stackiq register is out of date.": "Het schema \"{schema}\" van het stackiq-register is verouderd.", + "The stackiq register is out of date.": "Het stackiq-register is verouderd.", + "It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.": "Het mist eigenschappen waarop de import records herkent. Klik bovenaan deze pagina op Force Update om de registerconfiguratie opnieuw te importeren en probeer het daarna opnieuw. Er is niets geïmporteerd.", + "Another CMDB import is running.": "Er loopt al een andere CMDB-import.", + "Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.": "Er loopt maar één import tegelijk. Wacht tot die klaar is en probeer het opnieuw. Er is niets geïmporteerd.", + "{count} municipalities have this name.": "{count} gemeenten hebben deze naam.", + "Several municipalities have this name.": "Meerdere gemeenten hebben deze naam.", + "Choose the municipality from the list instead of typing its name. Nothing was imported.": "Kies de gemeente uit de lijst in plaats van de naam te typen. Er is niets geïmporteerd.", + "Publish the applications this import creates": "De applicaties die deze import aanmaakt publiceren", + "A published application is visible to anyone, including anonymous visitors of OpenCatalogi. When off, the applications this import creates stay unpublished until you publish them by hand. Applications imported before keep their publication as it is.": "Een gepubliceerde applicatie is voor iedereen zichtbaar, ook voor anonieme bezoekers van OpenCatalogi. Staat dit uit, dan blijven de applicaties die deze import aanmaakt ongepubliceerd tot u ze zelf publiceert. Eerder geïmporteerde applicaties houden hun publicatie zoals die is.", + "Created unpublished": "Ongepubliceerd aangemaakt", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; alleen een Nextcloud-beheerder kan deze wijzigen.", + "conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd", + "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.", + "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.", + "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.", + "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.", + "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.", + "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.", + "The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.", + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest." } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 672b1cdec..3c5f2d851 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -22,6 +22,7 @@ use OCA\OpenRegister\Contract\ObjectServiceInterface; use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\OpenRegister\Event\ObjectDeletedEvent; use OCA\OpenRegister\Event\ObjectUpdatedEvent; use OCA\OpenRegister\Event\UserProfileUpdatedEvent; use OCA\OpenRegister\Service\OrganisationService as OpenRegisterOrganisationService; @@ -32,6 +33,8 @@ use OCA\Stackiq\Controller\ContactpersonenController; use OCA\Stackiq\Dashboard\ConceptOrganisatiesWidget; use OCA\Stackiq\EventListener\DecisionConcludedListener; +use OCA\Stackiq\EventListener\MaintenanceRecipientsListener; +use OCA\Stackiq\EventListener\ModuleVersionPublicationListener; use OCA\Stackiq\EventListener\ModuleComplianceSubscriber; use OCA\Stackiq\EventListener\ModuleRegistrationSubscriber; use OCA\Stackiq\EventListener\TestEventListener; @@ -39,6 +42,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; @@ -364,7 +368,9 @@ function ($container) { function ($container) { return new StackiqContactSyncService( contactsManager: $container->get('OCP\Contacts\IManager'), - logger: $container->get('Psr\Log\LoggerInterface') + logger: $container->get('Psr\Log\LoggerInterface'), + container: $container, + userSession: $container->get('OCP\IUserSession') ); } ); @@ -515,14 +521,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 +549,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,8 +599,11 @@ function ($container) { ProgressTracker::class, function ($container) { return new ProgressTracker( - session: $container->get('OCP\ISession'), - logger: $container->get('Psr\Log\LoggerInterface') + cacheFactory: $container->get(ICacheFactory::class), + userSession: $container->get('OCP\IUserSession'), + logger: $container->get('Psr\Log\LoggerInterface'), + config: $container->get(IConfig::class), + appConfig: $container->get(IAppConfig::class) ); } ); @@ -674,7 +683,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 +719,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 +814,14 @@ 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); + + // A module version is public only while its application is (publication-field-rules). + $context->registerEventListener(ObjectCreatedEvent::class, ModuleVersionPublicationListener::class); + $context->registerEventListener(ObjectUpdatedEvent::class, ModuleVersionPublicationListener::class); + $context->registerEventListener(ObjectDeletedEvent::class, ModuleVersionPublicationListener::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..fd9c05d37 --- /dev/null +++ b/lib/BackgroundJob/MaintenanceRecipientsJob.php @@ -0,0 +1,107 @@ + + * @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\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * 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 { + + /** + * How many times one resolution is tried before it is given up and logged as critical. + */ + public const MAX_ATTEMPTS = 3; + + /** + * Seconds between a failed resolution and the next try. + */ + public const RETRY_DELAY = 300; + + /** + * Constructor. + * + * @param ITimeFactory $time The time factory. + * @param MaintenanceRecipientService $recipients The owner resolution. + * @param IJobList $jobList Queues the job again after a failure. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + ITimeFactory $time, + private readonly MaintenanceRecipientService $recipients, + private readonly IJobList $jobList, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + }//end __construct() + + /** + * Resolve and record the owners for the window in the argument. + * + * A queued job is removed before it runs, so a resolution that fails (the + * window or the product cannot be read, or the window cannot be written) + * is queued again a few minutes later, up to MAX_ATTEMPTS tries. + * + * @param mixed $argument `{uuid, register, schema, attempt?}` 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; + } + + try { + $this->recipients->recordRecipientsFor( + uuid: $argument['uuid'], + register: ($argument['register'] ?? null), + schema: ($argument['schema'] ?? null) + ); + } catch (DoesNotExistException $e) { + // The window was deleted before its owners were resolved: nothing to do. + return; + } catch (Throwable $e) { + $attempt = (int) ($argument['attempt'] ?? 1); + $context = ['uuid' => $argument['uuid'], 'attempt' => $attempt, 'error' => $e->getMessage()]; + if ($attempt >= self::MAX_ATTEMPTS) { + $this->logger->critical('MaintenanceRecipientsJob: gave up recording the owners to notify; nothing was written', $context); + return; + } + + $this->logger->error('MaintenanceRecipientsJob: could not record the owners to notify; tried again later', $context); + $argument['attempt'] = ($attempt + 1); + $this->jobList->scheduleAfter(self::class, $this->time->getTime() + self::RETRY_DELAY, $argument); + } + }//end run() +}//end class diff --git a/lib/BackgroundJob/ModuleVersionPublicationJob.php b/lib/BackgroundJob/ModuleVersionPublicationJob.php new file mode 100644 index 000000000..6c5928563 --- /dev/null +++ b/lib/BackgroundJob/ModuleVersionPublicationJob.php @@ -0,0 +1,183 @@ + + * @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/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\BackgroundJob; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\Stackiq\Service\ModuleVersionPublicationService; +use OCA\Stackiq\Service\SettingsService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * One copy of one module's publication onto its versions. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ +class ModuleVersionPublicationJob extends QueuedJob { + + /** + * How many times one copy is tried before it is given up and logged as critical. + */ + public const MAX_ATTEMPTS = 3; + + /** + * Seconds between a failed copy and the next try. + */ + public const RETRY_DELAY = 300; + + /** + * Constructor. + * + * @param ITimeFactory $time The time factory. + * @param ModuleVersionPublicationService $publication The mirror. + * @param SettingsService $settingsService Resolves the module register and schema. + * @param ContainerInterface $container Resolves OpenRegister's object service. + * @param LoggerInterface $logger The logger. + * @param IJobList $jobList Queues the job again after a failed copy. + */ + public function __construct( + ITimeFactory $time, + private readonly ModuleVersionPublicationService $publication, + private readonly SettingsService $settingsService, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + private readonly IJobList $jobList, + ) { + parent::__construct(time: $time); + }//end __construct() + + /** + * Copy the publication of the module in the argument onto its versions. + * + * A deleted module, or one that no longer exists, takes its versions out + * of public view. A module that cannot be read, or a version that cannot be + * written, is tried again later. + * + * @param mixed $argument `{module, deleted, attempt?}`: the module's id, whether it was deleted, and the try. + * + * @return void + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + protected function run($argument): void { + $moduleUuid = ''; + if (is_array($argument) === true && is_string($argument['module'] ?? null) === true) { + $moduleUuid = $argument['module']; + } + + if ($moduleUuid === '') { + return; + } + + if (($argument['deleted'] ?? false) === true) { + $this->retryOnFailure(argument: $argument, failed: $this->publication->clearVersions(moduleUuid: $moduleUuid)['failed']); + return; + } + + try { + $module = $this->findModule(moduleUuid: $moduleUuid); + } catch (DoesNotExistException $e) { + $module = null; + } catch (Throwable $e) { + $this->logger->error( + 'ModuleVersionPublicationJob: could not read the module; its versions are tried again later', + ['module' => $moduleUuid, 'error' => $e->getMessage()] + ); + $this->retryOnFailure(argument: $argument, failed: 1); + return; + } + + $result = ['failed' => 0]; + if ($module !== null) { + $result = $this->publication->backfillModule(module: $module); + } + + if ($module === null) { + $result = $this->publication->clearVersions(moduleUuid: $moduleUuid); + } + + $this->retryOnFailure(argument: $argument, failed: $result['failed']); + }//end run() + + /** + * Queue the copy again after a failure, until MAX_ATTEMPTS tries were made. + * + * @param array $argument The job's argument. + * @param int $failed The versions or reads that failed in this try. + * + * @return void + */ + private function retryOnFailure(array $argument, int $failed): void { + if ($failed === 0) { + return; + } + + $attempt = ((int) ($argument['attempt'] ?? 1)); + if ($attempt >= self::MAX_ATTEMPTS) { + $this->logger->critical( + 'ModuleVersionPublicationJob: gave up copying the publication onto the versions; save the module again to retry', + ['module' => $argument['module'], 'attempts' => $attempt, 'failed' => $failed] + ); + return; + } + + $argument['attempt'] = ($attempt + 1); + $this->jobList->scheduleAfter(self::class, $this->time->getTime() + self::RETRY_DELAY, $argument); + }//end retryOnFailure() + + /** + * Read the module as it is now. + * + * @param string $moduleUuid The module. + * + * @return ObjectEntityInterface|null The module, or null when it no longer exists. + * + * @throws DoesNotExistException When the module no longer exists (OpenRegister's find() throws rather than returning null). + * @throws Throwable When OpenRegister is absent or the read fails. + */ + private function findModule(string $moduleUuid): ?ObjectEntityInterface { + $objects = $this->container->get(ObjectServiceInterface::class); + if (($objects instanceof ObjectServiceInterface) === false) { + throw new RuntimeException('OpenRegister\'s object service is not available'); + } + + return $objects->find( + id: $moduleUuid, + register: $this->settingsService->getRegisterIdForObjectType('module'), + schema: $this->settingsService->getSchemaIdForObjectType('module'), + _rbac: false, + _multitenancy: false + ); + }//end findModule() +}//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/CmdbImportController.php b/lib/Controller/CmdbImportController.php new file mode 100644 index 000000000..7d1c00a8f --- /dev/null +++ b/lib/Controller/CmdbImportController.php @@ -0,0 +1,557 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Controller; + +use OCA\Stackiq\AppInfo\Application; +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\CmdbExportImportService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IL10N; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +/** + * CMDB import and cancel, for Nextcloud admins only and CSRF-protected. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The complexity is one branch per + * contract error code (message()) and per refused upload or field state, checked in + * the order of design D10; spreading those checks over classes would hide that order. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ +class CmdbImportController extends Controller { + /** + * The multipart field of the export. + */ + public const FILE_FIELD = 'cmdbFile'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param CmdbExportImportService $importService The import service. + * @param IL10N $l10n Translations of the error messages. + * @param LoggerInterface $logger Logger. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function __construct( + IRequest $request, + private readonly CmdbExportImportService $importService, + private readonly IL10N $l10n, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Import a TOPdesk CMDB export for one municipality. + * + * Multipart fields: `cmdbFile`, `municipalityUuid` or `municipalityName`, + * `updateExisting` (default true), `publish` (default true), `missingRecords` (only `keep`) and + * `operationId` (pattern `cmdb-` plus 8 to 64 letters, digits or hyphens). + * + * @return JSONResponse The report (200), or an error envelope with the contract code. + * + * @auth admin-only the import writes with RBAC and multitenancy off, across tenants, so only a full Nextcloud admin may run it. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function import(): JSONResponse { + try { + // The upload checks run inside the same boundary, so an unexpected + // error from the ZIP check is IMPORT_FAILED too, not a bare 500. + $validated = $this->validateRequest(); + if ($validated instanceof JSONResponse) { + return $validated; + } + + $report = $this->importService->import(path: $validated['path'], options: $validated['options']); + } catch (CmdbImportException $e) { + $this->logger->info( + 'CmdbImportController: import refused', + ['error' => $e->getErrorCode(), 'details' => $e->getDetails(), 'reason' => $e->getMessage()] + ); + return $this->fromException(e: $e); + } catch (\Throwable $e) { + $this->logger->error('CmdbImportController: import failed', $this->failureContext(e: $e)); + return $this->error(code: 'IMPORT_FAILED', status: Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse(data: $report, statusCode: Http::STATUS_OK); + }//end import() + + /** + * The log context of an unexpected failure: the class, the sanitised first line and where it was thrown. + * + * Deliberately not the exception object itself under the `exception` key, + * as usual elsewhere: Nextcloud would then log the whole message, the previous exceptions and the stack trace with its + * call arguments, and those can quote cell values of the export or an + * owner's e-mail address (personal data). The service already logs the run's + * failure the same way. + * + * @param \Throwable $e The exception. + * + * @return array{exception: string, error: string, file: string, line: int} + * + * @SuppressWarnings(PHPMD.StaticAccess) logSafeMessage() is a pure function of the exception, shared with the service so + * both logs sanitise the same way. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + private function failureContext(\Throwable $e): array { + return [ + 'exception' => get_class($e), + 'error' => CmdbExportImportService::logSafeMessage(step: 'request', e: $e, values: []), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]; + }//end failureContext() + + /** + * Check the request in the order of design D10, before anything is parsed. + * + * Present, size, xlsx, `missingRecords`, municipality. + * + * @return array{path: string, options: array}|JSONResponse The import input, or the first error. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + private function validateRequest(): array|JSONResponse { + $maxBytes = $this->importService->maxFileBytes(); + $upload = $this->uploadedFile(); + if ($upload === null) { + return $this->missingUpload(maxBytes: $maxBytes); + } + + if ($upload['serverError'] !== null) { + // The upload reached PHP but could not be stored: a server problem, not the admin's. + $this->logger->error('CmdbImportController: the upload could not be stored', ['uploadError' => $upload['serverError']]); + return $this->error(code: 'UPLOAD_FAILED', status: Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($upload['tooLarge'] === true) { + // PHP's upload_max_filesize fired; it can be lower than the profile's maximum. + $limit = min($maxBytes, ($this->iniBytes(name: 'upload_max_filesize') ?? $maxBytes)); + return $this->error(code: 'FILE_TOO_LARGE', status: Http::STATUS_REQUEST_ENTITY_TOO_LARGE, details: ['maxBytes' => $limit]); + } + + if ($upload['size'] > $maxBytes) { + return $this->error(code: 'FILE_TOO_LARGE', status: Http::STATUS_REQUEST_ENTITY_TOO_LARGE, details: ['maxBytes' => $maxBytes]); + } + + try { + $this->importService->assertXlsx(path: $upload['tmpName'], fileName: $upload['name']); + } catch (CmdbImportException $e) { + return $this->fromException(e: $e); + } + + return $this->readOptions(path: $upload['tmpName'], fileName: $upload['name']); + }//end validateRequest() + + /** + * The answer when no upload arrived: 413 when the body was over post_max_size, else 400. + * + * PHP drops the whole body, files and fields alike, when it is larger than + * post_max_size, so such a request looks like one without a file. + * + * @param int $maxBytes The profile's maximum. + * + * @return JSONResponse + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + private function missingUpload(int $maxBytes): JSONResponse { + $postLimit = $this->iniBytes(name: 'post_max_size'); + $length = (int)$this->request->getHeader('Content-Length'); + if ($postLimit !== null && $postLimit > 0 && $length > $postLimit) { + return $this->error( + code: 'FILE_TOO_LARGE', + status: Http::STATUS_REQUEST_ENTITY_TOO_LARGE, + details: ['maxBytes' => min($maxBytes, $postLimit)] + ); + } + + return $this->error(code: 'NO_FILE_UPLOADED', status: Http::STATUS_BAD_REQUEST); + }//end missingUpload() + + /** + * A PHP size setting such as `10M`, in bytes. + * + * Protected so a test can stand in for php.ini, whose size settings cannot + * be changed at run time. + * + * @param string $name The ini setting. + * + * @return int|null Null when it is unset or not a size; 0 means no limit. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + protected function iniBytes(string $name): ?int { + $value = trim((string)ini_get($name)); + if (preg_match('/^(\d+)\s*([KMG]?)$/i', $value, $matches) !== 1) { + return null; + } + + $exponent = ['' => 0, 'K' => 1, 'M' => 2, 'G' => 3][strtoupper($matches[2])]; + return ((int)$matches[1] * (1024 ** $exponent)); + }//end iniBytes() + + /** + * Read and check the form fields, after the upload itself was checked. + * + * A field sent as an array (`municipalityName[]=x`) is refused instead of + * being cast to the string "Array". + * + * @param string $path The checked upload. + * @param string $fileName The upload's name as the client sent it; its base name is passed on for the audit log. + * + * @return array{path: string, options: array}|JSONResponse The import input, or the first error. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + private function readOptions(string $path, string $fileName): array|JSONResponse { + $missingRecords = $this->stringParam(name: 'missingRecords', default: 'keep'); + if ($missingRecords === null) { + return $this->invalidField(field: 'missingRecords'); + } + + if ($this->importService->supportsMissingRecords(mode: $missingRecords) === false) { + return $this->error(code: 'MISSING_RECORDS_UNSUPPORTED', status: Http::STATUS_UNPROCESSABLE_ENTITY, details: ['accepted' => ['keep']]); + } + + // An unrecognised value is refused rather than read as true: true is the mode that overwrites. + $updateExisting = $this->booleanParam(name: 'updateExisting', default: true); + if ($updateExisting === null) { + return $this->invalidField(field: 'updateExisting', accepted: ['true', 'false']); + } + + // Read like updateExisting: a typo must not publish what the admin chose to keep unpublished. + $publish = $this->booleanParam(name: 'publish', default: true); + if ($publish === null) { + return $this->invalidField(field: 'publish', accepted: ['true', 'false']); + } + + $municipalityUuid = $this->stringParam(name: 'municipalityUuid', default: ''); + $municipalityName = $this->stringParam(name: 'municipalityName', default: ''); + if ($municipalityUuid === null) { + return $this->invalidField(field: 'municipalityUuid'); + } + + if ($municipalityName === null) { + return $this->invalidField(field: 'municipalityName'); + } + + $municipalityUuid = trim($municipalityUuid); + $municipalityName = trim($municipalityName); + if ($municipalityUuid === '' && $municipalityName === '') { + return $this->error(code: CmdbImportException::MUNICIPALITY_REQUIRED, status: Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return [ + 'path' => $path, + 'options' => [ + 'municipalityUuid' => $municipalityUuid, + 'municipalityName' => $municipalityName, + 'updateExisting' => $updateExisting, + 'publish' => $publish, + 'operationId' => $this->request->getParam('operationId'), + 'fileName' => basename(str_replace('\\', '/', $fileName)), + ], + ]; + }//end readOptions() + + + /** + * Ask a running CMDB import to stop between rows. + * + * @param string $operationId The operation id. + * + * @return JSONResponse `{success, cancelRequested}`, 404 OPERATION_NOT_FOUND, or 500 IMPORT_FAILED. + * + * @auth admin-only cancelling is part of the import, which writes with RBAC and multitenancy off, so only a full Nextcloud admin may do it. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function cancel(string $operationId): JSONResponse { + try { + $requested = $this->importService->requestCancel(operationId: $operationId); + } catch (\Throwable $e) { + $this->logger->error('CmdbImportController: cancel failed', $this->failureContext(e: $e)); + return $this->error(code: 'IMPORT_FAILED', status: Http::STATUS_INTERNAL_SERVER_ERROR); + } + + if ($requested === false) { + return $this->error(code: 'OPERATION_NOT_FOUND', status: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: ['success' => true, 'cancelRequested' => true], statusCode: Http::STATUS_OK); + }//end cancel() + + /** + * The 400 FIELD_INVALID response for a malformed form field. + * + * @param string $field The field. + * @param array $accepted The values the field accepts, when it has a fixed set. + * + * @return JSONResponse + */ + private function invalidField(string $field, array $accepted = []): JSONResponse { + $details = ['field' => $field]; + if ($accepted !== []) { + $details['accepted'] = $accepted; + } + + return $this->error(code: 'FIELD_INVALID', status: Http::STATUS_BAD_REQUEST, details: $details); + }//end invalidField() + + /** + * Translate a CmdbImportException into its contract response. + * + * @param CmdbImportException $e The exception. + * + * @return JSONResponse + */ + private function fromException(CmdbImportException $e): JSONResponse { + return $this->error(code: $e->getErrorCode(), status: $e->getHttpStatus(), details: $e->getDetails()); + }//end fromException() + + /** + * The error envelope of contract.md. + * + * @param string $code The machine error code. + * @param int $status The HTTP status. + * @param array $details Details, e.g. sheet and column. + * + * @return JSONResponse + */ + private function error(string $code, int $status, array $details = []): JSONResponse { + return new JSONResponse( + data: [ + 'success' => false, + 'error' => $code, + 'message' => $this->message(code: $code, details: $details), + 'details' => (object)$details, + ], + statusCode: $status + ); + }//end error() + + /** + * The translated message of an error code. + * + * @param string $code The machine error code. + * @param array $details The details. + * + * @return string + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per contract error code. + */ + private function message(string $code, array $details): string { + $megabytes = (string)round((int)($details['maxBytes'] ?? $this->importService->maxFileBytes()) / 1048576, 1); + $expected = implode(', ', array_map('strval', ($details['expected'] ?? []))); + $accepted = implode(', ', array_map('strval', ($details['accepted'] ?? []))); + + return match ($code) { + 'NO_FILE_UPLOADED' => $this->l10n->t('No file was uploaded.'), + 'NOT_XLSX' => $this->l10n->t('The file is not an Excel workbook (.xlsx).'), + 'FILE_TOO_LARGE' => $this->l10n->t('The file is larger than the maximum of %s MB.', [$megabytes]), + 'MISSING_RECORDS_UNSUPPORTED' => $this->l10n->t('Only keeping records that are missing from the export is supported.'), + 'FIELD_INVALID' => $this->fieldMessage(field: (string)($details['field'] ?? ''), accepted: $accepted), + 'MUNICIPALITY_REQUIRED' => $this->l10n->t('Choose a municipality or enter the name of a new one.'), + 'MUNICIPALITY_INVALID' => $this->l10n->t('The chosen organisation is not a municipality.'), + 'NO_SOURCE_SHEET' => $this->l10n->t('The workbook has neither of the sheets %s.', [$expected]), + 'MISSING_COLUMN' => $this->l10n->t('Sheet "%1$s" has no column "%2$s".', [(string)($details['sheet'] ?? ''), (string)($details['column'] ?? '')]), + 'TOO_MANY_ROWS' => $this->l10n->t('Sheet "%1$s" has more than %2$s rows.', [(string)($details['sheet'] ?? ''), (string)($details['limit'] ?? '')]), + 'MAPPING_UNAVAILABLE' => $this->l10n->t('The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.'), + 'READER_UNAVAILABLE' => $this->l10n->t('The Excel reader is not available: OpenRegister is missing or incomplete.'), + 'NOT_CONFIGURED' => $this->l10n->t('Stackiq is not configured: the register or its schemas cannot be found.'), + 'OPERATION_NOT_FOUND' => $this->l10n->t('No running CMDB import has this id.'), + 'UPLOAD_FAILED' => $this->l10n->t('The server could not store the uploaded file. The details are in the Nextcloud log.'), + 'WORKBOOK_TOO_LARGE' => $this->l10n->t('The workbook is too large to read once unpacked.'), + 'SCHEMA_OUTDATED' => $this->l10n->t('The stackiq register is out of date; import its configuration again.'), + 'IMPORT_IN_PROGRESS' => $this->l10n->t('Another CMDB import is running; try again when it has finished.'), + 'MUNICIPALITY_AMBIGUOUS' => $this->l10n->t('Several municipalities have this name; choose one from the list.'), + default => $this->l10n->t('The import failed. The details are in the Nextcloud log.'), + }; + }//end message() + + /** + * The message of FIELD_INVALID, naming the accepted values when the field has a fixed set. + * + * @param string $field The field. + * @param string $accepted The accepted values, comma-separated, or ''. + * + * @return string + */ + private function fieldMessage(string $field, string $accepted): string { + if ($accepted === '') { + return $this->l10n->t('Field "%s" has an invalid value.', [$field]); + } + + return $this->l10n->t('Field "%1$s" must be one of: %2$s.', [$field, $accepted]); + }//end fieldMessage() + + /** + * A boolean form field: `true`/`false` or `1`/`0`, trimmed and in any case. + * + * @param string $name The field. + * @param bool $default The value when absent or empty. + * + * @return bool|null Null for any other value, which the caller refuses. + */ + private function booleanParam(string $name, bool $default): ?bool { + $value = $this->request->getParam($name); + if ($value === null || $value === '') { + return $default; + } + + if (is_bool($value) === true) { + return $value; + } + + if (is_string($value) === false && is_int($value) === false) { + return null; + } + + return match (strtolower(trim((string)$value))) { + 'true', '1' => true, + 'false', '0' => false, + default => null, + }; + }//end booleanParam() + + /** + * A text form field. + * + * @param string $name The field. + * @param string $default The value when absent. + * + * @return string|null Null when the field is not a single value, such as `name[]=x`. + */ + private function stringParam(string $name, string $default): ?string { + $value = $this->request->getParam($name, $default); + if (is_string($value) === true) { + return $value; + } + + if (is_int($value) === true || is_float($value) === true) { + return (string)$value; + } + + return null; + }//end stringParam() + + /** + * The uploaded export, or null when none was sent. + * + * `serverError` is the PHP upload error when the file reached the server + * but could not be stored (no tmp dir, disk full, an extension stopped it). + * + * @return array{tmpName: string, name: string, size: int, tooLarge: bool, serverError: int|null}|null + */ + private function uploadedFile(): ?array { + $file = $this->request->getUploadedFile(self::FILE_FIELD); + if (is_array($file) === false || $file === []) { + return null; + } + + // `cmdbFile[]` gives arrays for every key; that is no usable upload. + $error = $file['error'] ?? UPLOAD_ERR_OK; + if (is_int($error) === false) { + return null; + } + + if (in_array($error, [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) === true) { + return ['tmpName' => '', 'name' => '', 'size' => 0, 'tooLarge' => true, 'serverError' => null]; + } + + if (in_array($error, [UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION], true) === true) { + return ['tmpName' => '', 'name' => '', 'size' => 0, 'tooLarge' => false, 'serverError' => $error]; + } + + $tmpName = $file['tmp_name'] ?? ''; + if ($error !== UPLOAD_ERR_OK || is_string($tmpName) === false || $tmpName === '') { + return null; + } + + return [ + 'tmpName' => $tmpName, + 'name' => self::uploadName(file: $file), + 'size' => self::uploadSize(file: $file, tmpName: $tmpName), + 'tooLarge' => false, + 'serverError' => null, + ]; + }//end uploadedFile() + + /** + * The client's file name of an upload, or '' when it is not a single name. + * + * @param array $file The upload entry. + * + * @return string + */ + private static function uploadName(array $file): string { + $name = $file['name'] ?? ''; + if (is_string($name) === false) { + return ''; + } + + return $name; + }//end uploadName() + + /** + * The size of an upload: the size PHP reported, or the stored file's when that is 0. + * + * @param array $file The upload entry. + * @param string $tmpName The stored file. + * + * @return int + */ + private static function uploadSize(array $file, string $tmpName): int { + $size = (int)($file['size'] ?? 0); + if ($size === 0 && is_file($tmpName) === true) { + $size = (int)filesize($tmpName); + } + + return $size; + }//end uploadSize() +}//end class diff --git a/lib/Controller/ItsmExchangeController.php b/lib/Controller/ItsmExchangeController.php new file mode 100644 index 000000000..2ef5baefe --- /dev/null +++ b/lib/Controller/ItsmExchangeController.php @@ -0,0 +1,154 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Controller; + +use OCA\Stackiq\AppInfo\Application; +use OCA\Stackiq\Service\ItsmExchangeService; +use OCA\Stackiq\Service\ItsmFileImportService; +use OCA\Stackiq\Settings\StackiqAdmin; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Admin set-up and import, and a public-to-users status, for the service desk exchange. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ +class ItsmExchangeController extends Controller { + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param ItsmExchangeService $exchange Sets up the flows. + * @param ItsmFileImportService $fileImport Imports a spreadsheet. + * @param IUserSession $userSession The signed-in user, who the imports run as. + */ + public function __construct( + IRequest $request, + private readonly ItsmExchangeService $exchange, + private readonly ItsmFileImportService $fileImport, + private readonly IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Whether the exchange runs, and with which desk. + * + * @NoAdminRequired + * + * @return JSONResponse The summary. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is + */ + #[NoAdminRequired] + public function status(): JSONResponse { + $status = $this->exchange->status(); + + return new JSONResponse( + [ + 'available' => $status['available'], + 'enabled' => $status['enabled'], + 'desk' => $status['desk'], + 'setUpAt' => $status['setUpAt'], + 'desks' => $status['desks'], + ] + ); + }//end status() + + /** + * The full set-up, for the admin section. + * + * @AuthorizedAdminSetting(settings=OCA\Stackiq\Settings\StackiqAdmin) + * + * @return JSONResponse The set-up. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + #[AuthorizedAdminSetting(settings: StackiqAdmin::class)] + public function config(): JSONResponse { + return new JSONResponse($this->exchange->status()); + }//end config() + + /** + * Set up, or set up again, the exchange. + * + * @param string $desk The desk key. + * @param string $organisation The organisation uuid. + * @param string $templateId The desk's asset template for new records (TOPdesk), or empty. + * + * @AuthorizedAdminSetting(settings=OCA\Stackiq\Settings\StackiqAdmin) + * + * @return JSONResponse The outcome; 422 when nothing was created. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + #[AuthorizedAdminSetting(settings: StackiqAdmin::class)] + public function setUp(string $desk = '', string $organisation = '', string $templateId = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['created' => false, 'message' => 'Sign in first.'], Http::STATUS_UNAUTHORIZED); + } + + $result = $this->exchange->setUp(desk: $desk, organisation: $organisation, runAs: $user->getUID(), templateId: trim($templateId)); + if ($result['created'] !== true) { + return new JSONResponse($result, Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return new JSONResponse($result); + }//end setUp() + + /** + * Import a CSV or XLSX file. + * + * @AuthorizedAdminSetting(settings=OCA\Stackiq\Settings\StackiqAdmin) + * + * @return JSONResponse The outcome; 422 when nothing started. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import + */ + #[AuthorizedAdminSetting(settings: StackiqAdmin::class)] + public function import(): JSONResponse { + $file = $this->request->getUploadedFile('file'); + if (is_array($file) === false || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || is_string($file['tmp_name'] ?? null) === false) { + return new JSONResponse(['started' => false, 'message' => 'Choose a .csv or .xlsx file to import.'], Http::STATUS_BAD_REQUEST); + } + + $result = $this->fileImport->import(path: $file['tmp_name'], name: (string) ($file['name'] ?? '')); + if ($result['started'] !== true) { + return new JSONResponse($result, Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return new JSONResponse($result); + }//end import() +}//end class diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 2064ade21..2e5fd4b2e 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,31 @@ public function getProgress(string $operationId): JSONResponse { }//end try }//end getProgress() + /** + * Whether a user may read the progress of an operation. + * + * Progress lives in a store every request reads (the distributed cache, or + * the app config without a shared 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 +1382,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 +1596,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 +1644,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 +1713,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 +1751,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 +2092,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 +2109,7 @@ public function updateEmailSettings(): JSONResponse { $emailSettings = $data['emailSettings'] ?? $data; $updatedSettings = $this->settingsService->updateEmailSettings($emailSettings); + $this->connectionReports?->emailSettingsSaved(); return new JSONResponse( [ @@ -2786,8 +2855,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 +2934,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/Controller/SetupController.php b/lib/Controller/SetupController.php index 02d778a47..8686c4501 100644 --- a/lib/Controller/SetupController.php +++ b/lib/Controller/SetupController.php @@ -68,11 +68,10 @@ class SetupController extends Controller { /** * App-config key holding the dataset the operator picked. * - * The wizard's `choice` step writes it through `POST /api/setup/config`, and - * the `run-action` step that follows reads it back. Two steps rather than - * one because `CnSetupWizard::runAction()` posts to - * `/api/setup/action/{action}` with no body: an action cannot carry the - * answer, so the answer has to be stored before the action runs. + * The wizard's `choice` step writes it through `POST /api/setup/config`. + * Each card's Load button posts `{ dataset }` to the `load-demo-data` + * action, which stores the same key once the load succeeds, so both routes + * land in one place (`loadAction` on the step, wizard-dataset-card-load). * * @var string */ @@ -107,7 +106,7 @@ public function __construct( * * @return JSONResponse The status document. * - * @spec exclude Setup status document; ADR-042 contract, no per-app behavioural spec. + * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md */ #[AuthorizedAdminSetting(StackiqAdmin::class)] public function status(): JSONResponse { @@ -122,13 +121,16 @@ public function status(): JSONResponse { // `optionsSource: datasets` and no options of its own, so a // dataset missing from this list is a dataset nobody can pick. 'datasets' => $this->demoDataService->listChoices(), + // Every id of `manifest.setup.steps`, asserted by the status + // contract test: a step the server never reports stays open, + // and an open step reopens the wizard on every page. 'steps' => [ - 'demo-data' => ['done' => ($picked !== '')], - // "None" is an ANSWER, so the load step is finished the moment - // it is chosen: there is nothing left for the operator to run. - 'load-demo-data' => [ - 'done' => ($demoDecided === true || $picked === DemoDataService::NONE_DATASET), - ], + 'welcome' => ['done' => true], + // Answered once a card was picked ("None" included) or a load + // ran. A pick without a load still counts: a wizard that + // predates `loadAction` can only record the pick. + 'demo-data' => ['done' => ($demoDecided === true || $picked !== '')], + 'done' => ['done' => true], ], ] ); @@ -140,7 +142,7 @@ public function status(): JSONResponse { * * @return JSONResponse `{ success, config }`. * - * @spec exclude Setup config write; ADR-042 contract, no per-app behavioural spec. + * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md */ #[AuthorizedAdminSetting(StackiqAdmin::class)] public function saveConfig(): JSONResponse { @@ -161,22 +163,12 @@ public function saveConfig(): JSONResponse { $submitted = ($value[0] ?? null); } - if (is_scalar($submitted) === false) { - return new JSONResponse( - data: ['success' => false, 'message' => 'A dataset is named by a string.'], - statusCode: Http::STATUS_BAD_REQUEST, - ); + $refusal = $this->refuseDataset(value: $submitted); + if ($refusal !== null) { + return $refusal; } $datasetId = (string)$submitted; - $known = array_column($this->demoDataService->listChoices(), 'id'); - if (in_array($datasetId, $known, true) === false) { - return new JSONResponse( - data: ['success' => false, 'message' => 'No dataset is called "' . $datasetId . '".'], - statusCode: Http::STATUS_BAD_REQUEST, - ); - } - $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, $datasetId); return new JSONResponse(data: ['success' => true, 'config' => [self::DATASET_KEY => $datasetId]]); @@ -223,7 +215,8 @@ public function runAction(string $actionId): JSONResponse { }//end runAction() /** - * Import the dataset the operator picked in the previous step. + * Import the dataset a card's Load button posted as `dataset`, or the + * stored pick when nothing is posted. * * @param string $actionId The action that asked, which decides whether an * unanswered choice is refused or means the shipped set. @@ -233,10 +226,26 @@ public function runAction(string $actionId): JSONResponse { * throws instead of returning an empty result. * * @return JSONResponse `{ success, message }`. + * + * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md */ private function loadDataset(string $actionId): JSONResponse { $picked = $this->appConfig->getValueString(Application::APP_ID, self::DATASET_KEY, ''); + // The card's Load button names its dataset in the body. An older wizard + // posts nothing and relies on the choice stored a step earlier. Nothing + // is stored before the load succeeds: a failed load must leave the step + // open for an operator who asked for data and got none. + $posted = $this->request->getParam('dataset'); + if ($posted !== null) { + $refusal = $this->refuseDataset(value: $posted); + if ($refusal !== null) { + return $refusal; + } + + $picked = (string)$posted; + } + // The legacy id carries no answer, so it means the shipped dataset. A // caller that posts it has said which one by posting it. if ($actionId === 'install-demo-data' && $picked === '') { @@ -254,6 +263,7 @@ private function loadDataset(string $actionId): JSONResponse { } if ($picked === DemoDataService::NONE_DATASET) { + $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, DemoDataService::NONE_DATASET); $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'skipped'); return new JSONResponse(data: ['success' => true, 'message' => 'No example data was loaded.']); @@ -273,6 +283,8 @@ private function loadDataset(string $actionId): JSONResponse { ); } + // Loading IS choosing the set, so the pick is recorded too. + $this->appConfig->setValueString(Application::APP_ID, self::DATASET_KEY, $picked); $this->appConfig->setValueString(Application::APP_ID, self::DEMO_DECIDED_KEY, 'installed'); return new JSONResponse( @@ -283,4 +295,36 @@ private function loadDataset(string $actionId): JSONResponse { ); }//end loadDataset() + + /** + * Refuse a dataset id no dataset answers to. + * + * Shared by the choice step's config write and the card's Load button, so + * both refuse the same values with the same message. + * + * @param mixed $value The posted value. + * + * @return JSONResponse|null The refusal, or null when the dataset is known. + * + * @spec openspec/changes/wizard-dataset-card-load/specs/first-time-setup/spec.md + */ + private function refuseDataset(mixed $value): ?JSONResponse { + if (is_scalar($value) === false) { + return new JSONResponse( + data: ['success' => false, 'message' => 'A dataset is named by a string.'], + statusCode: Http::STATUS_BAD_REQUEST, + ); + } + + $known = array_column($this->demoDataService->listChoices(), 'id'); + if (in_array((string)$value, $known, true) === true) { + return null; + } + + return new JSONResponse( + data: ['success' => false, 'message' => 'No dataset is called "' . (string)$value . '".'], + statusCode: Http::STATUS_BAD_REQUEST, + ); + + }//end refuseDataset() }//end class diff --git a/lib/Dashboard/ConceptOrganisatiesWidget.php b/lib/Dashboard/ConceptOrganisatiesWidget.php index 2c7d72972..8da5c906b 100644 --- a/lib/Dashboard/ConceptOrganisatiesWidget.php +++ b/lib/Dashboard/ConceptOrganisatiesWidget.php @@ -37,15 +37,17 @@ public function __construct( * Returns the unique widget identifier. * * @return string The widget ID + * + * @spec openspec/specs/app-id-rename/spec.md#requirement-externally-owned-identifiers-stay-frozen */ public function getId(): string { - // FROZEN across the stackiq -> stackiq rename. The Dashboard app + // FROZEN across the softwarecatalog -> stackiq rename. The Dashboard app // stores each user's chosen widgets BY WIDGET ID, in its own `dashboard` // appid namespace in `oc_preferences` — data this app's repair steps // cannot reach. Renaming this id therefore does not error: the widget // simply stops matching the stored selection and silently vanishes from // every dashboard that had it. - return 'stackiq_concept_organisaties_widget'; + return 'softwarecatalog_concept_organisaties_widget'; }//end getId() /** 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/EventListener/ModuleVersionPublicationListener.php b/lib/EventListener/ModuleVersionPublicationListener.php new file mode 100644 index 000000000..1e143b513 --- /dev/null +++ b/lib/EventListener/ModuleVersionPublicationListener.php @@ -0,0 +1,79 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\EventListener; + +use OCA\OpenRegister\Event\ObjectCreatedEvent; +use OCA\OpenRegister\Event\ObjectDeletedEvent; +use OCA\OpenRegister\Event\ObjectUpdatedEvent; +use OCA\Stackiq\Service\ModuleVersionPublicationService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Hands every created, updated or deleted object to the publication mirror. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + * + * @template-implements IEventListener + */ +class ModuleVersionPublicationListener implements IEventListener { + + /** + * Constructor. + * + * @param ModuleVersionPublicationService $publication The mirror. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly ModuleVersionPublicationService $publication, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle an object event. + * + * @param Event $event The event. + * + * @return void + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public function handle(Event $event): void { + try { + if ($event instanceof ObjectUpdatedEvent) { + $this->publication->objectSaved(object: $event->getNewObject(), previous: $event->getOldObject()); + } + + if ($event instanceof ObjectCreatedEvent) { + $this->publication->objectSaved(object: $event->getObject()); + } + + if ($event instanceof ObjectDeletedEvent) { + $this->publication->objectDeleted(object: $event->getObject()); + } + } catch (Throwable $e) { + $this->logger->error('ModuleVersionPublicationListener: could not mirror the publication', ['error' => $e->getMessage()]); + } + }//end handle() +}//end class diff --git a/lib/Exception/CmdbImportException.php b/lib/Exception/CmdbImportException.php new file mode 100644 index 000000000..31404c505 --- /dev/null +++ b/lib/Exception/CmdbImportException.php @@ -0,0 +1,126 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Exception; + +use RuntimeException; +use Throwable; + +/** + * A CMDB import failure with a contract error code and an HTTP status. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ +class CmdbImportException extends RuntimeException { + public const NOT_XLSX = 'NOT_XLSX'; + public const NO_SOURCE_SHEET = 'NO_SOURCE_SHEET'; + public const MISSING_COLUMN = 'MISSING_COLUMN'; + public const TOO_MANY_ROWS = 'TOO_MANY_ROWS'; + public const MUNICIPALITY_REQUIRED = 'MUNICIPALITY_REQUIRED'; + public const MUNICIPALITY_INVALID = 'MUNICIPALITY_INVALID'; + public const MAPPING_UNAVAILABLE = 'MAPPING_UNAVAILABLE'; + public const READER_UNAVAILABLE = 'READER_UNAVAILABLE'; + public const NOT_CONFIGURED = 'NOT_CONFIGURED'; + public const WORKBOOK_TOO_LARGE = 'WORKBOOK_TOO_LARGE'; + public const SCHEMA_OUTDATED = 'SCHEMA_OUTDATED'; + public const IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS'; + public const MUNICIPALITY_AMBIGUOUS = 'MUNICIPALITY_AMBIGUOUS'; + + /** + * HTTP status per error code. + * + * @var array + */ + private const STATUS = [ + self::NOT_XLSX => 400, + self::NO_SOURCE_SHEET => 422, + self::MISSING_COLUMN => 422, + self::TOO_MANY_ROWS => 422, + self::MUNICIPALITY_REQUIRED => 422, + self::MUNICIPALITY_INVALID => 422, + self::MAPPING_UNAVAILABLE => 503, + self::READER_UNAVAILABLE => 503, + self::NOT_CONFIGURED => 503, + self::WORKBOOK_TOO_LARGE => 413, + self::SCHEMA_OUTDATED => 503, + self::IMPORT_IN_PROGRESS => 409, + self::MUNICIPALITY_AMBIGUOUS => 422, + ]; + + /** + * Constructor. + * + * @param string $errorCode One of the class constants. + * @param string $message English log message, no person data. + * @param array $details Contract details, e.g. sheet and column. + * @param Throwable|null $previous The cause, if any. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function __construct( + private readonly string $errorCode, + string $message, + private readonly array $details = [], + ?Throwable $previous = null, + ) { + parent::__construct(message: $message, code: 0, previous: $previous); + }//end __construct() + + /** + * The contract error code, e.g. `MISSING_COLUMN`. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function getErrorCode(): string { + return $this->errorCode; + }//end getErrorCode() + + /** + * The HTTP status the controller answers with. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function getHttpStatus(): int { + return (self::STATUS[$this->errorCode] ?? 500); + }//end getHttpStatus() + + /** + * The contract details of the error. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function getDetails(): array { + return $this->details; + }//end getDetails() +}//end class diff --git a/lib/Repair/BackfillModuleVersionPublication.php b/lib/Repair/BackfillModuleVersionPublication.php new file mode 100644 index 000000000..861084554 --- /dev/null +++ b/lib/Repair/BackfillModuleVersionPublication.php @@ -0,0 +1,166 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Repair; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\Stackiq\AppInfo\Application; +use OCA\Stackiq\Service\ModuleVersionPublicationService; +use OCA\Stackiq\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Throwable; + +/** + * Backfills the mirrored publication on module versions. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ +class BackfillModuleVersionPublication implements IRepairStep { + + /** + * App-config key set after a pass in which every module and version was handled. + * + * @var string + */ + public const DONE_CONFIG_KEY = 'module_version_publication_backfilled'; + + /** + * How many modules one read returns. + * + * @var integer + */ + public const PAGE_SIZE = 200; + + /** + * Constructor. + * + * @param IAppManager $appManager Tells whether OpenRegister is installed. + * @param SettingsService $settingsService Resolves the module schema and the object service. + * @param ModuleVersionPublicationService $publication The mirror. + * @param IAppConfig $appConfig Holds the done marker. + */ + public function __construct( + private readonly IAppManager $appManager, + private readonly SettingsService $settingsService, + private readonly ModuleVersionPublicationService $publication, + private readonly IAppConfig $appConfig, + ) { + }//end __construct() + + /** + * The step's name. + * + * @return string The name. + */ + public function getName(): string { + return 'Copy each application\'s publication onto its versions'; + }//end getName() + + /** + * Run the backfill. + * + * @param IOutput $output The output. + * + * @return void + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public function run(IOutput $output): void { + if ($this->appConfig->getValueBool(Application::APP_ID, self::DONE_CONFIG_KEY, false) === true) { + return; + } + + if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) { + $output->info('OpenRegister not installed, so there are no versions to update.'); + return; + } + + $register = $this->settingsService->getRegisterIdForObjectType('module'); + $schema = $this->settingsService->getSchemaIdForObjectType('module'); + $objects = $this->settingsService->getObjectService(); + if ($register === null || $schema === null || $objects === null) { + $output->info('The module schema is not configured yet, so there are no versions to update.'); + return; + } + + try { + $result = $this->backfillAll(objects: $objects, register: $register, schema: $schema); + } catch (Throwable $e) { + $output->warning('Could not read the applications: ' . $e->getMessage()); + return; + } + + $output->info($result['written'] . ' versions now follow their application\'s publication.'); + if ($result['failed'] > 0) { + $output->warning($result['failed'] . ' versions or searches failed; the next upgrade tries again.'); + return; + } + + $this->appConfig->setValueBool(Application::APP_ID, self::DONE_CONFIG_KEY, true); + }//end run() + + /** + * Backfill every module, page by page. + * + * @param ObjectServiceInterface $objects The object service. + * @param int|string $register The module register. + * @param int|string $schema The module schema. + * + * @return array{written: int, failed: int} The versions written, and the versions or searches that failed. + * + * @throws Throwable When a page of modules cannot be read. + */ + private function backfillAll(ObjectServiceInterface $objects, int|string $register, int|string $schema): array { + $total = ['written' => 0, 'failed' => 0]; + $offset = 0; + do { + $modules = (array) $objects->setRegister($register)->setSchema($schema)->findAll( + ['limit' => self::PAGE_SIZE, 'offset' => $offset], + false, + false + ); + + foreach ($modules as $module) { + if (($module instanceof ObjectEntityInterface) === true) { + $result = $this->publication->backfillModule(module: $module); + $total['written'] += $result['written']; + $total['failed'] += $result['failed']; + } + } + + $offset += self::PAGE_SIZE; + $pageSize = count($modules); + } while ($pageSize === self::PAGE_SIZE); + + return $total; + }//end backfillAll() +}//end class diff --git a/lib/Service/ArchiMateExportService.php b/lib/Service/ArchiMateExportService.php index ac3654009..f94410edb 100644 --- a/lib/Service/ArchiMateExportService.php +++ b/lib/Service/ArchiMateExportService.php @@ -53,6 +53,8 @@ * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.CamelCaseVariableName) * @SuppressWarnings(PHPMD.CamelCaseParameterName) + * + * @spec openspec/specs/archimate-export/spec.md */ class ArchiMateExportService { /** @@ -2143,11 +2145,6 @@ private function reconstructNestedXmlStructure(array $archiMateData): array { private function runQualityAssuranceChecks(string $xmlString): void { $this->logger->info('Running Quality Assurance checks on exported XML'); - // DEBUG: Save XML to file for inspection. - $debugPath = '/tmp/debug_export.xml'; - file_put_contents($debugPath, $xmlString); - $this->logger->info('DEBUG: Raw XML saved to ' . $debugPath . ' (size: ' . strlen($xmlString) . ' bytes)'); - try { $xml = new \SimpleXMLElement($xmlString); diff --git a/lib/Service/ArchiMateImportService.php b/lib/Service/ArchiMateImportService.php index a1a4a1576..7f06a382c 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,10 @@ public function importArchiMateFileFromPathOptimized(array $options = []): array ] ); + if ($this->operationId !== null) { + $this->progressTracker->failOperation(message: $e->getMessage()); + } + return [ 'success' => false, 'error' => $e->getMessage(), @@ -1343,7 +1391,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 +1458,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 +1532,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/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php new file mode 100644 index 000000000..1e8805c90 --- /dev/null +++ b/lib/Service/Cmdb/CmdbImportProfile.php @@ -0,0 +1,724 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use OCA\Stackiq\Exception\CmdbImportException; +use Psr\Container\ContainerInterface; +use Throwable; + +/** + * The validated import profile plus its packs. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One small accessor per profile setting, so + * callers never read the raw JSON. + * @SuppressWarnings(PHPMD.TooManyMethods) The same accessors, plus the loader's small private helpers. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The accessors each guard against a + * malformed profile value; the sum passes the threshold, no single method is complex. + */ +class CmdbImportProfile { + /** + * OpenRegister's pack validator (not a public contract). + */ + public const VALIDATOR_CLASS = 'OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator'; + + /** + * The targets every profile must name a pack for. + * + * @var array + */ + public const TARGETS = ['module', 'manufacturer', 'municipality', 'usage', 'businessOwner']; + + /** + * Default upload limit when the profile file cannot be read (10 MB). + */ + public const DEFAULT_MAX_FILE_BYTES = 10485760; + + /** + * Default limit on the unpacked size of a workbook (50 MB). + */ + public const DEFAULT_MAX_UNCOMPRESSED_BYTES = 52428800; + + /** + * Default limit on the unpacked size of any one part of a workbook (10 MB). + */ + public const DEFAULT_MAX_PART_BYTES = 10485760; + + /** + * Default limit on the number of entries in a workbook's shared-strings table. + */ + public const DEFAULT_MAX_SHARED_STRINGS = 200000; + + /** + * Default limit on the shared-string text a workbook's cells reference together (64 MB). + */ + public const DEFAULT_MAX_REFERENCED_STRING_BYTES = 67108864; + + /** + * Sources of the municipality pack that come from the request, not from a sheet. + * + * @var array + */ + private const OPTION_SOURCES = ['municipalityName']; + + /** + * The decoded profile, once loaded. + * + * @var array|null + */ + private ?array $profile = null; + + /** + * The validated packs per target, once loaded. + * + * @var array> + */ + private array $packs = []; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves OpenRegister's validator. + * @param string|null $directory Directory of the profile and packs; null is the shipped one. + * @param string $profileFile File name of the profile inside the directory. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function __construct( + private readonly ContainerInterface $container, + private ?string $directory = null, + private readonly string $profileFile = 'topdesk-profile.json', + ) { + if ($this->directory === null) { + $this->directory = __DIR__ . '/../../Settings/cmdb-import'; + } + }//end __construct() + + /** + * Load the profile and validate every pack it names. + * + * @return void + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing, + * or the profile or a pack is unreadable or invalid. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function load(): void { + $validator = $this->resolveValidator(); + $profile = $this->decodeFile(fileName: $this->profileFile); + + $packs = []; + foreach (self::TARGETS as $target) { + $fileName = $profile['packs'][$target] ?? null; + if (is_string($fileName) === false || $fileName === '' || basename($fileName) !== $fileName) { + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'CMDB import profile names no pack for target ' . $target + ); + } + + $pack = $this->decodeFile(fileName: $fileName); + $errors = $validator->validate($pack); + if (is_array($errors) === true && $errors !== []) { + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'CMDB mapping pack ' . $fileName . ' is invalid: ' . implode('; ', array_map('strval', $errors)) + ); + } + + $packs[$target] = $pack; + } + + $this->profile = $profile; + $this->packs = $packs; + }//end load() + + /** + * The upload limit in bytes, readable without validating the packs. + * + * The controller checks the size before anything else, so this must not + * depend on OpenRegister being available. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function maxFileBytes(): int { + try { + $profile = $this->profile ?? $this->decodeFile(fileName: $this->profileFile); + } catch (CmdbImportException $e) { + return self::DEFAULT_MAX_FILE_BYTES; + } + + $limit = $profile['maxFileBytes'] ?? null; + if (is_int($limit) === true && $limit > 0) { + return $limit; + } + + return self::DEFAULT_MAX_FILE_BYTES; + }//end maxFileBytes() + + /** + * The limit on the unpacked size of a workbook, in bytes. + * + * The upload limit is on the compressed file; a sheet of identical rows + * compresses a hundredfold, so the unpacked size is bounded too. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function maxUncompressedBytes(): int { + $limit = $this->profile()['maxUncompressedBytes'] ?? null; + if (is_int($limit) === true && $limit > 0) { + return $limit; + } + + return self::DEFAULT_MAX_UNCOMPRESSED_BYTES; + }//end maxUncompressedBytes() + + /** + * The limit on the unpacked size of any one part of a workbook, in bytes. + * + * PhpSpreadsheet parses the shared-strings part and every loaded sheet part + * whole, into structures many times the part's size, so the parts are + * bounded one by one, not only their total. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function maxPartBytes(): int { + $limit = $this->profile()['maxPartBytes'] ?? null; + if (is_int($limit) === true && $limit > 0) { + return $limit; + } + + return self::DEFAULT_MAX_PART_BYTES; + }//end maxPartBytes() + + /** + * The limit on the number of entries in a workbook's shared-strings table. + * + * PhpSpreadsheet builds the whole table before it reads a sheet, and many + * short strings cost far more memory than their bytes. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function maxSharedStrings(): int { + $limit = $this->profile()['maxSharedStrings'] ?? null; + if (is_int($limit) === true && $limit > 0) { + return $limit; + } + + return self::DEFAULT_MAX_SHARED_STRINGS; + }//end maxSharedStrings() + + /** + * The limit on the shared-string text a workbook's cells reference together, in bytes. + * + * PhpSpreadsheet gives every cell that references a shared string its own + * copy of the text, so one long string referenced by many cells costs far + * more memory than the file's size; the reader adds up what the cells + * reference before it parses a sheet. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function maxReferencedStringBytes(): int { + $limit = $this->profile()['maxReferencedStringBytes'] ?? null; + if (is_int($limit) === true && $limit > 0) { + return $limit; + } + + return self::DEFAULT_MAX_REFERENCED_STRING_BYTES; + }//end maxReferencedStringBytes() + + /** + * The maximum number of non-empty rows per source sheet. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function maxRowsPerSheet(): int { + $limit = $this->profile()['maxRowsPerSheet'] ?? 10000; + if (is_int($limit) === false || $limit < 0) { + return 10000; + } + + return $limit; + }//end maxRowsPerSheet() + + /** + * The source sheets, each with the constants it adds to its rows and the + * pack columns it is known not to have. + * + * @return array, absentColumns: array}> + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function sheets(): array { + $sheets = []; + foreach (($this->profile()['sheets'] ?? []) as $sheet) { + if (is_array($sheet) === false || is_string($sheet['name'] ?? null) === false) { + continue; + } + + $constants = []; + if (is_array($sheet['constants'] ?? null) === true) { + foreach ($sheet['constants'] as $column => $value) { + if (is_scalar($value) === true) { + $constants[(string)$column] = (string)$value; + } + } + } + + $absent = []; + if (is_array($sheet['absentColumns'] ?? null) === true) { + $absent = array_values(array_map('strval', $sheet['absentColumns'])); + } + + $sheets[] = ['name' => $sheet['name'], 'constants' => $constants, 'absentColumns' => $absent]; + }//end foreach + + return $sheets; + }//end sheets() + + /** + * The names of the source sheets. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function sheetNames(): array { + return array_column($this->sheets(), 'name'); + }//end sheetNames() + + /** + * The rank of a sheet when an APPID is on more than one: lower wins. + * + * The profile's `sheetPrecedence` lists the sheets, the winner first; a + * sheet it does not list ranks after every listed one, in profile order. + * + * @param string $sheetName The sheet name. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function sheetRank(string $sheetName): int { + $order = array_values(array_unique(array_merge($this->stringList(key: 'sheetPrecedence'), $this->sheetNames()))); + $rank = array_search($sheetName, $order, true); + if ($rank === false) { + return count($order); + } + + return (int)$rank; + }//end sheetRank() + + /** + * The constants a sheet adds to each of its rows, as column => value. + * + * A constant is mapped like a column (the usage pack reads "Beheer"), but + * it is never looked up in the sheet. + * + * @param string $sheetName The sheet name. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function sheetConstants(string $sheetName): array { + foreach ($this->sheets() as $sheet) { + if ($sheet['name'] === $sheetName) { + return $sheet['constants']; + } + } + + return []; + }//end sheetConstants() + + /** + * The pack columns a sheet is known not to have; their absence is no warning. + * + * @param string $sheetName The sheet name. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function absentColumns(string $sheetName): array { + foreach ($this->sheets() as $sheet) { + if ($sheet['name'] === $sheetName) { + return $sheet['absentColumns']; + } + } + + return []; + }//end absentColumns() + + /** + * The names of every sheet constant; these are never read from a sheet. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function constantColumns(): array { + $columns = []; + foreach ($this->sheets() as $sheet) { + $columns = array_merge($columns, array_keys($sheet['constants'])); + } + + return array_values(array_unique(array_map('strval', $columns))); + }//end constantColumns() + + /** + * Values that mean "empty" per column, such as the "NB" a CMDB sheet + * writes for an unknown BNN classification. + * + * @return array> + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function emptyValues(): array { + $value = $this->profile()['emptyValues'] ?? []; + if (is_array($value) === false) { + return []; + } + + $empty = []; + foreach ($value as $column => $values) { + if (is_array($values) === true) { + $empty[(string)$column] = array_values(array_map('strval', $values)); + } + } + + return $empty; + }//end emptyValues() + + /** + * The match key column ("APPID"). + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function keyColumn(): string { + return (string)($this->profile()['keyColumn'] ?? 'APPID'); + }//end keyColumn() + + /** + * The application name column ("Applicatie Naam"). + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function nameColumn(): string { + return (string)($this->profile()['nameColumn'] ?? 'Applicatie Naam'); + }//end nameColumn() + + /** + * Columns whose absence stops the import with MISSING_COLUMN. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function requiredColumns(): array { + return $this->stringList(key: 'requiredColumns'); + }//end requiredColumns() + + /** + * Columns that hold Excel serial dates. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function dateColumns(): array { + return $this->stringList(key: 'dateColumns'); + }//end dateColumns() + + /** + * Columns that hold identifiers which must not carry a decimal part. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function idColumns(): array { + return $this->stringList(key: 'idColumns'); + }//end idColumns() + + /** + * The prefix of the module match key ("topdesk"). + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function externalKeyPrefix(): string { + return (string)($this->profile()['externalKeyPrefix'] ?? 'topdesk'); + }//end externalKeyPrefix() + + /** + * The validated pack for a target. + * + * @param string $target One of self::TARGETS. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function pack(string $target): array { + $this->profile(); + return ($this->packs[$target] ?? []); + }//end pack() + + /** + * Values set on create only, per target, as field => value. + * + * @param string $target The target. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function createOnlyDefaults(string $target): array { + $value = $this->profile()['createOnly'][$target] ?? []; + if (is_array($value) === false || array_is_list($value) === true) { + return []; + } + + return $value; + }//end createOnlyDefaults() + + /** + * Mapped fields written on create, or on update only when the stored value is empty. + * + * @param string $target The target. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function createOnlyFields(string $target): array { + $value = $this->profile()['createOnly'][$target] ?? []; + if (is_array($value) === false) { + return []; + } + + if (array_is_list($value) === true) { + return array_values(array_map('strval', $value)); + } + + return array_map('strval', array_keys($value)); + }//end createOnlyFields() + + /** + * Fields the import never writes on an existing object. + * + * @param string $target The target. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function neverWrittenOnUpdate(string $target): array { + $value = $this->profile()['neverWritten'][$target] ?? []; + if (is_array($value) === false) { + return []; + } + + return array_values(array_map('strval', $value)); + }//end neverWrittenOnUpdate() + + /** + * The accepted values of the missingRecords option. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function missingRecordsModes(): array { + $modes = $this->stringList(key: 'missingRecords'); + if ($modes === []) { + return ['keep']; + } + + return $modes; + }//end missingRecordsModes() + + /** + * Every column the profile or a pack references: the read allowlist. + * + * The municipality pack maps the request options, not a sheet, and the + * sheet constants are added by the import, so both are left out. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function referencedColumns(): array { + $columns = array_merge( + [$this->keyColumn(), $this->nameColumn()], + $this->requiredColumns(), + $this->dateColumns(), + $this->idColumns() + ); + + foreach (self::TARGETS as $target) { + foreach (($this->pack(target: $target)['fieldMappings'] ?? []) as $mapping) { + $columns[] = (string)($mapping['source'] ?? ''); + foreach (($mapping['transform']['fields'] ?? []) as $extra) { + $columns[] = (string)$extra; + } + } + } + + $excluded = array_merge(self::OPTION_SOURCES, $this->constantColumns()); + $columns = array_filter( + $columns, + fn (string $column): bool => $column !== '' && $column[0] !== '/' && in_array($column, $excluded, true) === false + ); + + return array_values(array_unique($columns)); + }//end referencedColumns() + + /** + * The loaded profile. + * + * @return array + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when load() failed. + */ + private function profile(): array { + if ($this->profile === null) { + $this->load(); + } + + return ($this->profile ?? []); + }//end profile() + + /** + * A list of strings from the profile. + * + * @param string $key The profile key. + * + * @return array + */ + private function stringList(string $key): array { + $value = $this->profile()[$key] ?? []; + if (is_array($value) === false) { + return []; + } + + return array_values(array_map('strval', $value)); + }//end stringList() + + /** + * Resolve OpenRegister's pack validator. + * + * @return object The validator, with a `validate(array): array` method. + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when it is not available. + */ + private function resolveValidator(): object { + $class = static::VALIDATOR_CLASS; + + try { + if ($this->container->has($class) === true) { + $validator = $this->container->get($class); + if (is_object($validator) === true && method_exists($validator, 'validate') === true) { + return $validator; + } + } + } catch (Throwable $e) { + // Fall through to the class check below. + $validator = null; + } + + if (class_exists($class) === true) { + $validator = new $class(); + if (method_exists($validator, 'validate') === true) { + return $validator; + } + } + + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'OpenRegister PackDefinitionValidator is not available' + ); + }//end resolveValidator() + + /** + * Read and decode one JSON file from the profile directory. + * + * @param string $fileName The file name. + * + * @return array + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the file is missing or not a JSON object. + */ + private function decodeFile(string $fileName): array { + $path = $this->directory . '/' . $fileName; + $content = false; + if (is_readable($path) === true) { + $content = file_get_contents($path); + } + + $decoded = null; + if (is_string($content) === true) { + $decoded = json_decode($content, true); + } + + if (is_array($decoded) === false) { + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'CMDB import file ' . $fileName . ' is missing or not valid JSON' + ); + } + + return $decoded; + }//end decodeFile() +}//end class diff --git a/lib/Service/Cmdb/CmdbImportReport.php b/lib/Service/Cmdb/CmdbImportReport.php new file mode 100644 index 000000000..730365fde --- /dev/null +++ b/lib/Service/Cmdb/CmdbImportReport.php @@ -0,0 +1,299 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +/** + * The per-row report of one CMDB import run. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ +class CmdbImportReport { + public const CREATED = 'created'; + public const UPDATED = 'updated'; + public const UNCHANGED = 'unchanged'; + public const SKIPPED = 'skipped'; + public const FAILED = 'failed'; + + /** + * The row entries, in processing order. + * + * @var array> + */ + private array $rows = []; + + /** + * Import-level warnings. + * + * @var array + */ + private array $importWarnings = []; + + /** + * Whether the run stopped on a cancel. + * + * @var bool + */ + private bool $cancelled = false; + + /** + * The consuming municipality. + * + * @var array{uuid: string, name: string, created: bool}|null + */ + private ?array $municipality = null; + + /** + * Modules this run created without a publication date. + * + * @var int + */ + private int $unpublished = 0; + + /** + * Constructor. + * + * @param string $operationId The progress operation id. + * @param int $rowsRead Non-empty rows read from the workbook. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function __construct( + private readonly string $operationId, + private readonly int $rowsRead, + ) { + }//end __construct() + + /** + * Add one row outcome. + * + * @param string $sheet The sheet name. + * @param int $row The 1-based sheet row number. + * @param string $appId The APPID ('' when missing). + * @param string $name The application name ('' when missing). + * @param string $outcome One of the outcome constants. + * @param array $reasons Why the row was skipped or failed. + * @param array $warnings Row warnings. + * @param string|null $moduleUuid The module, when there is one. + * @param string|null $usageUuid The usage, when there is one. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function addRow( + string $sheet, + int $row, + string $appId, + string $name, + string $outcome, + array $reasons = [], + array $warnings = [], + ?string $moduleUuid = null, + ?string $usageUuid = null, + ): void { + $this->rows[] = [ + 'sheet' => $sheet, + 'row' => $row, + 'appId' => $appId, + 'name' => $name, + 'outcome' => $outcome, + 'reasons' => array_values($reasons), + 'warnings' => array_values($warnings), + 'moduleUuid' => $moduleUuid, + 'usageUuid' => $usageUuid, + ]; + }//end addRow() + + /** + * Add import-level warnings, such as a missing optional column. + * + * @param array $warnings The warnings. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function addImportWarnings(array $warnings): void { + foreach ($warnings as $warning) { + $this->importWarnings[] = ['sheet' => (string)$warning['sheet'], 'message' => (string)$warning['message']]; + } + }//end addImportWarnings() + + /** + * Record the consuming municipality. + * + * @param string $uuid The organisation uuid. + * @param string $name Its name. + * @param bool $created Whether this run created it. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function setMunicipality(string $uuid, string $name, bool $created): void { + $this->municipality = ['uuid' => $uuid, 'name' => $name, 'created' => $created]; + }//end setMunicipality() + + /** + * Count a module this run created without publishing it. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function countUnpublished(): void { + $this->unpublished++; + }//end countUnpublished() + + /** + * Mark the run as stopped on a cancel. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function markCancelled(): void { + $this->cancelled = true; + }//end markCancelled() + + /** + * The number of rows processed so far. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function processed(): int { + return count($this->rows); + }//end processed() + + /** + * The summary counts. + * + * `unpublished` counts the modules created without a publication date; + * a row whose module was created but whose usage then failed counts too. + * + * @return array{rowsRead: int, processed: int, created: int, updated: int, unchanged: int, skipped: int, failed: int, + * warnings: int, unpublished: int} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function summary(): array { + $summary = [ + 'rowsRead' => $this->rowsRead, + 'processed' => count($this->rows), + self::CREATED => 0, + self::UPDATED => 0, + self::UNCHANGED => 0, + self::SKIPPED => 0, + self::FAILED => 0, + 'warnings' => 0, + 'unpublished' => $this->unpublished, + ]; + + foreach ($this->rows as $row) { + $summary[$row['outcome']]++; + $summary['warnings'] += count($row['warnings']); + } + + return $summary; + }//end summary() + + /** + * The report in the contract shape. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function toArray(): array { + return [ + 'success' => true, + 'operationId' => $this->operationId, + 'cancelled' => $this->cancelled, + 'municipality' => $this->municipality, + 'summary' => $this->summary(), + 'importWarnings' => $this->importWarnings, + 'rows' => $this->rows, + ]; + }//end toArray() + + /** + * The report as it is stored with the operation: the counts, and at most + * $maxRows rows. + * + * The progress entry lives in the distributed cache, where a value of a + * few megabytes is refused without an error (memcached's default item + * limit is 1 MB). The stored copy keeps every count. Within the limit it + * keeps every row; over it, the rows that need attention: failed, then + * skipped, then rows with a warning, then the rest, each in processing + * order. `rowsStored` and `rowsTruncated` say how much it holds. + * + * @param int $maxRows The most rows to keep. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function toStoredArray(int $maxRows): array { + $stored = $this->toArray(); + $stored['rowsStored'] = count($this->rows); + $stored['rowsTruncated'] = false; + if (count($this->rows) <= $maxRows) { + return $stored; + } + + $rank = static function (array $row): int { + if ($row['outcome'] === self::FAILED) { + return 0; + } + + if ($row['outcome'] === self::SKIPPED) { + return 1; + } + + if ($row['warnings'] !== []) { + return 2; + } + + return 3; + }; + + $ordered = []; + foreach ($this->rows as $index => $row) { + $ordered[] = [$rank($row), $index, $row]; + } + + usort($ordered, static fn (array $left, array $right): int => [$left[0], $left[1]] <=> [$right[0], $right[1]]); + $kept = array_column(array_slice($ordered, 0, max(0, $maxRows)), 2); + + $stored['rows'] = $kept; + $stored['rowsStored'] = count($kept); + $stored['rowsTruncated'] = true; + + return $stored; + }//end toStoredArray() +}//end class diff --git a/lib/Service/Cmdb/CmdbPartReferences.php b/lib/Service/Cmdb/CmdbPartReferences.php new file mode 100644 index 000000000..ac5580546 --- /dev/null +++ b/lib/Service/Cmdb/CmdbPartReferences.php @@ -0,0 +1,279 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use OCA\Stackiq\Exception\CmdbImportException; +use XMLReader; + +/** + * Refuses a part beyond the per-part bound, or lists it for blanking when only unread sheets refer to it. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ +class CmdbPartReferences { + /** + * Refuse the first part beyond the limit that the import may parse, and list the ones to blank. + * + * @param string $path The xlsx file. + * @param array $oversized Part name => unpacked size, for the parts beyond the limit. + * @param array $sourceSheets The sheet names the import reads. + * @param int $limit The profile's `maxPartBytes`. + * @param object $scanner PhpSpreadsheet's XmlScanner, which every part passes before it is read. + * + * @return array The names of the parts beyond the limit that only unread sheets refer to. + * + * @throws CmdbImportException WORKBOOK_TOO_LARGE for a part the import may parse, with `maxPartBytes`, + * `part`, `size` and, for a sheet, `sheet`. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function assertPartSizes(string $path, array $oversized, array $sourceSheets, int $limit, object $scanner): array { + if ($oversized === []) { + return []; + } + + $references = self::references(path: $path, scanner: $scanner, sourceSheets: $sourceSheets); + $blank = []; + foreach ($oversized as $part => $size) { + $verdict = self::verdict(part: (string)$part, references: $references); + if ($verdict['unread'] === true) { + $blank[] = (string)$part; + continue; + } + + $details = ['maxPartBytes' => $limit, 'part' => $part, 'size' => $size]; + if ($verdict['sheet'] !== null) { + $details['sheet'] = $verdict['sheet']; + } + + throw new CmdbImportException( + errorCode: CmdbImportException::WORKBOOK_TOO_LARGE, + message: 'A part of the workbook the import reads unpacks to more bytes than the profile allows', + details: $details + ); + } + + return $blank; + }//end assertPartSizes() + + /** + * Whether only unread sheets may name a part, and the sheet the refusal names. + * + * The refusal names the sheet of the relationship that keeps the limit, + * which is null when that relationship is not a sheet's. + * + * @param string $part The part's name in the package. + * @param array, sheet: ?string, unread: bool}> $references Every relationship. + * + * @return array{unread: bool, sheet: ?string} + */ + private static function verdict(string $part, array $references): array { + $key = self::lastSegment(path: $part); + if (str_ends_with($key, '.rels') === true || str_ends_with($key, 'content_types].xml') === true) { + return ['unread' => false, 'sheet' => null]; + } + + $unread = false; + foreach ($references as $reference) { + if (in_array($key, $reference['keys'], true) === false) { + continue; + } + + if ($reference['unread'] === false) { + return ['unread' => false, 'sheet' => $reference['sheet']]; + } + + $unread = true; + } + + return ['unread' => $unread, 'sheet' => null]; + }//end verdict() + + /** + * Every relationship in the package: the names it may resolve to, its sheet, and whether that sheet is unread. + * + * Every part is read, whatever its name, for `Relationship` elements and + * for `sheet` elements. A worksheet relationship takes the names of the + * sheets that refer to its id from the part its relationships file + * belongs to (`dir/_rels/name.rels` belongs to `dir/name`). + * + * @param string $path The xlsx file. + * @param object $scanner PhpSpreadsheet's XmlScanner. + * @param array $sourceSheets The sheet names the import reads. + * + * @return array, sheet: ?string, unread: bool}> + */ + private static function references(string $path, object $scanner, array $sourceSheets): array { + $relationships = []; + $sheets = []; + (new CmdbWorkbookBounds())->eachPart( + path: $path, + scanner: $scanner, + consume: static function (XMLReader $xml, string $name) use (&$relationships, &$sheets): void { + self::collect(xml: $xml, name: $name, relationships: $relationships, sheets: $sheets); + } + ); + + $read = array_map(static fn (string $sheet): string => mb_strtolower(trim($sheet)), $sourceSheets); + $references = []; + foreach ($relationships as $relationship) { + $names = []; + if ($relationship['worksheet'] === true) { + $names = $sheets[$relationship['owner']][$relationship['id']] ?? []; + } + + $unread = ($names !== []); + $sheet = ($names[0] ?? null); + foreach ($names as $sheetName) { + if (in_array(mb_strtolower(trim($sheetName)), $read, true) === true) { + $unread = false; + $sheet = $sheetName; + } + } + + $references[] = [ + 'keys' => self::keys(target: $relationship['target']), + 'sheet' => $sheet, + 'unread' => $unread, + ]; + } + + return $references; + }//end references() + + /** + * Collect the relationships and the sheet elements of one part. + * + * @param XMLReader $xml The reader, opened on the part. + * @param string $name The part's name in the package. + * @param array $relationships Added to. + * @param array>> $sheets Owner part => relationship id => sheet names; added to. + * + * @return void + */ + private static function collect(XMLReader $xml, string $name, array &$relationships, array &$sheets): void { + $owner = self::owner(relsPart: $name); + $self = mb_strtolower($name); + while ($xml->read() === true) { + if ($xml->nodeType !== XMLReader::ELEMENT) { + continue; + } + + if ($xml->localName === 'Relationship') { + $relationships[] = [ + 'owner' => $owner, + 'id' => (string)$xml->getAttribute('Id'), + 'target' => (string)$xml->getAttribute('Target'), + 'worksheet' => str_ends_with(mb_strtolower((string)$xml->getAttribute('Type')), '/worksheet'), + ]; + } elseif ($xml->localName === 'sheet' && $xml->getAttribute('name') !== null) { + $sheets[$self][self::idAttribute(xml: $xml)][] = (string)$xml->getAttribute('name'); + } + } + }//end collect() + + /** + * The value of the element's `id` attribute in any namespace (PhpSpreadsheet reads `r:id`). + * + * @param XMLReader $xml The reader, on the element; it is moved back to it. + * + * @return string + */ + private static function idAttribute(XMLReader $xml): string { + $id = ''; + if ($xml->moveToFirstAttribute() === true) { + do { + if ($xml->localName === 'id') { + $id = $xml->value; + } + } while ($xml->moveToNextAttribute() === true); + + $xml->moveToElement(); + } + + return $id; + }//end idAttribute() + + /** + * The part a relationships part belongs to, lower-cased: `dir/_rels/name.rels` belongs to `dir/name`. + * + * @param string $relsPart The relationships part's name. + * + * @return string An empty string for a part that is not named like a relationships part. + */ + private static function owner(string $relsPart): string { + $path = mb_strtolower(str_replace('\\', '/', $relsPart)); + if (preg_match('#^(?:(.*)/)?_rels/([^/]+)\.rels$#', $path, $match) !== 1) { + return ''; + } + + return ltrim($match[1] . '/' . $match[2], '/'); + }//end owner() + + /** + * The last path segments a relationship target may resolve to, lower-cased. + * + * @param string $target The relationship's `Target`. + * + * @return array + */ + private static function keys(string $target): array { + $keys = []; + foreach ([$target, rawurldecode($target)] as $candidate) { + $segment = self::lastSegment(path: $candidate); + $keys[] = $segment; + $keys[] = substr($segment, 1); + } + + return array_values(array_unique($keys)); + }//end keys() + + /** + * The last segment of a path, after `/` or `\`, lower-cased. + * + * @param string $path The path. + * + * @return string + */ + private static function lastSegment(string $path): string { + $segments = preg_split('#[/\\\\]#', $path); + return mb_strtolower((string)end($segments)); + }//end lastSegment() +}//end class diff --git a/lib/Service/Cmdb/CmdbReadFilter.php b/lib/Service/Cmdb/CmdbReadFilter.php new file mode 100644 index 000000000..87672dbe9 --- /dev/null +++ b/lib/Service/Cmdb/CmdbReadFilter.php @@ -0,0 +1,79 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use PhpOffice\PhpSpreadsheet\Reader\IReadFilter; + +/** + * Admits the header row, or the allowlisted columns of the data rows. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ +class CmdbReadFilter implements IReadFilter { + /** + * Constructor. + * + * @param int $lastRow The last row number that is read; 1 reads the header row only. + * @param array> $columns Sheet name => column letters of the data rows. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function __construct( + private readonly int $lastRow, + private readonly array $columns = [], + ) { + }//end __construct() + + /** + * Whether a cell is read. + * + * @param string $columnAddress The column letters. + * @param int $row The row number. + * @param string $worksheetName The sheet name. + * + * @return bool + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function readCell(string $columnAddress, int $row, string $worksheetName = ''): bool { + if ($row === 1) { + return $this->lastRow === 1; + } + + if ($row < 1 || $row > $this->lastRow) { + return false; + } + + return in_array(strtoupper($columnAddress), ($this->columns[$worksheetName] ?? []), true); + }//end readCell() +}//end class diff --git a/lib/Service/Cmdb/CmdbRowNormaliser.php b/lib/Service/Cmdb/CmdbRowNormaliser.php new file mode 100644 index 000000000..e2d54f5a9 --- /dev/null +++ b/lib/Service/Cmdb/CmdbRowNormaliser.php @@ -0,0 +1,219 @@ + string` row OpenRegister's `MappingEngine` expects (design D4): + * + * - Date columns: an Excel serial number becomes `Y-m-d` (1900 date system, + * or 1904 when the workbook says so). A non-numeric value stays as it is, so + * the pack's `date` transform accepts it or reports a warning. + * - Id columns: a whole number becomes a string without a decimal part + * (`1234.0` becomes `"1234"`). + * - Every value is trimmed; an empty value becomes the empty string. + * - A value the profile lists as "empty" for its column (such as the "NB" a + * CMDB sheet writes for an unknown BNN classification) becomes the empty + * string, compared case-insensitively before any conversion. + * + * The conversion lives here and not in the packs, so every pack stays a plain + * OpenRegister migration pack. + * + * @category Service + * @package OCA\Stackiq\Service\Cmdb + * @author Conduction b.v. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use DateInterval; +use DateTimeImmutable; +use DateTimeZone; + +/** + * Normalises reader rows into string rows for the mapping engine. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The date system (1900 or 1904) is a property + * of the workbook that the reader reports; it is data, not a mode switch. + */ +class CmdbRowNormaliser { + /** + * Highest serial Excel accepts (9999-12-31). + */ + private const MAX_SERIAL = 2958465; + + /** + * Normalise one row. + * + * @param array $cells Column name => raw cell value. + * @param array $dateColumns Columns holding Excel serial dates. + * @param array $idColumns Columns holding identifiers. + * @param bool $date1904 Whether the workbook uses the 1904 date system. + * @param array> $emptyValues Values that mean empty, per column. + * + * @return array Column name => normalised value. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function normalise(array $cells, array $dateColumns, array $idColumns, bool $date1904 = false, array $emptyValues = []): array { + $row = []; + foreach ($cells as $column => $value) { + $column = (string)$column; + if (self::meansEmpty(text: $this->toText(value: $value), empty: ($emptyValues[$column] ?? [])) === true) { + $row[$column] = ''; + continue; + } + + if (in_array($column, $dateColumns, true) === true) { + $row[$column] = $this->normaliseDate(value: $value, date1904: $date1904); + continue; + } + + if (in_array($column, $idColumns, true) === true) { + $row[$column] = $this->normaliseId(value: $value); + continue; + } + + $row[$column] = $this->toText(value: $value); + } + + return $row; + }//end normalise() + + /** + * An Excel serial date to `Y-m-d`; any other value trimmed as text. + * + * @param mixed $value The raw value. + * @param bool $date1904 Whether the workbook uses the 1904 date system. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function normaliseDate(mixed $value, bool $date1904 = false): string { + $text = $this->toText(value: $value); + if (is_numeric($text) === false) { + return $text; + } + + $serial = (float)$text; + if ($serial < 1 || $serial > self::MAX_SERIAL) { + return $text; + } + + $days = (int)floor($serial); + // 1900 system: 1899-12-30 plus the serial, which absorbs Excel's + // phantom 1900-02-29 for every serial after it. Before it (serial < 61) + // the base is one day later. 1904 system: serial 0 is 1904-01-01. + $base = '1899-12-30'; + if ($days < 61) { + $base = '1899-12-31'; + } + + if ($date1904 === true) { + $base = '1904-01-01'; + } + + $base = new DateTimeImmutable($base, new DateTimeZone('UTC')); + + return $base->add(new DateInterval('P' . $days . 'D'))->format('Y-m-d'); + }//end normaliseDate() + + /** + * A numeric identifier without a decimal part, as a string. + * + * @param mixed $value The raw value. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function normaliseId(mixed $value): string { + if (is_float($value) === true && floor($value) === $value && abs($value) < PHP_INT_MAX) { + return (string)(int)$value; + } + + $text = $this->toText(value: $value); + if (preg_match('/^(\d+)\.0+$/', $text, $matches) === 1) { + return $matches[1]; + } + + return $text; + }//end normaliseId() + + /** + * Whether a value is one of the column's "empty" values. + * + * @param string $text The trimmed value. + * @param array $empty The column's empty values. + * + * @return bool + */ + private static function meansEmpty(string $text, array $empty): bool { + if ($text === '' || $empty === []) { + return false; + } + + $needle = mb_strtolower($text); + foreach ($empty as $candidate) { + if (mb_strtolower(trim($candidate)) === $needle) { + return true; + } + } + + return false; + }//end meansEmpty() + + /** + * Any scalar as trimmed text; null as the empty string. + * + * Trimming removes Unicode whitespace too: a non-breaking space, a + * zero-width space or a byte-order mark around a value is not part of it. + * + * @param mixed $value The raw value. + * + * @return string + */ + private function toText(mixed $value): string { + if ($value === null) { + return ''; + } + + if (is_bool($value) === true) { + if ($value === true) { + return 'TRUE'; + } + + return 'FALSE'; + } + + if (is_float($value) === true && floor($value) === $value && abs($value) < PHP_INT_MAX) { + return (string)(int)$value; + } + + if (is_scalar($value) === false) { + return ''; + } + + $text = (string)$value; + $trimmed = preg_replace('/^[\s\x{00A0}\x{200B}\x{FEFF}]+|[\s\x{00A0}\x{200B}\x{FEFF}]+$/u', '', $text); + if ($trimmed === null) { + // Not valid UTF-8: trim the ASCII whitespace only. + return trim($text); + } + + return $trimmed; + }//end toText() +}//end class diff --git a/lib/Service/Cmdb/CmdbWorkbookBounds.php b/lib/Service/Cmdb/CmdbWorkbookBounds.php new file mode 100644 index 000000000..55826991e --- /dev/null +++ b/lib/Service/Cmdb/CmdbWorkbookBounds.php @@ -0,0 +1,455 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use OCA\Stackiq\Exception\CmdbImportException; +use Throwable; +use XMLReader; +use ZipArchive; + +/** + * Streams a workbook's parts to bound what PhpSpreadsheet can be made to hold by its shared strings. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Each helper is one small step of a streamed check; + * together they pass the threshold, and splitting the two checks apart would duplicate the streaming. + */ +class CmdbWorkbookBounds { + /** + * Refuse a shared-strings table with more entries than the limit, without building it. + * + * The table's `count` and `uniqueCount` attributes are written by the + * producer and can be wrong, so the entries are counted, streamed with + * XMLReader. PhpSpreadsheet reads the `` children of whatever part the + * workbook's relationships name, whatever that part's name or root + * element, so every part of the package is counted. It also builds every + * formatting run of a rich-text entry as objects, kept for the whole + * load, and does the same for the runs of a cell's own text (an inline + * string, ``), so each `` inside an entry or an inline string + * counts as an entry too. The count runs over all parts together. + * + * @param string $path The xlsx file. + * @param int $limit The maximum number of shared strings. + * @param object $scanner PhpSpreadsheet's XmlScanner, which every part passes before it is parsed. + * + * @return void + * + * @throws CmdbImportException WORKBOOK_TOO_LARGE above the limit. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function assertSharedStringCount(string $path, int $limit, object $scanner): void { + $count = 0; + self::streamParts( + path: $path, + scanner: $scanner, + consume: static function (XMLReader $xml) use ($limit, &$count): void { + $count = self::countSharedStrings(xml: $xml, limit: $limit, count: $count); + if ($count > $limit) { + throw new CmdbImportException( + errorCode: CmdbImportException::WORKBOOK_TOO_LARGE, + message: 'The shared-strings table holds more entries than the profile allows', + details: ['maxSharedStrings' => $limit] + ); + } + } + ); + }//end assertSharedStringCount() + + /** + * Add one part's `` children of the root element and the `` runs inside them or inside an ``, stopping past the limit. + * + * @param XMLReader $xml The reader, opened on one part. + * @param int $limit The maximum number of shared strings. + * @param int $count The entries counted so far. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + private static function countSharedStrings(XMLReader $xml, int $limit, int $count): int { + $textDepth = null; + while ($count <= $limit && $xml->read() === true) { + $count += self::entriesAt(xml: $xml, textDepth: $textDepth); + } + + return $count; + }//end countSharedStrings() + + /** + * The entries the node the reader is on adds: 1 for a shared string or a run inside a text, else 0. + * + * @param XMLReader $xml The reader, on a node. + * @param int|null $textDepth The depth of the shared or inline string the reader is inside, or null; kept up to date. + * + * @return int + */ + private static function entriesAt(XMLReader $xml, ?int &$textDepth): int { + if ($xml->nodeType === XMLReader::END_ELEMENT && $xml->depth === $textDepth) { + $textDepth = null; + return 0; + } + + if ($xml->nodeType !== XMLReader::ELEMENT) { + return 0; + } + + if ($textDepth === null && self::opensText(xml: $xml) === true) { + $textDepth = self::depthUnlessEmpty(xml: $xml); + return (int)($xml->localName === 'si'); + } + + return (int)($textDepth !== null && $xml->localName === 'r'); + }//end entriesAt() + + /** + * The depth of the element the reader is on, or null for an empty element, which has no end to wait for. + * + * @param XMLReader $xml The reader, on an element. + * + * @return int|null + */ + private static function depthUnlessEmpty(XMLReader $xml): ?int { + if ($xml->isEmptyElement === true) { + return null; + } + + return $xml->depth; + }//end depthUnlessEmpty() + + /** + * Whether the reader is on a text PhpSpreadsheet builds runs for: a shared string or an inline string. + * + * @param XMLReader $xml The reader, on an element. + * + * @return bool + */ + private static function opensText(XMLReader $xml): bool { + return ($xml->depth === 1 && $xml->localName === 'si') || $xml->localName === 'is'; + }//end opensText() + + /** + * Refuse a workbook whose cells reference more shared-string text than the limit, before any sheet is parsed. + * + * PhpSpreadsheet gives every cell that references a shared string its own + * copy of the text, and clones every run of a rich-text string first, so a + * long or many-run string referenced by many cells costs memory and time + * per cell however small the file is. Each entry weighs the bytes of its + * text plus 16 per element in it (so a run weighs at least 32), and the + * weights of the entries every `t="s"` cell references are added up, + * streamed with XMLReader. + * + * The check charges at least what PhpSpreadsheet can build, never less: + * every part of the package is read, whatever its name, as a possible + * table and a possible sheet; entries are numbered per namespace and an + * index weighs the heaviest entry any table has there; every `` of a + * shared-string cell is charged, read both as its own text and as all + * the text inside it; every cell counts, read or not; and the sum counts + * once per source sheet, because two source sheets may point at the same + * part. + * + * @param string $path The xlsx file. + * @param int $limit The maximum weight of the referenced shared strings. + * @param int $sheetCount The number of source sheets the profile reads. + * @param object $scanner PhpSpreadsheet's XmlScanner, which every part passes before it is parsed. + * + * @return void + * + * @throws CmdbImportException WORKBOOK_TOO_LARGE above the limit. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function assertReferencedStringBytes(string $path, int $limit, int $sheetCount, object $scanner): void { + $tables = []; + self::streamParts( + path: $path, + scanner: $scanner, + consume: static function (XMLReader $xml) use (&$tables): void { + $tables[] = self::sharedStringWeights(xml: $xml); + } + ); + $weights = self::heaviestByIndex(tables: $tables); + if ($weights === []) { + return; + } + + $budget = intdiv($limit, max(1, $sheetCount)); + $total = 0; + self::streamParts( + path: $path, + scanner: $scanner, + consume: static function (XMLReader $xml) use ($weights, $budget, $limit, &$total): void { + $total = self::referencedWeight(xml: $xml, weights: $weights, total: $total, budget: $budget); + if ($total > $budget) { + throw new CmdbImportException( + errorCode: CmdbImportException::WORKBOOK_TOO_LARGE, + message: 'The cells of the workbook reference more shared-string text than the profile allows', + details: ['maxReferencedStringBytes' => $limit] + ); + } + } + ); + }//end assertReferencedStringBytes() + + /** + * The weight of each `` child of a part's root element, by its position among the entries of its namespace. + * + * PhpSpreadsheet numbers the entries of one namespace; which one depends + * on the workbook, so every namespace is numbered and the heaviest entry + * at a position counts. + * + * @param XMLReader $xml The reader, opened on one part. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + private static function sharedStringWeights(XMLReader $xml): array { + $byNamespace = []; + $positions = []; + $namespace = null; + while ($xml->read() === true) { + if ($xml->depth === 1 && $xml->nodeType === XMLReader::ELEMENT) { + $namespace = null; + if ($xml->localName === 'si') { + $namespace = $xml->namespaceURI; + $positions[$namespace] = (($positions[$namespace] ?? -1) + 1); + $byNamespace[$namespace][$positions[$namespace]] = 0; + } + } + + if ($namespace !== null && $xml->depth >= 1) { + $byNamespace[$namespace][$positions[$namespace]] += self::nodeWeight(xml: $xml); + } + } + + return self::heaviestByIndex(tables: $byNamespace); + }//end sharedStringWeights() + + /** + * Merge weight tables, keeping the heaviest weight at each index. + * + * @param array> $tables The tables. + * + * @return array + */ + private static function heaviestByIndex(array $tables): array { + $merged = []; + foreach ($tables as $table) { + foreach ($table as $index => $weight) { + $merged[$index] = max(($merged[$index] ?? 0), $weight); + } + } + + return $merged; + }//end heaviestByIndex() + + /** + * What one node inside a shared string adds to its weight: 16 for an element, the bytes of a text. + * + * @param XMLReader $xml The reader, on the node. + * + * @return int + */ + private static function nodeWeight(XMLReader $xml): int { + if ($xml->nodeType === XMLReader::ELEMENT) { + return 16; + } + + if (self::isText(xml: $xml) === true) { + return strlen($xml->value); + } + + return 0; + }//end nodeWeight() + + /** + * Add the weights of the shared strings the `t="s"` cells of one part reference, stopping past the budget. + * + * Every `` after a shared-string cell opens, up to the next cell, is + * charged: PhpSpreadsheet reads the cell's first `` of its namespace, + * and charging all of them never charges less. + * + * @param XMLReader $xml The reader, opened on one part. + * @param array $weights The weight of each shared string, by index. + * @param int $total The weight counted so far. + * @param int $budget The weight above which the workbook is refused. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + private static function referencedWeight(XMLReader $xml, array $weights, int $total, int $budget): int { + $shared = false; + while ($total <= $budget && $xml->read() === true) { + if ($xml->nodeType !== XMLReader::ELEMENT) { + continue; + } + + if ($xml->localName === 'c') { + $shared = ($xml->getAttribute('t') === 's'); + } elseif ($shared === true && $xml->localName === 'v') { + $total += self::valueWeight(xml: $xml, weights: $weights); + } + } + + return $total; + }//end referencedWeight() + + /** + * The weight of the shared string a `` element points at. + * + * PhpSpreadsheet casts the element's own text to int; all the text inside + * it can differ when it holds child elements, so both are read and the + * heavier entry counts. + * + * @param XMLReader $xml The reader, on the `` element; it is left on its end. + * @param array $weights The weight of each shared string, by index. + * + * @return int + */ + private static function valueWeight(XMLReader $xml, array $weights): int { + $depth = $xml->depth; + $own = ''; + $all = ''; + if ($xml->isEmptyElement === false) { + while ($xml->read() === true && $xml->depth > $depth) { + if (self::isText(xml: $xml) === true) { + $all .= $xml->value; + if ($xml->depth === ($depth + 1)) { + $own .= $xml->value; + } + } + } + } + + return max(($weights[(int)$own] ?? 0), ($weights[(int)$all] ?? 0)); + }//end valueWeight() + + /** + * Whether the reader is on a text node: text, CDATA or whitespace. + * + * @param XMLReader $xml The reader. + * + * @return bool + */ + private static function isText(XMLReader $xml): bool { + return in_array($xml->nodeType, [XMLReader::TEXT, XMLReader::CDATA, XMLReader::WHITESPACE, XMLReader::SIGNIFICANT_WHITESPACE], true); + }//end isText() + + /** + * Stream every part of the package through a callback, with the part's name, as PhpSpreadsheet would parse it. + * + * @param string $path The xlsx file. + * @param object $scanner PhpSpreadsheet's XmlScanner. + * @param callable(XMLReader, string): void $consume Reads one opened part; gets the reader and the part's name. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function eachPart(string $path, object $scanner, callable $consume): void { + self::streamParts(path: $path, scanner: $scanner, consume: $consume); + }//end eachPart() + + /** + * Stream every part of the package through a callback, as PhpSpreadsheet would parse it. + * + * Parts are read by index, so a part counts whatever its name, and each + * passes PhpSpreadsheet's XmlScanner first, which also converts its + * encoding. A part the scanner refuses, or that is not XML, is skipped: + * PhpSpreadsheet cannot read it either. Network access and entity + * substitution stay off; libxml errors are kept from the log and + * cleared, also when the callback throws. + * + * @param string $path The xlsx file. + * @param object $scanner PhpSpreadsheet's XmlScanner. + * @param callable(XMLReader, string): void $consume Reads one opened part; gets the reader and the part's name. + * + * @return void + */ + private static function streamParts(string $path, object $scanner, callable $consume): void { + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::RDONLY) !== true) { + return; + } + + try { + for ($index = 0; $index < $zip->numFiles; $index++) { + $previous = libxml_use_internal_errors(true); + try { + $xml = self::openPart(zip: $zip, index: $index, scanner: $scanner); + if ($xml !== null) { + $consume($xml, (string)$zip->getNameIndex($index)); + $xml->close(); + } + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + } finally { + $zip->close(); + } + }//end streamParts() + + /** + * Open one part of the package with XMLReader, after PhpSpreadsheet's XmlScanner. + * + * @param ZipArchive $zip The open package. + * @param int $index The part's index. + * @param object $scanner PhpSpreadsheet's XmlScanner. + * + * @return XMLReader|null Null for an empty part, or one the scanner refuses. + */ + private static function openPart(ZipArchive $zip, int $index, object $scanner): ?XMLReader { + $content = $zip->getFromIndex($index); + if (is_string($content) === false || $content === '') { + return null; + } + + try { + $content = (string)$scanner->scan($content); + } catch (Throwable) { + return null; + } + + $xml = new XMLReader(); + if ($xml->XML($content, null, LIBXML_NONET) === false) { + return null; + } + + return $xml; + }//end openPart() +}//end class diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php new file mode 100644 index 000000000..3ff3e9184 --- /dev/null +++ b/lib/Service/Cmdb/CmdbWorkbookReader.php @@ -0,0 +1,821 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use OCA\Stackiq\Exception\CmdbImportException; +use Throwable; +use ZipArchive; + +/** + * Reads the allowlisted columns of the profile's source sheets. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The file checks before parsing and the + * header resolution are each a chain of small guards; together they pass the threshold. + */ +class CmdbWorkbookReader { + /** + * PhpSpreadsheet's Xlsx reader, shipped in OpenRegister's vendor directory. + */ + public const READER_CLASS = 'PhpOffice\PhpSpreadsheet\Reader\Xlsx'; + + /** + * What an oversized unread sheet becomes in the copy the reader reads. + */ + private const EMPTY_WORKSHEET = '' + . ''; + + /** + * The ZIP local-file-header signature every xlsx package starts with. + */ + private const ZIP_SIGNATURE = "PK\x03\x04"; + + /** + * Check that an upload is an xlsx workbook, without parsing it. + * + * @param string $path The uploaded temporary file. + * @param string $fileName The original file name. + * + * @return void + * + * @throws CmdbImportException NOT_XLSX when the name, signature or package is wrong. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function assertXlsx(string $path, string $fileName): void { + if (strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION)) !== 'xlsx') { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The file name does not end in .xlsx'); + } + + $head = false; + if (is_file($path) === true && is_readable($path) === true) { + $head = file_get_contents($path, false, null, 0, 4); + } + + if ($head !== self::ZIP_SIGNATURE) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The file is not a ZIP package'); + } + + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::RDONLY) !== true) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The ZIP package cannot be opened'); + } + + $hasWorkbook = ($zip->locateName('xl/workbook.xml') !== false); + $zip->close(); + + if ($hasWorkbook === false) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The package holds no xl/workbook.xml'); + } + }//end assertXlsx() + + /** + * Whether PhpSpreadsheet's Xlsx reader can be loaded. + * + * @return bool + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function isAvailable(): bool { + return class_exists(static::READER_CLASS) === true; + }//end isAvailable() + + /** + * Read the source sheets of an xlsx workbook. + * + * What the workbook can make PhpSpreadsheet hold is bounded before any + * part is parsed: the unpacked size of the package (the profile's + * `maxUncompressedBytes`) and of each part (`maxPartBytes`), the number of + * shared strings (`maxSharedStrings`), the shared-string text the cells + * reference (`maxReferencedStringBytes`), then the last used row of every + * source sheet. + * The sheets are then read twice through a read filter: once for the + * header row, once for the resolved columns of the data rows, so no other + * cell is ever materialised. + * + * @param string $path The xlsx file, already checked by assertXlsx(). + * @param CmdbImportProfile $profile The import profile. + * + * @return array `rows` (list of {sheet, row, cells, uncached}), `importWarnings` + * (list of {sheet, message}) and `date1904` (bool). + * + * @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET, + * MISSING_COLUMN or TOO_MANY_ROWS. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function read(string $path, CmdbImportProfile $profile): array { + $oversized = $this->assertUncompressedSize(path: $path, limit: $profile->maxUncompressedBytes(), partLimit: $profile->maxPartBytes()); + + if ($this->isAvailable() === false) { + throw new CmdbImportException( + errorCode: CmdbImportException::READER_UNAVAILABLE, + message: 'PhpSpreadsheet Xlsx reader is not available' + ); + } + + $scanner = $this->newReader(sheetNames: null)->getSecurityScannerOrThrow(); + $blank = (new CmdbPartReferences())->assertPartSizes( + path: $path, + oversized: $oversized, + sourceSheets: $profile->sheetNames(), + limit: $profile->maxPartBytes(), + scanner: $scanner + ); + if ($blank === []) { + return $this->readBounded(path: $path, profile: $profile, scanner: $scanner); + } + + $copy = self::blankedCopy(path: $path, parts: $blank); + try { + return $this->readBounded(path: $copy, profile: $profile, scanner: $scanner); + } finally { + self::removeCopy(path: $copy); + } + }//end read() + + /** + * A copy of the package in which each named part is an empty worksheet; the caller removes it. + * + * Every entry under one of the names is replaced, so PhpSpreadsheet can + * reach at most an empty sheet through it, whichever way it resolves a + * target to that name. + * + * @param string $path The xlsx file. + * @param array $parts The names of the parts to blank. + * + * @return string The copy's path, ending in .xlsx. + * + * @throws CmdbImportException NOT_XLSX when the copy cannot be written. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + private static function blankedCopy(string $path, array $parts): string { + $copy = self::temporaryCopy(path: $path); + $zip = new ZipArchive(); + if ($zip->open($copy) !== true) { + unlink($copy); + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The ZIP package cannot be opened'); + } + + for ($index = ($zip->numFiles - 1); $index >= 0; $index--) { + if (in_array((string)$zip->getNameIndex($index), $parts, true) === true) { + $zip->deleteIndex($index); + } + } + + foreach (array_unique($parts) as $part) { + $zip->addFromString($part, self::EMPTY_WORKSHEET); + } + + if ($zip->close() === false) { + unlink($copy); + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading'); + } + + return $copy; + }//end blankedCopy() + + /** + * A copy of the file in the temporary directory, removed at the end of the request at the latest. + * + * The copy holds the upload, so it is also removed when the request dies + * (memory or time limit) before read() removes it. + * + * @param string $path The xlsx file. + * + * @return string The copy's path, ending in .xlsx. + * + * @throws CmdbImportException NOT_XLSX when the copy cannot be written. + */ + private static function temporaryCopy(string $path): string { + $base = tempnam(sys_get_temp_dir(), 'cmdb-read-'); + if ($base === false) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading'); + } + + $copy = $base . '.xlsx'; + if (rename($base, $copy) === false) { + unlink($base); + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading'); + } + + register_shutdown_function(static function () use ($copy): void { + self::removeCopy(path: $copy); + }); + if (copy($path, $copy) === false) { + unlink($copy); + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The workbook cannot be copied for reading'); + } + + return $copy; + }//end temporaryCopy() + + /** + * Remove a copy made by temporaryCopy(), if it is still there. + * + * Called by read() once the copy is read, and by the shutdown function + * when the request dies first; after read(), the latter finds nothing. + * + * @param string $path The copy's path. + * + * @return void + */ + private static function removeCopy(string $path): void { + if (is_file($path) === true) { + unlink($path); + } + }//end removeCopy() + + /** + * Read the source sheets of a package whose part sizes have been checked. + * + * @param string $path The xlsx file, or its copy with the oversized unread sheets blanked. + * @param CmdbImportProfile $profile The import profile. + * @param object $scanner PhpSpreadsheet's XmlScanner. + * + * @return array See read(). + * + * @throws CmdbImportException WORKBOOK_TOO_LARGE, NOT_XLSX, NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS. + */ + private function readBounded(string $path, CmdbImportProfile $profile, object $scanner): array { + $bounds = new CmdbWorkbookBounds(); + $bounds->assertSharedStringCount(path: $path, limit: $profile->maxSharedStrings(), scanner: $scanner); + $bounds->assertReferencedStringBytes( + path: $path, + limit: $profile->maxReferencedStringBytes(), + sheetCount: count($profile->sheetNames()), + scanner: $scanner + ); + + $reader = $this->newReader(sheetNames: null); + try { + $available = $reader->listWorksheetNames($path); + } catch (Throwable $e) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_XLSX, + message: 'The workbook cannot be read: ' . get_class($e), + previous: $e + ); + } + + $expected = $profile->sheetNames(); + $present = array_values(array_intersect($expected, $available)); + if ($present === []) { + throw new CmdbImportException( + errorCode: CmdbImportException::NO_SOURCE_SHEET, + message: 'The workbook holds none of the source sheets', + details: ['expected' => $expected] + ); + } + + $limit = $profile->maxRowsPerSheet(); + $lastRow = self::lastReadableRow(limit: $limit); + $this->assertRowSpan(path: $path, sheetNames: $present, lastRow: $lastRow, limit: $limit); + + $headers = $this->load(path: $path, sheetNames: $present, filter: new CmdbReadFilter(lastRow: 1)); + try { + $resolved = $this->resolveSheets(spreadsheet: $headers, sheetNames: $present, profile: $profile); + } finally { + $headers->disconnectWorksheets(); + } + + $letters = array_map(static fn (array $columns): array => array_keys($columns), $resolved['columns']); + $spreadsheet = $this->load(path: $path, sheetNames: $present, filter: new CmdbReadFilter(lastRow: $lastRow, columns: $letters)); + try { + $rows = []; + foreach ($present as $sheetName) { + $sheetRows = $this->readRows( + worksheet: $spreadsheet->getSheetByName($sheetName), + columns: $resolved['columns'][$sheetName], + sheetName: $sheetName, + limit: $limit + ); + array_push($rows, ...$sheetRows); + } + + $date1904 = false; + if (method_exists($spreadsheet, 'getExcelCalendar') === true) { + $date1904 = ((int)$spreadsheet->getExcelCalendar() === 1904); + } + } finally { + $spreadsheet->disconnectWorksheets(); + } + + return ['rows' => $rows, 'importWarnings' => $resolved['warnings'], 'date1904' => $date1904]; + }//end readBounded() + + /** + * The last row number the data pass reads. + * + * Twice the row limit plus the header row: a sheet may carry empty rows + * between its data rows (formatted rows, formulas whose cached value is 0), + * but not more of them than it has room for data rows. + * + * @param int $limit The profile's maximum number of non-empty rows. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public static function lastReadableRow(int $limit): int { + return ((2 * max(0, $limit)) + 1); + }//end lastReadableRow() + + /** + * Refuse a package whose parts together unpack to more than the limit, and list the parts beyond the part limit. + * + * The sizes are the uncompressed sizes the ZIP directory declares; libzip + * never inflates a part beyond its declared size. A part beyond the part + * limit is refused later, by CmdbPartReferences, unless only sheets the + * import does not read refer to it. + * + * @param string $path The xlsx file. + * @param int $limit The maximum number of unpacked bytes of all parts together. + * @param int $partLimit The maximum number of unpacked bytes of one part the import parses. + * + * @return array Part name => unpacked size, for every part beyond the part limit. + * + * @throws CmdbImportException NOT_XLSX when the package cannot be opened, WORKBOOK_TOO_LARGE above the limit. + */ + private function assertUncompressedSize(string $path, int $limit, int $partLimit): array { + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::RDONLY) !== true) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The ZIP package cannot be opened'); + } + + $total = 0; + $readable = true; + $oversized = []; + for ($index = 0; $index < $zip->numFiles && $total <= $limit; $index++) { + $stat = $zip->statIndex($index); + if ($stat === false) { + $readable = false; + break; + } + + if ((int)$stat['size'] > $partLimit) { + $oversized[(string)$stat['name']] = (int)$stat['size']; + } + + $total += (int)$stat['size']; + } + + $zip->close(); + + if ($readable === false) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'A part of the ZIP package cannot be read'); + } + + if ($total > $limit) { + throw new CmdbImportException( + errorCode: CmdbImportException::WORKBOOK_TOO_LARGE, + message: 'The workbook unpacks to more bytes than the profile allows', + details: ['maxUncompressedBytes' => $limit] + ); + } + + return $oversized; + }//end assertUncompressedSize() + + /** + * Refuse a source sheet whose last used row lies beyond the rows that are read. + * + * PhpSpreadsheet's worksheet info streams the sheet and counts no cell + * objects, so this runs before a sheet is loaded. + * + * @param string $path The xlsx file. + * @param array $sheetNames The present source sheets. + * @param int $lastRow The last row number that is read. + * @param int $limit The profile's maximum number of non-empty rows, for the details. + * + * @return void + * + * @throws CmdbImportException NOT_XLSX when the sheets cannot be listed, TOO_MANY_ROWS beyond the last row. + */ + private function assertRowSpan(string $path, array $sheetNames, int $lastRow, int $limit): void { + try { + $info = $this->newReader(sheetNames: null)->listWorksheetInfo($path); + } catch (Throwable $e) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_XLSX, + message: 'The workbook cannot be read: ' . get_class($e), + previous: $e + ); + } + + foreach ($info as $sheet) { + $name = (string)($sheet['worksheetName'] ?? ''); + if (in_array($name, $sheetNames, true) === true && (int)($sheet['totalRows'] ?? 0) > $lastRow) { + throw new CmdbImportException( + errorCode: CmdbImportException::TOO_MANY_ROWS, + message: 'A source sheet has more rows than the profile allows', + details: ['sheet' => $name, 'limit' => $limit] + ); + } + } + }//end assertRowSpan() + + /** + * A PhpSpreadsheet Xlsx reader in read-data-only mode. + * + * @param array|null $sheetNames The sheets to load, or null for none set. + * + * @return object + */ + private function newReader(?array $sheetNames): object { + $readerClass = static::READER_CLASS; + $reader = new $readerClass(); + $reader->setReadDataOnly(true); + $reader->setReadEmptyCells(false); + if ($sheetNames !== null) { + $reader->setLoadSheetsOnly($sheetNames); + } + + return $reader; + }//end newReader() + + /** + * Load the source sheets through a read filter. + * + * @param string $path The xlsx file. + * @param array $sheetNames The present source sheets. + * @param CmdbReadFilter $filter Which cells are read. + * + * @return object The PhpSpreadsheet workbook. + * + * @throws CmdbImportException NOT_XLSX when the workbook cannot be loaded. + */ + private function load(string $path, array $sheetNames, CmdbReadFilter $filter): object { + $reader = $this->newReader(sheetNames: $sheetNames); + $reader->setReadFilter($filter); + + try { + return $reader->load($path); + } catch (Throwable $e) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_XLSX, + message: 'The workbook cannot be loaded: ' . get_class($e), + previous: $e + ); + } + }//end load() + + /** + * Normalise a header or column name for matching. + * + * @param string $header The raw header. + * + * @return string The normalised name. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public static function normaliseHeader(string $header): string { + $header = (string)preg_replace('/\s+/u', ' ', trim($header)); + $header = (string)preg_replace('/\s*(?::|⚡)+$/u', '', $header); + + return mb_strtolower(trim($header)); + }//end normaliseHeader() + + /** + * Resolve the columns of every present source sheet from its header row. + * + * Every sheet is resolved before a single data row is read, so a missing + * required column stops the import first. + * + * @param object $spreadsheet The workbook, loaded with the header row only. + * @param array $sheetNames The present source sheets, in profile order. + * @param CmdbImportProfile $profile The import profile. + * + * @return array{columns: array>, warnings: array} + * Per sheet, column letter => referenced column name; and the import warnings. + * + * @throws CmdbImportException MISSING_COLUMN. + */ + private function resolveSheets(object $spreadsheet, array $sheetNames, CmdbImportProfile $profile): array { + $referenced = $profile->referencedColumns(); + $mapped = $this->packSources(profile: $profile); + $required = $profile->requiredColumns(); + + $columnsPerSheet = []; + $warnings = []; + foreach ($sheetNames as $sheetName) { + $worksheet = $spreadsheet->getSheetByName($sheetName); + $columns = $this->resolveColumns(worksheet: $worksheet, referenced: $referenced); + + foreach ($required as $column) { + if (in_array($column, $columns, true) === false) { + throw new CmdbImportException( + errorCode: CmdbImportException::MISSING_COLUMN, + message: 'A source sheet lacks a required column', + details: ['sheet' => $sheetName, 'column' => $column] + ); + } + } + + $skip = array_merge($required, $profile->absentColumns(sheetName: $sheetName)); + array_push($warnings, ...self::missingOptionalColumns(sheetName: $sheetName, mapped: $mapped, columns: $columns, skip: $skip)); + $columnsPerSheet[$sheetName] = $columns; + } + + return ['columns' => $columnsPerSheet, 'warnings' => $warnings]; + }//end resolveSheets() + + /** + * Map the header row to the referenced column names. + * + * @param object $worksheet The worksheet. + * @param array $referenced The allowlisted column names. + * + * @return array Column letter => referenced column name. + */ + private function resolveColumns(object $worksheet, array $referenced): array { + $wanted = []; + foreach ($referenced as $column) { + $wanted[self::normaliseHeader(header: $column)] = $column; + } + + $columns = []; + $lastColumn = self::columnIndex(letters: (string)$worksheet->getHighestDataColumn(1)); + for ($index = 1; $index <= $lastColumn; $index++) { + $letters = self::columnLetters(index: $index); + $coordinate = $letters . '1'; + if ($worksheet->cellExists($coordinate) === false) { + continue; + } + + $header = $this->cellValue(cell: $worksheet->getCell($coordinate)); + if (is_scalar($header) === false) { + continue; + } + + $name = $wanted[self::normaliseHeader(header: (string)$header)] ?? null; + // The first column with a matching header wins. + if ($name !== null && in_array($name, $columns, true) === false) { + $columns[$letters] = $name; + } + } + + return $columns; + }//end resolveColumns() + + /** + * Read the non-empty data rows of one sheet. + * + * @param object $worksheet The worksheet. + * @param array $columns Column letter => column name. + * @param string $sheetName The sheet name. + * @param int $limit The maximum number of non-empty rows. + * + * @return array, uncached: array}> + * + * @throws CmdbImportException TOO_MANY_ROWS. + */ + private function readRows(object $worksheet, array $columns, string $sheetName, int $limit): array { + $rows = []; + $lastRow = (int)$worksheet->getHighestDataRow(); + for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) { + ['cells' => $cells, 'uncached' => $uncached, 'empty' => $empty] = $this->readRow( + worksheet: $worksheet, + columns: $columns, + rowNumber: $rowNumber + ); + if ($empty === true) { + continue; + } + + if (count($rows) >= $limit) { + throw new CmdbImportException( + errorCode: CmdbImportException::TOO_MANY_ROWS, + message: 'A source sheet has more rows than the profile allows', + details: ['sheet' => $sheetName, 'limit' => $limit] + ); + } + + $rows[] = ['sheet' => $sheetName, 'row' => $rowNumber, 'cells' => $cells, 'uncached' => $uncached]; + }//end for + + return $rows; + }//end readRows() + + /** + * The kept cells of one row, the columns whose formula has no cached value, + * and whether every kept cell is empty. + * + * @param object $worksheet The worksheet. + * @param array $columns Column letter => column name. + * @param int $rowNumber The 1-based row number. + * + * @return array{cells: array, uncached: array, empty: bool} + */ + private function readRow(object $worksheet, array $columns, int $rowNumber): array { + $cells = []; + $uncached = []; + $empty = true; + foreach ($columns as $letters => $name) { + $value = null; + $coordinate = $letters . $rowNumber; + if ($worksheet->cellExists($coordinate) === true) { + $cell = $worksheet->getCell($coordinate); + $value = $this->cellValue(cell: $cell); + if (self::isUncachedFormula(cell: $cell) === true) { + $uncached[] = $name; + } + } + + $cells[$name] = $value; + if ($value !== null && (is_string($value) === false || trim($value) !== '')) { + $empty = false; + } + } + + return ['cells' => $cells, 'uncached' => $uncached, 'empty' => $empty]; + }//end readRow() + + /** + * One import warning per pack column a sheet lacks, except the ones to skip. + * + * @param string $sheetName The sheet name. + * @param array $mapped The sheet-mapped pack sources. + * @param array $columns The sheet's resolved columns. + * @param array $skip Required columns and the columns the sheet is known to lack. + * + * @return array + */ + private static function missingOptionalColumns(string $sheetName, array $mapped, array $columns, array $skip): array { + $warnings = []; + foreach ($mapped as $column) { + if (in_array($column, $columns, true) === false && in_array($column, $skip, true) === false) { + $warnings[] = ['sheet' => $sheetName, 'column' => $column, 'message' => sprintf('Optional column "%s" not found', $column)]; + } + } + + return $warnings; + }//end missingOptionalColumns() + + /** + * Whether a cell holds a formula without a cached value. + * + * @param object $cell The PhpSpreadsheet cell. + * + * @return bool + */ + private static function isUncachedFormula(object $cell): bool { + return $cell->getDataType() === 'f' && $cell->getOldCalculatedValue() === null; + }//end isUncachedFormula() + + /** + * The stored value of a cell; for a formula, the value Excel cached. + * + * A formula without a cached value, and a formula whose cached value is + * the number 0 (Excel's result for a reference to an empty cell), yield + * null. + * + * @param object $cell The PhpSpreadsheet cell. + * + * @return mixed A scalar or null. + */ + private function cellValue(object $cell): mixed { + $value = $cell->getValue(); + if ($cell->getDataType() === 'f') { + // The value Excel cached; the formula itself is never evaluated. + $value = $cell->getOldCalculatedValue(); + if ((is_int($value) === true || is_float($value) === true) && (float)$value === 0.0) { + return null; + } + } + + if (is_object($value) === true && method_exists($value, 'getPlainText') === true) { + return (string)$value->getPlainText(); + } + + if (is_scalar($value) === false) { + return null; + } + + return $value; + }//end cellValue() + + /** + * The sources of every sheet-mapped pack field. + * + * @param CmdbImportProfile $profile The import profile. + * + * @return array + */ + private function packSources(CmdbImportProfile $profile): array { + $constants = $profile->constantColumns(); + $sources = []; + foreach (CmdbImportProfile::TARGETS as $target) { + if ($target === 'municipality') { + continue; + } + + foreach (($profile->pack(target: $target)['fieldMappings'] ?? []) as $mapping) { + $sources[] = (string)($mapping['source'] ?? ''); + } + } + + return array_values( + array_unique( + array_filter( + $sources, + fn (string $source): bool => $source !== '' && in_array($source, $constants, true) === false + ) + ) + ); + }//end packSources() + + /** + * Column letters to a 1-based index ("A" = 1, "AA" = 27). + * + * @param string $letters The column letters. + * + * @return int + */ + private static function columnIndex(string $letters): int { + $index = 0; + foreach (str_split(strtoupper($letters)) as $char) { + $index = (($index * 26) + (ord($char) - 64)); + } + + return $index; + }//end columnIndex() + + /** + * A 1-based column index to its letters. + * + * @param int $index The column index. + * + * @return string + */ + private static function columnLetters(int $index): string { + $letters = ''; + while ($index > 0) { + $remainder = (($index - 1) % 26); + $letters = chr(65 + $remainder) . $letters; + $index = intdiv(($index - 1), 26); + } + + return $letters; + }//end columnLetters() +}//end class diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php new file mode 100644 index 000000000..1efc049d7 --- /dev/null +++ b/lib/Service/CmdbExportImportService.php @@ -0,0 +1,2051 @@ +:` (the + * APPID in lower case), so a + * second import of a newer export updates the same records. + * + * What each column becomes is declarative: the migration packs under + * `lib/Settings/cmdb-import/`, executed by OpenRegister's + * `MigrationPack\MappingEngine` (ADR-031). This class is the imperative glue + * around the file: reading it, splitting a row over four linked objects, + * resolving contacts, progress and cancel. Every read and write goes through + * OpenRegister's `ObjectServiceInterface` (ADR-022). + * + * Rules stated once and enforced here: + * - A module matches on `externalKey`, but only when the municipality + * uses it or no organisation does yet; a usage on (consumer, module); a + * manufacturer on its normalised name, a Municipality of that name before + * a Supplier, so a municipality that builds its own applications stays one + * organisation; a contact person on (contactsUid, organization). An organisation that was merged away + * (status `merged`) or is `Inactive` is never matched by name. + * - `publicationDate` is set to the import's start on create, unless the + * admin chose not to publish (`publish` false), and never written on + * update; neither is `depublicationDate`. + * - Records missing from a newer export are left untouched. + * - Owners become contact persons, never Nextcloud user accounts, and no + * report entry or log line carries an owner name or e-mail address. + * - One import runs per register at a time: every match is find-then-create, + * so two interleaved runs would each create the same records. + * + * @category Service + * @package OCA\Stackiq\Service + * @author Conduction b.v. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use DateTimeImmutable; +use DateTimeZone; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; +use OCA\Stackiq\Service\Cmdb\CmdbImportReport; +use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser; +use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IL10N; +use OCP\IUserSession; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Imports a TOPdesk CMDB export for one municipality. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One import run resolves four linked + * object kinds per row (supplier, module, owners, usage), each with its own match rule, + * create-only fields and run cache. Splitting them over several classes would hand the + * same run state from class to class without making any one rule simpler to read. + * @SuppressWarnings(PHPMD.TooManyFields) The run caches are one field per matched kind. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Reader, normaliser, profile, report, + * contacts, progress and OpenRegister are the import's collaborators by design. + * @SuppressWarnings(PHPMD.TooManyMethods) Each match rule and each step of a row is its own + * small method; merging them back would only make the steps longer. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Most of the length is docblocks that state + * the matching rules; the code itself is under the threshold. + */ +class CmdbExportImportService { + /** + * The ProgressTracker operation type of an import. + */ + public const OPERATION_TYPE = 'cmdb_import'; + + /** + * Operation ids a client may choose; anything else gets a generated id. + * + * `\z`, not `$`: `$` also matches before a trailing newline, which would + * let "cmdb-12345678\n" through as a cache key. + */ + public const OPERATION_ID_PATTERN = '/^cmdb-[A-Za-z0-9-]{8,64}\z/'; + + /** + * OpenRegister's migration-pack mapping engine (not a public contract). + */ + public const ENGINE_CLASS = 'OCA\OpenRegister\Service\MigrationPack\MappingEngine'; + + /** + * OpenRegister's schema mapper (not a public contract). + */ + public const SCHEMA_MAPPER_CLASS = 'OCA\OpenRegister\Db\SchemaMapper'; + + /** + * The properties every match search and the module key rely on, per schema. + * + * OpenRegister answers a filter on a property its schema does not declare + * with no rows, not an error; without these the import would create a + * duplicate of every record instead of matching it. `applicationType` is + * not matched on, but it arrives with the same module version (0.3.8) as + * the write rule below, so its absence marks an outdated module schema. + * + * @var array> + */ + private const MATCH_PROPERTIES = [ + 'module' => ['externalKey', 'externalId', 'externalNumber', 'applicationType'], + 'organization' => ['name', 'type'], + 'usage' => ['consumer', 'module'], + 'contactPerson' => ['contactsUid', 'organization'], + ]; + + /** + * Wall-clock seconds an import may run, below the default 3600 s lifetime of a Nextcloud lock. + */ + public const TIME_LIMIT_SECONDS = 3000; + + /** + * The update rule a property must carry, per schema: property => the group that alone may change it. + * + * Only an admin may change `module.externalKey` outside the import; the + * conflict and ownership model relies on that, so a module schema without + * the rule (before 0.3.8) is outdated. + * + * @var array> + */ + private const REQUIRED_UPDATE_RULES = [ + 'module' => ['externalKey' => 'admin'], + ]; + + /** + * The most report rows stored with the operation in the distributed cache; the counts are always kept. + */ + public const STORED_REPORT_ROWS = 500; + + /** + * What the progress entry of a failed run says: a code and a generic message. + * + * The entry is readable by everyone who may follow the operation, and an + * exception message can quote cell values or person data, so it is never stored. + */ + public const FAILED_RUN_MESSAGE = 'IMPORT_FAILED: The import stopped unexpectedly. The details are in the Nextcloud log.'; + + /** + * The URI of the importing admin's address book that new owner contacts go into. + */ + public const OWNER_ADDRESS_BOOK_URI = 'stackiq-cmdb-owners'; + + /** + * The lock an import holds for its register, so imports never interleave. + */ + private const LOCK_PREFIX = 'stackiq/cmdb-import/register-'; + + /** + * Organisation statuses a name never matches: a merge tombstone, and a retired organisation. + * + * @var array + */ + private const UNMATCHED_STATUSES = ['merged', 'Inactive']; + + /** + * Page size for loading the organisations a name may match. + */ + private const PAGE_SIZE = 500; + + /** + * Separator of the concat mapping for the internal note. + */ + private const NOTE_SEPARATOR = ' / '; + + /** + * The mapping engine of the current run. + * + * @var object|null + */ + private ?object $engine = null; + + /** + * OpenRegister and the register/schema ids of the current run. + * + * @var array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int}|null + */ + private ?array $coordinates = null; + + /** + * Manufacturer organisations by normalised name, loaded once per run. + * + * @var array|null + */ + private ?array $suppliers = null; + + /** + * APPIDs seen in this upload. + * + * @var array + */ + private array $seenKeys = []; + + /** + * Per APPID match key, the sheet that wins when the APPID is on more than one sheet. + * + * @var array + */ + private array $winningSheets = []; + + /** + * Contact UIDs by e-mail or display name, per run. + * + * @var array + */ + private array $contactUids = []; + + /** + * Contact person uuids by contactsUid and organisation, per run. + * + * @var array + */ + private array $contactPersons = []; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves OpenRegister services. + * @param SettingsService $settingsService Register and schema ids. + * @param StackiqContactSyncService $contactSync Nextcloud Contacts bridge. + * @param ProgressTracker $progressTracker Progress and cancel. + * @param CmdbImportProfile $profile The import profile and packs. + * @param CmdbWorkbookReader $reader The xlsx reader. + * @param CmdbRowNormaliser $normaliser Dates and ids to strings. + * @param IL10N $l10n Translates report reasons and warnings. + * @param LoggerInterface $logger Logger; never handed person data. + * @param ILockingProvider $lockingProvider Serialises imports per register. + * @param IUserSession $userSession Names the admin who ran an import in its audit log lines. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Each collaborator is one concern of the + * import (the file, the packs, OpenRegister, Contacts, progress, the lock); grouping them + * into a parameter object would only move the same list one class further. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly SettingsService $settingsService, + private readonly StackiqContactSyncService $contactSync, + private readonly ProgressTracker $progressTracker, + private readonly CmdbImportProfile $profile, + private readonly CmdbWorkbookReader $reader, + private readonly CmdbRowNormaliser $normaliser, + private readonly IL10N $l10n, + private readonly LoggerInterface $logger, + private readonly ILockingProvider $lockingProvider, + private readonly IUserSession $userSession, + ) { + }//end __construct() + + /** + * The upload limit in bytes (the profile's `maxFileBytes`). + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function maxFileBytes(): int { + return $this->profile->maxFileBytes(); + }//end maxFileBytes() + + /** + * Check that an upload is an xlsx workbook, without parsing it. + * + * @param string $path The uploaded temporary file. + * @param string $fileName The original file name. + * + * @return void + * + * @throws CmdbImportException NOT_XLSX. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function assertXlsx(string $path, string $fileName): void { + $this->reader->assertXlsx(path: $path, fileName: $fileName); + }//end assertXlsx() + + /** + * Whether a `missingRecords` value is supported (only `keep`). + * + * @param string $mode The requested value. + * + * @return bool + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function supportsMissingRecords(string $mode): bool { + return $mode === 'keep'; + }//end supportsMissingRecords() + + /** + * Ask a running import to stop between rows. + * + * @param string $operationId The operation id. + * + * @return bool False when no running `cmdb_import` operation has this id. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function requestCancel(string $operationId): bool { + if (preg_match(self::OPERATION_ID_PATTERN, $operationId) !== 1) { + return false; + } + + $progress = $this->progressTracker->getProgress(operationId: $operationId); + if (is_array($progress) === false + || ($progress['operation_type'] ?? null) !== self::OPERATION_TYPE + || ($progress['status'] ?? null) !== 'running' + ) { + return false; + } + + $this->progressTracker->setCancelRequested(operationId: $operationId); + return true; + }//end requestCancel() + + /** + * Import an export for one municipality. + * + * Validation that can fail the whole import runs before any object is + * written: the packs and the engine, the configuration, the workbook and + * the municipality uuid. After that every row is processed in its own + * error boundary. The import holds an exclusive lock on its register from + * before the file is read until it returns or throws. + * + * @param string $path The xlsx file, already checked by assertXlsx(). + * @param array $options municipalityUuid, municipalityName, updateExisting, publish, + * operationId, and fileName (the upload's name, for the audit log line). + * + * @return array The report (contract.md). + * + * @throws CmdbImportException MAPPING_UNAVAILABLE, NOT_CONFIGURED, SCHEMA_OUTDATED, IMPORT_IN_PROGRESS, + * WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET, + * MISSING_COLUMN, TOO_MANY_ROWS, MUNICIPALITY_REQUIRED, + * MUNICIPALITY_INVALID or MUNICIPALITY_AMBIGUOUS. + * @throws \Exception An unexpected OpenRegister error outside a row, such as + * creating the municipality; rows catch their own. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function import(string $path, array $options): array { + $this->resetRun(); + $this->keepRunning(); + $startedAt = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM); + + $this->profile->load(); + $this->engine = $this->resolveEngine(); + $this->coordinates = $this->resolveCoordinates(); + + $lock = $this->acquireImportLock(register: $this->coordinates['register']); + try { + return $this->runImport(path: $path, options: $options, startedAt: $startedAt); + } finally { + $this->lockingProvider->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE); + } + }//end import() + + /** + * Let the import finish when the browser goes away, within a bounded time. + * + * A closed tab or a proxy that gives up would otherwise stop PHP after + * some rows, with no report and the operation left running. The time + * limit is only ever raised to TIME_LIMIT_SECONDS, never lowered, and an + * unlimited one (the CLI) stays unlimited. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function keepRunning(): void { + ignore_user_abort(true); + + $current = (int)ini_get('max_execution_time'); + if ($current > 0 && $current < self::TIME_LIMIT_SECONDS && function_exists('set_time_limit') === true) { + set_time_limit(self::TIME_LIMIT_SECONDS); + } + }//end keepRunning() + + /** + * Take the register's import lock, or refuse because another import holds it. + * + * @param int $register The register id. + * + * @return string The lock path, to release. + * + * @throws CmdbImportException IMPORT_IN_PROGRESS. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function acquireImportLock(int $register): string { + $lock = self::LOCK_PREFIX . $register; + try { + $this->lockingProvider->acquireLock($lock, ILockingProvider::LOCK_EXCLUSIVE, 'CMDB import'); + } catch (LockedException $e) { + throw new CmdbImportException( + errorCode: CmdbImportException::IMPORT_IN_PROGRESS, + message: 'Another CMDB import is running for this register', + previous: $e + ); + } + + return $lock; + }//end acquireImportLock() + + /** + * Read the workbook and import its rows, under the register's lock. + * + * An import is audited by two info log lines, "import started" and + * "import finished", naming the operation, the admin's user id, the + * file's base name, the municipality and updateExisting, and at the end + * the counts. No owner name or e-mail address is logged. + * + * @param string $path The xlsx file. + * @param array $options The import options. + * @param string $startedAt ISO start time of the import. + * + * @return array The report. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function runImport(string $path, array $options, string $startedAt): array { + $workbook = $this->reader->read(path: $path, profile: $this->profile); + $municipality = $this->resolveMunicipality(options: $options); + + $operationId = $this->operationIdFrom(options: $options); + $rows = array_values($workbook['rows']); + $report = new CmdbImportReport(operationId: $operationId, rowsRead: count($rows)); + $report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']); + $report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings'])); + if ($municipality['created'] === true) { + $report->addImportWarnings( + warnings: [ + [ + 'sheet' => '', + 'message' => $this->l10n->t( + 'No municipality named "%s" was found, so it was created. Check the name if you meant an existing one.', + [$municipality['name']] + ), + ], + ] + ); + } + + $this->progressTracker->startOperation( + operationType: self::OPERATION_TYPE, + options: ['total_items' => count($rows)], + operationId: $operationId + ); + $this->progressTracker->setPhase(phase: 'processing_elements', data: ['total_items' => count($rows)]); + + $this->winningSheets = $this->winningSheets(rows: $rows); + $updateExisting = (($options['updateExisting'] ?? true) !== false); + $publish = (($options['publish'] ?? true) !== false); + $publicationDate = null; + if ($publish === true) { + $publicationDate = $startedAt; + } + $audit = [ + 'operationId' => $operationId, + 'uid' => $this->userSession->getUser()?->getUID(), + 'fileName' => basename(str_replace('\\', '/', (string)($options['fileName'] ?? ''))), + 'municipality' => $municipality['uuid'], + 'municipalityCreated' => $municipality['created'], + 'updateExisting' => $updateExisting, + 'publish' => $publish, + ]; + $this->logger->info('CmdbExportImportService: import started', array_merge($audit, ['rows' => count($rows)])); + try { + foreach ($rows as $index => $row) { + if ($this->progressTracker->isCancelRequested(operationId: $operationId) === true) { + $report->markCancelled(); + break; + } + + $this->processRow( + row: $row, + municipalityUuid: $municipality['uuid'], + options: ['updateExisting' => $updateExisting, 'publicationDate' => $publicationDate], + date1904: $workbook['date1904'], + report: $report + ); + $this->progressTracker->updateProgress(processedItems: ($index + 1)); + } + + $result = $report->toArray(); + $this->finishOperation(report: $report->toStoredArray(maxRows: self::STORED_REPORT_ROWS)); + } catch (Throwable $e) { + // Rows catch their own errors; this is the run itself failing, so the + // operation stops as failed instead of staying running until it expires. + $this->logger->error( + 'CmdbExportImportService: import failed', + ['operationId' => $operationId, 'exception' => get_class($e), 'error' => self::logSafeMessage(step: 'import', e: $e, values: [])] + ); + $this->progressTracker->failOperation(message: self::FAILED_RUN_MESSAGE); + throw $e; + }//end try + + $this->logger->info( + 'CmdbExportImportService: import finished', + array_merge($audit, ['summary' => $result['summary'], 'cancelled' => $result['cancelled']]) + ); + + return $result; + }//end runImport() + + /** + * Process one row in its own error boundary and add its outcome. + * + * @param array{sheet: string, row: int, cells: array, uncached?: array} $row The reader row. + * @param string $municipalityUuid The consumer. + * @param array{updateExisting: bool, publicationDate: string|null} $options Whether matched rows are updated, and the + * publicationDate of a created module (null: unpublished). + * @param bool $date1904 The workbook's date system. + * @param CmdbImportReport $report The report. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function processRow( + array $row, + string $municipalityUuid, + array $options, + bool $date1904, + CmdbImportReport $report, + ): void { + $sheet = $row['sheet']; + $values = $this->normaliseRow(row: $row, date1904: $date1904); + $rowNumber = $row['row']; + $appId = ($values[$this->profile->keyColumn()] ?? ''); + $name = ($values[$this->profile->nameColumn()] ?? ''); + + $entry = ['sheet' => $sheet, 'row' => $rowNumber, 'appId' => $appId, 'name' => $name]; + + $warnings = $this->uncachedWarnings(row: $row); + + $matchKey = self::matchKey(appId: $appId); + if ($this->skipForWinningSheet(report: $report, entry: $entry, matchKey: $matchKey, warnings: $warnings) === true) { + return; + } + + $skipReason = $this->skipReason(appId: $appId, matchKey: $matchKey); + if ($skipReason !== null) { + $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: [$skipReason], warnings: $warnings); + return; + } + + $step = 'mapping'; + $moduleUuid = null; + $usageUuid = null; + + try { + $module = $this->map(target: 'module', values: $values, rowNumber: $rowNumber, warnings: $warnings); + if ($module['missing'] !== []) { + $reasons = array_map(fn (string $column): string => $this->l10n->t('missing %s', [$column]), $module['missing']); + $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: $reasons, warnings: $warnings); + return; + } + + // Claimed only now: a row skipped for a missing value leaves its APPID to a later row. + $this->seenKeys[$matchKey] = true; + + // The module is matched before the supplier is resolved, so a row that + // ends as a conflict or `exists` creates no Supplier organisation. + $step = 'module'; + $externalKey = $this->profile->externalKeyPrefix() . ':' . $municipalityUuid . ':' . $matchKey; + $match = $this->matchModule(externalKey: $externalKey, municipalityUuid: $municipalityUuid, matchKey: $matchKey, options: $options); + if ($match['skipReason'] !== null) { + $this->addRow( + report: $report, + entry: $entry, + outcome: CmdbImportReport::SKIPPED, + reasons: [$match['skipReason']], + warnings: $warnings, + moduleUuid: $match['uuid'] + ); + return; + } + + $step = 'manufacturer'; + $providerUuid = $this->resolveManufacturer(values: $values, rowNumber: $rowNumber); + + $step = 'module'; + $moduleResult = $this->importModule( + data: $module['data'], + externalKey: $externalKey, + existing: $match['existing'], + providerUuid: $providerUuid, + publicationDate: $options['publicationDate'], + report: $report + ); + $moduleUuid = $moduleResult['uuid']; + + $step = 'owners'; + $owners = $this->resolveOwners(values: $values, rowNumber: $rowNumber, municipalityUuid: $municipalityUuid, warnings: $warnings); + + $step = 'usage'; + $usage = $this->map(target: 'usage', values: $values, rowNumber: $rowNumber, warnings: $warnings); + $usageResult = $this->upsertUsage( + data: $usage['data'], + municipalityUuid: $municipalityUuid, + moduleUuid: $moduleUuid, + providerUuid: $providerUuid, + owners: $owners + ); + $usageUuid = $usageResult['uuid']; + } catch (Throwable $e) { + $this->failRow(report: $report, entry: $entry, step: $step, e: $e, warnings: $warnings, uuids: [$moduleUuid, $usageUuid], values: $values); + return; + }//end try + + $outcome = self::rowOutcome(module: $moduleResult['outcome'], usage: $usageResult['outcome']); + $this->addRow(report: $report, entry: $entry, outcome: $outcome, warnings: $warnings, moduleUuid: $moduleUuid, usageUuid: $usageUuid); + }//end processRow() + + /** + * The row's cells plus its sheet's constants, normalised the way the profile says. + * + * @param array{sheet: string, cells: array} $row The reader row. + * @param bool $date1904 The workbook's date system. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function normaliseRow(array $row, bool $date1904): array { + return $this->normaliser->normalise( + cells: array_merge($row['cells'], $this->profile->sheetConstants(sheetName: $row['sheet'])), + dateColumns: $this->profile->dateColumns(), + idColumns: $this->profile->idColumns(), + date1904: $date1904, + emptyValues: $this->profile->emptyValues() + ); + }//end normaliseRow() + + /** + * The warning for each formula cell of a row that had no cached value. + * + * @param array{uncached?: array} $row The reader row. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function uncachedWarnings(array $row): array { + $warnings = []; + foreach (($row['uncached'] ?? []) as $column) { + $warnings[] = $this->l10n->t('Column "%s": formula without a cached value, read as empty', [(string)$column]); + } + + return $warnings; + }//end uncachedWarnings() + + /** + * Report a row as failed at a step, and log it without person data. + * + * The report names the step and the kind of exception, never its message: + * OpenRegister messages can quote the object data. The log line keeps the + * message for the administrator, with every cell value of the row and + * every e-mail address taken out (logSafeMessage()). + * + * @param CmdbImportReport $report The report. + * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is. + * @param string $step The step that failed. + * @param Throwable $e The cause. + * @param array $warnings Row warnings so far. + * @param array{0: string|null, 1: string|null} $uuids Module and usage, when saved. + * @param array $values The normalised row, whose values are taken out of the log line. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function failRow(CmdbImportReport $report, array $entry, string $step, Throwable $e, array $warnings, array $uuids, array $values): void { + $this->logger->warning( + 'CmdbExportImportService: row failed', + array_merge( + ['sheet' => $entry['sheet'], 'row' => $entry['row'], 'appId' => $entry['appId']], + ['step' => $step, 'exception' => get_class($e), 'error' => self::logSafeMessage(step: $step, e: $e, values: $values)] + ) + ); + + $kind = substr((string)strrchr('\\' . get_class($e), '\\'), 1); + $reason = $this->l10n->t('step "%1$s" failed (%2$s)', [$step, $kind]); + + $this->addRow( + report: $report, + entry: $entry, + outcome: CmdbImportReport::FAILED, + reasons: [$reason], + warnings: $warnings, + moduleUuid: $uuids[0], + usageUuid: $uuids[1] + ); + }//end failRow() + + /** + * Translate the reader's import-level warnings. + * + * @param array $warnings The reader warnings. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function translateImportWarnings(array $warnings): array { + $translated = []; + foreach ($warnings as $warning) { + $message = $warning['message']; + if (isset($warning['column']) === true) { + $message = $this->l10n->t('Optional column "%s" not found', [$warning['column']]); + } + + $translated[] = ['sheet' => $warning['sheet'], 'message' => $message]; + } + + return $translated; + }//end translateImportWarnings() + + /** + * The row outcome from the module and usage outcomes. + * + * @param string $module The module outcome. + * @param string $usage The usage outcome. + * + * @return string created when the module was created, updated when anything was saved, else unchanged. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private static function rowOutcome(string $module, string $usage): string { + if ($module === CmdbImportReport::CREATED) { + return CmdbImportReport::CREATED; + } + + if ($module !== CmdbImportReport::UNCHANGED || $usage !== CmdbImportReport::UNCHANGED) { + return CmdbImportReport::UPDATED; + } + + return CmdbImportReport::UNCHANGED; + }//end rowOutcome() + + /** + * Store the report with the operation and close it, as completed or cancelled. + * + * @param array $report The report. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function finishOperation(array $report): void { + if ($report['cancelled'] === true) { + $this->progressTracker->updateStatistics(statistics: ['report' => $report]); + $this->progressTracker->cancelOperation(); + return; + } + + $this->progressTracker->completeOperation(finalStatistics: ['report' => $report]); + // A cancel that came in after the last row's check has nothing left to stop. + $this->progressTracker->clearCancelRequested(operationId: (string)$report['operationId']); + }//end finishOperation() + + /** + * Add a row outcome to the report. + * + * @param CmdbImportReport $report The report. + * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is. + * @param string $outcome The outcome. + * @param array $reasons Reasons. + * @param array $warnings Warnings. + * @param string|null $moduleUuid The module. + * @param string|null $usageUuid The usage. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function addRow( + CmdbImportReport $report, + array $entry, + string $outcome, + array $reasons = [], + array $warnings = [], + ?string $moduleUuid = null, + ?string $usageUuid = null, + ): void { + $report->addRow( + sheet: $entry['sheet'], + row: $entry['row'], + appId: $entry['appId'], + name: $entry['name'], + outcome: $outcome, + reasons: $reasons, + warnings: $warnings, + moduleUuid: $moduleUuid, + usageUuid: $usageUuid + ); + }//end addRow() + + /** + * Skip a row whose APPID another sheet wins, with a warning naming the APPID and that sheet. + * + * @param CmdbImportReport $report The report. + * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is. + * @param string $matchKey The APPID's match key. + * @param array $warnings Row warnings so far. + * + * @return bool Whether the row was skipped. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function skipForWinningSheet(CmdbImportReport $report, array $entry, string $matchKey, array $warnings): bool { + $winner = ($this->winningSheets[$matchKey] ?? $entry['sheet']); + if ($entry['appId'] === '' || $winner === $entry['sheet']) { + return false; + } + + $warnings[] = $this->l10n->t('APPID %1$s is also on sheet "%2$s", which wins; this row is not imported', [$entry['appId'], $winner]); + $this->addRow( + report: $report, + entry: $entry, + outcome: CmdbImportReport::SKIPPED, + reasons: [$this->l10n->t('duplicate %s in file', [$this->profile->keyColumn()])], + warnings: $warnings + ); + + return true; + }//end skipForWinningSheet() + + /** + * Per APPID, the sheet whose row is imported when the APPID is on more than one sheet. + * + * The profile ranks the sheets ("Beheerde Applicaties CMDB" before + * "Onbeh Applicaties CMDB"): an application whose maintenance is arranged + * is imported as such, whichever sheet the export lists first. + * + * @param array}> $rows The reader rows. + * + * @return array APPID match key => sheet name. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function winningSheets(array $rows): array { + $key = $this->profile->keyColumn(); + $winners = []; + foreach ($rows as $row) { + $values = $this->normaliser->normalise(cells: [$key => ($row['cells'][$key] ?? null)], dateColumns: [], idColumns: $this->profile->idColumns()); + $matchKey = self::matchKey(appId: ($values[$key] ?? '')); + if ($matchKey === '') { + continue; + } + + $current = ($winners[$matchKey] ?? null); + if ($current === null || $this->profile->sheetRank(sheetName: $row['sheet']) < $this->profile->sheetRank(sheetName: $current)) { + $winners[$matchKey] = $row['sheet']; + } + } + + return $winners; + }//end winningSheets() + + /** + * Why a row is skipped before mapping, or null when it is imported. + * + * An APPID an earlier row of the same sheet imported is a duplicate; across + * sheets winningSheets() decides. APPIDs compare by their match key, so `APP-1` and + * `app-1` are the same application. + * + * @param string $appId The APPID. + * @param string $matchKey The APPID's match key. + * + * @return string|null + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function skipReason(string $appId, string $matchKey): ?string { + if ($appId === '') { + return $this->l10n->t('missing %s', [$this->profile->keyColumn()]); + } + + if (isset($this->seenKeys[$matchKey]) === true) { + return $this->l10n->t('duplicate %s in file', [$this->profile->keyColumn()]); + } + + return null; + }//end skipReason() + + /** + * The key an APPID is matched on: lower case, so a change of case in the export is the same application. + * + * Surrounding whitespace, including a non-breaking space, is already + * removed by the normaliser. + * + * @param string $appId The normalised APPID. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public static function matchKey(string $appId): string { + return mb_strtolower($appId); + }//end matchKey() + + /** + * Map a row through a target's pack. + * + * Errors on required mappings are returned as missing columns. Other + * errors drop the field and become a warning naming the column and the + * value, except for the owner and manufacturer packs, whose errors are + * silent: such a row simply has no owner or no manufacturer. + * + * @param string $target The pack target. + * @param array $values The normalised row. + * @param int $rowNumber The sheet row number, for the engine's errors. + * @param array $warnings Row warnings, appended to. + * + * @return array{data: array, missing: array} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function map(string $target, array $values, int $rowNumber, array &$warnings): array { + $pack = $this->profile->pack(target: $target); + $result = $this->engine->mapRow($pack, $values, $rowNumber); + + $required = []; + foreach (($pack['fieldMappings'] ?? []) as $mapping) { + if (($mapping['required'] ?? false) === true) { + $required[] = (string)($mapping['source'] ?? ''); + } + } + + $silent = in_array($target, ['manufacturer', 'businessOwner', 'municipality'], true); + $missing = []; + foreach (($result['errors'] ?? []) as $error) { + $source = (string)($error['source'] ?? ''); + if (in_array($source, $required, true) === true) { + $missing[] = $source; + continue; + } + + if ($silent === false) { + $warnings[] = $this->l10n->t('Column "%1$s": %2$s', [$source, (string)($error['message'] ?? '')]); + } + } + + // A lookup whose default is null maps a known "no value" (an Applicatiesoort + // that is not a hosting model) to null; that field is left out, as an empty + // cell is, so an update never blanks what the field already holds. + $data = array_filter(($result['data'] ?? []), static fn ($value): bool => $value !== null); + unset($data['id']); + + return ['data' => $data, 'missing' => array_values(array_unique($missing))]; + }//end map() + + /** + * Find or create the organisation of the row's manufacturer. + * + * A name matches one live organisation: a Municipality of that name (the + * municipality builds its own applications, and its Vendor is then its + * own name), else a Supplier. Only no match creates a Supplier. + * + * @param array $values The normalised row. + * @param int $rowNumber The sheet row number. + * + * @return string|null The organisation uuid, or null when the row names no manufacturer. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function resolveManufacturer(array $values, int $rowNumber): ?string { + $warnings = []; + $mapped = $this->map(target: 'manufacturer', values: $values, rowNumber: $rowNumber, warnings: $warnings); + $name = trim((string)($mapped['data']['name'] ?? '')); + if ($mapped['missing'] !== [] || $name === '') { + return null; + } + + $key = self::normaliseName(name: $name); + $suppliers = $this->suppliers(); + if (isset($suppliers[$key]) === true) { + return $suppliers[$key]; + } + + $data = $mapped['data']; + $data['name'] = (string)preg_replace('/\s+/u', ' ', $name); + $uuid = $this->save(schemaKey: 'organization', data: $data, uuid: null); + $this->suppliers[$key] = $uuid; + + return $uuid; + }//end resolveManufacturer() + + /** + * Find the row's module by its import key, and decide whether the row stops there. + * + * A module found by its import key that another organisation uses is a + * conflict: it is neither changed nor duplicated, and the row is skipped. + * A match the run may not update is skipped as `exists`. Nothing is + * written here, so a skipped row leaves no trace in the register. The log + * line of a conflict names the module and the APPID's match key, nothing + * else. + * + * @param string $externalKey The module's import key. + * @param string $municipalityUuid The consumer. + * @param string $matchKey The APPID's match key. + * @param array{updateExisting: bool} $options Whether a match is updated. + * + * @return array{existing: object|null, uuid: string|null, skipReason: string|null} The stored module, the uuid + * to report for a skipped row, + * and why the row is skipped. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function matchModule(string $externalKey, string $municipalityUuid, string $matchKey, array $options): array { + $existing = $this->findOne(schemaKey: 'module', filters: ['externalKey' => $externalKey]); + if ($existing === null) { + return ['existing' => null, 'uuid' => null, 'skipReason' => null]; + } + + $uuid = (string)$existing->getUuid(); + if ($this->moduleBelongsTo(moduleUuid: $uuid, municipalityUuid: $municipalityUuid) === false) { + $this->logger->warning( + 'CmdbExportImportService: import key belongs to a module another organisation uses; row not imported', + ['module' => $uuid, 'municipality' => $municipalityUuid, 'appId' => $matchKey] + ); + $reason = $this->l10n->t('conflict: the application with this import key is used by another organisation, so it is not changed'); + return ['existing' => $existing, 'uuid' => null, 'skipReason' => $reason]; + } + + if ($options['updateExisting'] === false) { + return ['existing' => $existing, 'uuid' => $uuid, 'skipReason' => $this->l10n->t('exists')]; + } + + return ['existing' => $existing, 'uuid' => $uuid, 'skipReason' => null]; + }//end matchModule() + + /** + * Create or update the row's module, and count it when it was created unpublished. + * + * @param array $data The mapped module fields. + * @param string $externalKey The module's import key. + * @param object|null $existing The module matchModule() found, or null to create one. + * @param string|null $providerUuid The supplier, when there is one. + * @param string|null $publicationDate ISO start time of the import for a module that is published + * when created, or null to create it unpublished. + * @param CmdbImportReport $report The report. + * + * @return array{uuid: string, outcome: string} The module and the outcome of upsertModule(). + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function importModule( + array $data, + string $externalKey, + ?object $existing, + ?string $providerUuid, + ?string $publicationDate, + CmdbImportReport $report, + ): array { + $result = $this->upsertModule( + data: $data, + externalKey: $externalKey, + existing: $existing, + providerUuid: $providerUuid, + publicationDate: $publicationDate + ); + + if ($result['outcome'] === CmdbImportReport::CREATED && $publicationDate === null) { + $report->countUnpublished(); + } + + return $result; + }//end importModule() + + /** + * Whether a module found by its import key may be updated for this municipality. + * + * The import key is a property of the module, so it is only trusted + * together with the usages: the module must already have a usage whose + * consumer is this municipality, or, before the first import, no usage + * at all. A module only another organisation uses is never taken over. + * + * @param string $moduleUuid The module found by its import key. + * @param string $municipalityUuid The consumer of this import. + * + * @return bool + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function moduleBelongsTo(string $moduleUuid, string $municipalityUuid): bool { + if ($this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]) !== null) { + return true; + } + + return $this->findOne(schemaKey: 'usage', filters: ['module' => $moduleUuid]) === null; + }//end moduleBelongsTo() + + /** + * Create the module, or merge the mapped fields onto the one matchModule() found. + * + * @param array $data The mapped module fields. + * @param string $externalKey The match key. + * @param object|null $existing The stored module, or null to create one. + * @param string|null $providerUuid The supplier, when there is one. + * @param string|null $publicationDate ISO start time of the import for a module that is published + * when created, or null to create it unpublished. + * + * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function upsertModule( + array $data, + string $externalKey, + ?object $existing, + ?string $providerUuid, + ?string $publicationDate, + ): array { + $data['externalKey'] = $externalKey; + if ($providerUuid !== null) { + $data['provider'] = $providerUuid; + } + + if ($existing === null) { + $create = array_merge($this->profile->createOnlyDefaults(target: 'module'), $data); + unset($create['publicationDate']); + if ($publicationDate !== null) { + $create['publicationDate'] = $publicationDate; + } + + return ['uuid' => $this->save(schemaKey: 'module', data: $create, uuid: null), 'outcome' => CmdbImportReport::CREATED]; + } + + $uuid = (string)$existing->getUuid(); + $merged = $this->merge(target: 'module', stored: $existing->getObject(), mapped: $data); + if ($merged === null) { + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED]; + } + + $this->save(schemaKey: 'module', data: $merged, uuid: $uuid); + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED]; + }//end upsertModule() + + /** + * Create, update, or leave the usage of the module for the municipality. + * + * @param array $data The mapped usage fields. + * @param string $municipalityUuid The consumer. + * @param string $moduleUuid The module. + * @param string|null $providerUuid The supplier, when there is one. + * @param array{businessOwner: string|null} $owners The owner contact person. + * + * @return array{uuid: string, outcome: string} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function upsertUsage(array $data, string $municipalityUuid, string $moduleUuid, ?string $providerUuid, array $owners): array { + if (isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) { + // The concat mapping leaves an empty part for every empty column; drop those. + $parts = array_filter( + array_map('trim', explode(self::NOTE_SEPARATOR, $data['interneAnnotation'])), + fn (string $part): bool => $part !== '' + ); + $data['interneAnnotation'] = implode(self::NOTE_SEPARATOR, $parts); + } + + $data['consumer'] = $municipalityUuid; + $data['module'] = $moduleUuid; + if ($providerUuid !== null) { + $data['provider'] = $providerUuid; + } + + foreach ($owners as $field => $contactPersonUuid) { + if ($contactPersonUuid !== null) { + $data[$field] = $contactPersonUuid; + } + } + + $existing = $this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]); + if ($existing === null) { + return ['uuid' => $this->save(schemaKey: 'usage', data: $data, uuid: null), 'outcome' => CmdbImportReport::CREATED]; + } + + $uuid = (string)$existing->getUuid(); + $merged = $this->merge(target: 'usage', stored: $existing->getObject(), mapped: $data); + if ($merged === null) { + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED]; + } + + $this->save(schemaKey: 'usage', data: $merged, uuid: $uuid); + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED]; + }//end upsertUsage() + + /** + * Merge mapped fields onto a stored object. + * + * Fields the pack does not map stay as they are. Create-only fields are + * written only when the stored value is empty; never-written fields are + * never touched. + * + * @param string $target The profile target. + * @param array $stored The stored object data. + * @param array $mapped The mapped fields. + * + * @return array|null The merged object, or null when nothing changes. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function merge(string $target, array $stored, array $mapped): ?array { + $createOnly = $this->profile->createOnlyFields(target: $target); + $never = $this->profile->neverWrittenOnUpdate(target: $target); + $merged = $stored; + unset($merged['@self']); + $changed = false; + + foreach ($mapped as $field => $value) { + if (in_array($field, $never, true) === true) { + continue; + } + + $current = ($stored[$field] ?? null); + if (in_array($field, $createOnly, true) === true && self::isEmptyValue(value: $current) === false) { + continue; + } + + if (self::sameValue(stored: $current, value: $value) === true) { + continue; + } + + $merged[$field] = $value; + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $merged; + }//end merge() + + /** + * Resolve the owner of a row (Applicatie Eigenaar) as the usage's business owner. + * + * No technical owner is read: the functional administrator columns are + * not part of the mapping. + * + * @param array $values The normalised row. + * @param int $rowNumber The sheet row number. + * @param string $municipalityUuid The municipality the contact person belongs to. + * @param array $warnings Row warnings, appended to. + * + * @return array{businessOwner: string|null} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function resolveOwners(array $values, int $rowNumber, string $municipalityUuid, array &$warnings): array { + $owners = ['businessOwner' => null]; + $identities = []; + foreach (array_keys($owners) as $target) { + $silent = []; + $mapped = $this->map(target: $target, values: $values, rowNumber: $rowNumber, warnings: $silent); + $name = trim((string)($mapped['data']['name'] ?? '')); + if ($mapped['missing'] === [] && $name !== '') { + $identities[$target] = $mapped['data']; + } + } + + if ($identities === []) { + return $owners; + } + + if ($this->contactSync->isAvailable() === false) { + $warnings[] = $this->l10n->t('Owners skipped: Nextcloud Contacts is unavailable'); + return $owners; + } + + foreach ($identities as $target => $identity) { + $column = $this->ownerColumn(target: $target); + try { + $contactsUid = $this->resolveContactUid(identity: $identity); + if ($contactsUid === null) { + $warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved in Nextcloud Contacts', [$column]); + continue; + } + + $owners[$target] = $this->resolveContactPerson( + contactsUid: $contactsUid, + municipalityUuid: $municipalityUuid, + role: trim((string)($identity['role'] ?? '')) + ); + } catch (Throwable $e) { + $this->logger->warning( + 'CmdbExportImportService: owner could not be resolved', + ['row' => $rowNumber, 'column' => $column, 'exception' => get_class($e)] + ); + $warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved', [$column]); + } + }//end foreach + + return $owners; + }//end resolveOwners() + + /** + * Resolve the Nextcloud contact of an owner identity. + * + * Contacts are matched, and created, only in the importing admin's + * dedicated "Stackiq CMDB owners" address book, never in the admin's + * other address books. With an e-mail address, StackiqContactSyncService + * matches on it there or creates the contact. Without one, only a contact + * there whose display name is exactly the owner's name (case-insensitive) + * is reused, so an owner known by name alone is not created again on + * every import. + * + * @param array $identity name, email and role from the owner pack. + * + * @return string|null The contact UID, or null. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function resolveContactUid(array $identity): ?string { + $parts = self::splitPersonName(name: (string)$identity['name']); + $displayName = trim($parts['voornaam'] . ' ' . $parts['achternaam']); + $email = trim((string)($identity['email'] ?? '')); + + $cacheKey = 'name:' . mb_strtolower($displayName); + if ($email !== '') { + $cacheKey = 'email:' . mb_strtolower($email); + } + + if (array_key_exists($cacheKey, $this->contactUids) === true) { + return $this->contactUids[$cacheKey]; + } + + $uid = null; + if ($email === '') { + $uid = $this->contactByDisplayName(displayName: $displayName); + } + + if ($uid === null) { + $record = ['voornaam' => $parts['voornaam'], 'achternaam' => $parts['achternaam']]; + if ($email !== '') { + $record['email'] = $email; + } + + $role = trim((string)($identity['role'] ?? '')); + if ($role !== '') { + $record['role'] = $role; + } + + $uid = $this->contactSync->syncToNamedAddressBook( + objectType: 'contactPerson', + record: $record, + addressBookUri: self::OWNER_ADDRESS_BOOK_URI, + displayName: $this->l10n->t('Stackiq CMDB owners') + ); + if ($uid === '') { + $uid = null; + } + } + + $this->contactUids[$cacheKey] = $uid; + return $uid; + }//end resolveContactUid() + + /** + * The contact in the owners' address book whose display name is exactly this one, case-insensitive. + * + * Only the dedicated "Stackiq CMDB owners" address book is searched: a + * namesake in another address book of the admin, such as a personal + * contact, is never linked to an imported owner. + * + * @param string $displayName The display name. + * + * @return string|null The contact UID, or null. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function contactByDisplayName(string $displayName): ?string { + $needle = mb_strtolower($displayName); + $contacts = $this->contactSync->searchNamedAddressBook(query: $displayName, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: ['FN']); + foreach ($contacts as $contact) { + if (mb_strtolower(trim((string)($contact['name'] ?? ''))) === $needle) { + return (string)$contact['uid']; + } + } + + return null; + }//end contactByDisplayName() + + /** + * Find or create the contact person of a contact for the municipality. + * + * The object carries only `contactsUid`, `organization` and `role`: no + * e-mail and no username, so neither the contact-person listener nor + * OrganizationSyncService::performUserSync() provisions a user for it. + * + * @param string $contactsUid The Nextcloud contact UID. + * @param string $municipalityUuid The organisation. + * @param string $role The owner's function, or ''. + * + * @return string The contact person uuid. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function resolveContactPerson(string $contactsUid, string $municipalityUuid, string $role): string { + $cacheKey = $contactsUid . '|' . $municipalityUuid; + if (isset($this->contactPersons[$cacheKey]) === true) { + return $this->contactPersons[$cacheKey]; + } + + $existing = $this->findOne(schemaKey: 'contactPerson', filters: ['contactsUid' => $contactsUid, 'organization' => $municipalityUuid]); + $data = ['contactsUid' => $contactsUid, 'organization' => $municipalityUuid]; + if ($role !== '') { + $data['role'] = $role; + } + + $uuid = (string)$existing?->getUuid(); + if ($existing === null) { + $uuid = $this->save(schemaKey: 'contactPerson', data: $data, uuid: null); + } + + $this->contactPersons[$cacheKey] = $uuid; + return $uuid; + }//end resolveContactPerson() + + /** + * Resolve the consuming municipality from the options. + * + * A name matches the one live Municipality with that normalised name. + * Several matches are refused rather than guessed: the uuid embedded in + * every external key would bind the whole catalogue to the guess. No + * match creates the municipality, which the report then warns about. + * + * @param array $options municipalityUuid or municipalityName. + * + * @return array{uuid: string, name: string, created: bool} + * + * @throws CmdbImportException MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID or MUNICIPALITY_AMBIGUOUS. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function resolveMunicipality(array $options): array { + $uuid = trim((string)($options['municipalityUuid'] ?? '')); + if ($uuid !== '') { + return $this->municipalityByUuid(uuid: $uuid); + } + + $warnings = []; + $values = ['municipalityName' => trim((string)($options['municipalityName'] ?? ''))]; + $mapped = $this->map(target: 'municipality', values: $values, rowNumber: 0, warnings: $warnings); + $name = trim((string)preg_replace('/\s+/u', ' ', (string)($mapped['data']['name'] ?? ''))); + if ($mapped['missing'] !== [] || $name === '') { + throw new CmdbImportException(errorCode: CmdbImportException::MUNICIPALITY_REQUIRED, message: 'No municipality given'); + } + + $key = self::normaliseName(name: $name); + $matches = array_values( + array_filter( + $this->organisationsOfType(type: 'Municipality'), + static fn (array $organisation): bool => self::normaliseName(name: (string)($organisation['name'] ?? '')) === $key + ) + ); + + if (count($matches) > 1) { + throw new CmdbImportException( + errorCode: CmdbImportException::MUNICIPALITY_AMBIGUOUS, + message: 'Several municipalities have this name', + details: ['matches' => array_column($matches, 'uuid')] + ); + } + + if ($matches !== []) { + return ['uuid' => $matches[0]['uuid'], 'name' => (string)$matches[0]['name'], 'created' => false]; + } + + $data = $mapped['data']; + $data['name'] = $name; + $created = $this->save(schemaKey: 'organization', data: $data, uuid: null); + + return ['uuid' => $created, 'name' => $name, 'created' => true]; + }//end resolveMunicipality() + + /** + * Resolve a municipality uuid, which must be an organisation of type Municipality that was not merged away. + * + * @param string $uuid The organisation uuid. + * + * @return array{uuid: string, name: string, created: bool} + * + * @throws CmdbImportException MUNICIPALITY_INVALID when the uuid is unknown or not a live municipality. + * @throws Throwable When OpenRegister fails to look it up for another reason. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function municipalityByUuid(string $uuid): array { + $coordinates = $this->coordinates(); + $organisation = null; + try { + $organisation = $coordinates['objectService']->find( + id: $uuid, + register: $coordinates['register'], + schema: $coordinates['organization'], + _rbac: false, + _multitenancy: false + ); + } catch (DoesNotExistException $e) { + // OpenRegister throws DoesNotExistException for an unknown uuid. + $organisation = null; + } catch (Throwable $e) { + // Anything else is OpenRegister or the database failing, not a wrong uuid: no 422 for it. + $this->logger->error('CmdbExportImportService: the municipality could not be looked up', ['exception' => get_class($e)]); + throw $e; + } + + $data = []; + if ($organisation !== null) { + $data = $organisation->getObject(); + } + + if ($organisation === null || ($data['type'] ?? null) !== 'Municipality') { + throw new CmdbImportException( + errorCode: CmdbImportException::MUNICIPALITY_INVALID, + message: 'The municipality uuid is not an organisation of type Municipality' + ); + } + + // A merge tombstone points at the organisation that replaced it; data never goes to the tombstone. + if (($data['status'] ?? null) === 'merged') { + throw new CmdbImportException( + errorCode: CmdbImportException::MUNICIPALITY_INVALID, + message: 'The municipality uuid is an organisation that was merged into another' + ); + } + + return ['uuid' => (string)$organisation->getUuid(), 'name' => (string)($data['name'] ?? ''), 'created' => false]; + }//end municipalityByUuid() + + /** + * The organisations a manufacturer name matches, by normalised name, loaded once per run. + * + * Municipalities are loaded first, so a name both a Municipality and a + * Supplier carry is the Municipality. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function suppliers(): array { + if ($this->suppliers === null) { + $this->suppliers = []; + $organisations = array_merge($this->organisationsOfType(type: 'Municipality'), $this->organisationsOfType(type: 'Supplier')); + foreach ($organisations as $organisation) { + $key = self::normaliseName(name: (string)($organisation['name'] ?? '')); + if ($key !== '' && isset($this->suppliers[$key]) === false) { + $this->suppliers[$key] = $organisation['uuid']; + } + } + } + + return $this->suppliers; + }//end suppliers() + + /** + * Every organisation of a type that a name may match, as uuid and name. + * + * Merge tombstones and inactive organisations are left out: new data + * linked to them would never show where the live organisation is used. + * + * @param string $type Municipality or Supplier. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function organisationsOfType(string $type): array { + $coordinates = $this->coordinates(); + $found = []; + $offset = 0; + do { + $page = $coordinates['objectService']->searchObjects( + query: [ + '@self' => ['register' => $coordinates['register'], 'schema' => $coordinates['organization']], + 'type' => $type, + '_limit' => self::PAGE_SIZE, + '_offset' => $offset, + ], + _rbac: false, + _multitenancy: false + ); + if (is_array($page) === false) { + $page = []; + } + + foreach ($page as $entity) { + $data = $entity->getObject(); + // The filter is checked again: a filter OpenRegister cannot apply must not widen the match. + if (($data['type'] ?? null) === $type + && $entity->getUuid() !== null + && in_array(($data['status'] ?? null), self::UNMATCHED_STATUSES, true) === false + ) { + $found[] = ['uuid' => (string)$entity->getUuid(), 'name' => ($data['name'] ?? '')]; + } + } + + $offset += self::PAGE_SIZE; + $pageSize = count($page); + } while ($pageSize === self::PAGE_SIZE); + + return $found; + }//end organisationsOfType() + + /** + * The one object matching every filter, or null. + * + * @param string $schemaKey The coordinates key of the schema. + * @param array $filters Field => exact value. + * + * @return object|null The entity (ObjectEntityInterface). + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function findOne(string $schemaKey, array $filters): ?object { + $coordinates = $this->coordinates(); + $results = $coordinates['objectService']->searchObjects( + query: array_merge( + ['@self' => ['register' => $coordinates['register'], 'schema' => $coordinates[$schemaKey]], '_limit' => 10], + $filters + ), + _rbac: false, + _multitenancy: false + ); + if (is_array($results) === false) { + return null; + } + + foreach ($results as $entity) { + $data = $entity->getObject(); + $matches = true; + foreach ($filters as $field => $value) { + if (self::relationUuid(value: ($data[$field] ?? null)) !== $value) { + $matches = false; + break; + } + } + + if ($matches === true) { + return $entity; + } + } + + return null; + }//end findOne() + + /** + * Save an object through OpenRegister and return its uuid. + * + * @param string $schemaKey The coordinates key of the schema. + * @param array $data The object data. + * @param string|null $uuid The uuid to update, or null to create. + * + * @return string The uuid. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function save(string $schemaKey, array $data, ?string $uuid): string { + $coordinates = $this->coordinates(); + $entity = $coordinates['objectService']->saveObject( + object: $data, + register: $coordinates['register'], + schema: $coordinates[$schemaKey], + uuid: $uuid, + _rbac: false, + _multitenancy: false + ); + + return (string)$entity->getUuid(); + }//end save() + + /** + * Resolve OpenRegister's mapping engine. + * + * @return object The engine, with `mapRow(array, array, int): array`. + * + * @throws CmdbImportException MAPPING_UNAVAILABLE. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + private function resolveEngine(): object { + $class = static::ENGINE_CLASS; + try { + if ($this->container->has($class) === true) { + $engine = $this->container->get($class); + if (is_object($engine) === true && method_exists($engine, 'mapRow') === true) { + return $engine; + } + } + } catch (Throwable $e) { + // Fall through to the class check below. + $engine = null; + } + + if (class_exists($class) === true) { + $engine = new $class(); + if (method_exists($engine, 'mapRow') === true) { + return $engine; + } + } + + throw new CmdbImportException(errorCode: CmdbImportException::MAPPING_UNAVAILABLE, message: 'OpenRegister MappingEngine is not available'); + }//end resolveEngine() + + /** + * Resolve OpenRegister and the register and schema ids, failing closed. + * + * @return array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int} + * + * @throws CmdbImportException NOT_CONFIGURED when OpenRegister or a schema is not configured, + * SCHEMA_OUTDATED when a schema lacks a match property. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function resolveCoordinates(): array { + try { + $objectService = $this->container->get(ObjectServiceInterface::class); + } catch (Throwable $e) { + $objectService = null; + } + + $register = (int)($this->settingsService->getVoorzieningenConfig()['register'] ?? 0); + $schemas = []; + foreach (array_keys(self::MATCH_PROPERTIES) as $type) { + $schemas[$type] = (int)($this->settingsService->getSchemaIdForObjectType($type) ?? 0); + } + + if ($objectService instanceof ObjectServiceInterface === false || $register <= 0 || in_array(0, $schemas, true) === true) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_CONFIGURED, + message: 'OpenRegister or the stackiq register and schemas are not configured' + ); + } + + $this->assertMatchProperties(schemas: $schemas); + + return array_merge(['objectService' => $objectService, 'register' => $register], $schemas); + }//end resolveCoordinates() + + /** + * Refuse the import when a schema does not declare the properties and write rules the import relies on. + * + * The module properties, and the admin-only update rule on `externalKey`, + * arrive with the register fragment (module 0.3.8 and later), which an + * installation gets only after its register configuration is imported + * again. A missing rule is reported as `.authorization.update`. + * + * @param array $schemas Schema key => schema id. + * + * @return void + * + * @throws CmdbImportException NOT_CONFIGURED when the schemas cannot be read, + * SCHEMA_OUTDATED when one lacks a match property. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function assertMatchProperties(array $schemas): void { + try { + $mapper = $this->container->get(static::SCHEMA_MAPPER_CLASS); + } catch (Throwable $e) { + $mapper = null; + } + + if (is_object($mapper) === false || method_exists($mapper, 'find') === false) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_CONFIGURED, message: 'OpenRegister SchemaMapper is not available'); + } + + foreach (self::MATCH_PROPERTIES as $type => $required) { + try { + $properties = $mapper->find(id: $schemas[$type], _rbac: false, _multitenancy: false)->getProperties(); + } catch (Throwable $e) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_CONFIGURED, + message: 'The ' . $type . ' schema cannot be read: ' . get_class($e), + previous: $e + ); + } + + $properties = (array)$properties; + $missing = array_values(array_diff($required, array_keys($properties))); + array_push($missing, ...self::missingUpdateRules(type: $type, properties: $properties)); + if ($missing !== []) { + throw new CmdbImportException( + errorCode: CmdbImportException::SCHEMA_OUTDATED, + message: 'The ' . $type . ' schema does not declare the properties the import matches on', + details: ['schema' => $type, 'missing' => $missing] + ); + } + } + }//end assertMatchProperties() + + /** + * The declared properties of a schema that lack the update rule the import relies on. + * + * A property that is not declared at all is already reported as missing. + * + * @param string $type The schema key. + * @param array $properties The schema's property definitions. + * + * @return array `.authorization.update` per property without the rule. + */ + private static function missingUpdateRules(string $type, array $properties): array { + $missing = []; + foreach ((self::REQUIRED_UPDATE_RULES[$type] ?? []) as $property => $group) { + if (array_key_exists($property, $properties) === false) { + continue; + } + + $definition = json_decode((string)json_encode($properties[$property]), true); + $update = ($definition['authorization']['update'] ?? null); + if (is_array($update) === false) { + $update = []; + } + + $groups = []; + foreach ($update as $rule) { + if (is_array($rule) === true) { + $rule = ($rule['group'] ?? null); + } + + $groups[] = $rule; + } + + if (in_array($group, $groups, true) === false) { + $missing[] = $property . '.authorization.update'; + } + } + + return $missing; + }//end missingUpdateRules() + + /** + * The coordinates of the current run. + * + * @return array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int} + * + * @throws CmdbImportException NOT_CONFIGURED. + */ + private function coordinates(): array { + if ($this->coordinates === null) { + $this->coordinates = $this->resolveCoordinates(); + } + + return $this->coordinates; + }//end coordinates() + + /** + * The operation id the client chose, or a new one. + * + * The client's id is replaced when it does not match the pattern, and + * also when an operation with that id is still running: reusing it would + * overwrite that run's progress and owner, and a cancel would stop both. + * An id that is taken drops any cancel request left over from an earlier + * run with the same id, so the new run is not stopped before row 1. + * + * @param array $options The import options. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function operationIdFrom(array $options): string { + $operationId = $options['operationId'] ?? null; + if (is_string($operationId) === true + && preg_match(self::OPERATION_ID_PATTERN, $operationId) === 1 + && ($this->progressTracker->getProgress(operationId: $operationId)['status'] ?? null) !== 'running' + ) { + $this->progressTracker->clearCancelRequested(operationId: $operationId); + return $operationId; + } + + return 'cmdb-' . bin2hex(random_bytes(16)); + }//end operationIdFrom() + + /** + * Clear the run caches. + * + * @return void + */ + private function resetRun(): void { + $this->engine = null; + $this->coordinates = null; + $this->suppliers = null; + $this->seenKeys = []; + $this->winningSheets = []; + $this->contactUids = []; + $this->contactPersons = []; + }//end resetRun() + + /** + * The source column of an owner target, for warnings. + * + * @param string $target businessOwner. + * + * @return string + */ + private function ownerColumn(string $target): string { + foreach (($this->profile->pack(target: $target)['fieldMappings'] ?? []) as $mapping) { + if (($mapping['target'] ?? null) === 'name') { + return (string)($mapping['source'] ?? $target); + } + } + + return $target; + }//end ownerColumn() + + /** + * An exception message that is safe for the log: no cell value and no e-mail address. + * + * Only the first line is kept, at most 300 characters. Every value of the + * row of three characters or more is replaced by "…", longest first, and + * every e-mail address by "". The owner step logs no message at + * all, so no contact data can reach the log. Public so the controller logs + * an unexpected failure the same way. + * + * @param string $step The step that failed. + * @param Throwable $e The exception. + * @param array $values The normalised row. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public static function logSafeMessage(string $step, Throwable $e, array $values): string { + if ($step === 'owners') { + return ''; + } + + $lines = preg_split('/\R/u', $e->getMessage()); + $message = mb_substr(trim((string)($lines[0] ?? '')), 0, 300); + $message = (string)preg_replace('/[^\s@<>"\'(),;:=]+@[^\s@<>"\'(),;:]+/u', '', $message); + $secrets = array_filter(array_unique(array_map('strval', $values)), static fn (string $value): bool => mb_strlen($value) >= 3); + usort($secrets, static fn (string $left, string $right): int => (mb_strlen($right) <=> mb_strlen($left))); + foreach ($secrets as $secret) { + $message = str_ireplace($secret, '…', $message); + } + + return $message; + }//end logSafeMessage() + + /** + * Split a TOPdesk person name ("Achternaam, Voornaam"). + * + * @param string $name The name. + * + * @return array{voornaam: string, achternaam: string} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + public static function splitPersonName(string $name): array { + $name = trim((string)preg_replace('/\s+/u', ' ', $name)); + if (str_contains($name, ',') === true) { + [$last, $first] = array_map('trim', explode(',', $name, 2)); + return ['voornaam' => $first, 'achternaam' => $last]; + } + + return ['voornaam' => '', 'achternaam' => $name]; + }//end splitPersonName() + + /** + * Normalise an organisation name for matching: trim, collapse whitespace, lower case. + * + * @param string $name The name. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public static function normaliseName(string $name): string { + return mb_strtolower(trim((string)preg_replace('/\s+/u', ' ', $name))); + }//end normaliseName() + + /** + * A relation value (uuid string, or array/object with uuid or id) as a string. + * + * @param mixed $value The stored value. + * + * @return string|null + */ + private static function relationUuid(mixed $value): ?string { + if (is_scalar($value) === true) { + return (string)$value; + } + + if (is_array($value) === true) { + $uuid = ($value['uuid'] ?? ($value['id'] ?? null)); + if (is_scalar($uuid) === true) { + return (string)$uuid; + } + } + + return null; + }//end relationUuid() + + /** + * Whether a stored value equals a mapped value. + * + * @param mixed $stored The stored value. + * @param mixed $value The mapped value. + * + * @return bool + */ + private static function sameValue(mixed $stored, mixed $value): bool { + if (is_scalar($value) === true && (is_scalar($stored) === true || is_array($stored) === true)) { + return self::relationUuid(value: $stored) === (string)$value; + } + + return $stored === $value; + }//end sameValue() + + /** + * Whether a stored value counts as empty. + * + * @param mixed $value The stored value. + * + * @return bool + */ + private static function isEmptyValue(mixed $value): bool { + return $value === null || $value === '' || $value === []; + }//end isEmptyValue() +}//end class diff --git a/lib/Service/ConnectionReportService.php b/lib/Service/ConnectionReportService.php new file mode 100644 index 000000000..1e058c974 --- /dev/null +++ b/lib/Service/ConnectionReportService.php @@ -0,0 +1,555 @@ + + * @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 service desk exchange connection key in lib/Settings/connections.json. + * + * @var string + */ + public const KEY_ITSM = 'itsm'; + + /** + * 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() + + /** + * After the service desk exchange was set up: report what the set-up met. + * + * @param bool $created Whether every flow was created. + * @param string $message What the set-up found. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is + */ + public function itsmSetUp(bool $created, string $message): bool { + $status = 'error'; + if ($created === true) { + $status = 'configured'; + } + + return $this->report(key: self::KEY_ITSM, status: $status, message: $this->shorten(text: $message)); + }//end itsmSetUp() + + /** + * 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/DemoDataService.php b/lib/Service/DemoDataService.php index ac68ddefc..d5fd1a921 100644 --- a/lib/Service/DemoDataService.php +++ b/lib/Service/DemoDataService.php @@ -225,7 +225,10 @@ public function install(): array { $objects = count($components['objects']); } - $result = $this->configurationService()->importFromApp( + $importer = $this->configurationService(); + $data = $this->pinSchemasToTheirRegisters(data: $data); + + $result = $importer->importFromApp( appId: self::CONFIG_APP_ID, data: $data, version: $this->appManager->getAppVersion(Application::APP_ID), @@ -249,6 +252,74 @@ public function install(): array { return $imported; }//end install() + /** + * Point every demo object at its schema in its own register, by id. + * + * The descriptor carries no schema definitions (the live register's are the + * ones the objects must satisfy), so each object names its schema by slug. + * OpenRegister resolves a slug it has no definition for across the whole + * instance, and a slug such as `organization` is also another app's + * (OpenCatalogi's), whose schema the importer then skips as foreign. The + * register the object names lists the right schema, so the slug is + * resolved there and replaced by that schema's id. A slug the register does + * not list, or a register that cannot be read, is left for the importer. + * + * @param array $data The descriptor. + * + * @return array The descriptor with resolved schema ids. + * + * @spec exclude Demo-data import; ADR-111 rule 1 has no per-app behavioural spec. + */ + private function pinSchemasToTheirRegisters(array $data): array { + $objects = ($data['components']['objects'] ?? null); + if (is_array($objects) === false) { + return $data; + } + + $bySlug = []; + foreach ($objects as $index => $object) { + $register = (string) ($object['@self']['register'] ?? ''); + $schema = (string) ($object['@self']['schema'] ?? ''); + if ($register === '' || $schema === '') { + continue; + } + + $bySlug[$register] ??= $this->schemaIdsOfRegister(registerSlug: $register); + if (isset($bySlug[$register][$schema]) === true) { + $data['components']['objects'][$index]['@self']['schema'] = (string) $bySlug[$register][$schema]; + } + } + + return $data; + }//end pinSchemasToTheirRegisters() + + /** + * The schemas a register lists, as slug => id. + * + * @param string $registerSlug The register. + * + * @return array The schema ids by slug; empty when the register cannot be read. + */ + private function schemaIdsOfRegister(string $registerSlug): array { + try { + $register = $this->container->get('OCA\OpenRegister\Db\RegisterMapper')->find($registerSlug, _rbac: false, _multitenancy: false); + $schemas = $this->container->get('OCA\OpenRegister\Db\SchemaMapper')->findMultiple($register->getSchemas(), _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + $this->logger->warning( + '[DemoDataService] could not read the schemas of a demo register; its objects are resolved by slug', + ['register' => $registerSlug, 'error' => $e->getMessage()] + ); + return []; + } + + $ids = []; + foreach ($schemas as $schema) { + $ids[(string) $schema->getSlug()] = (int) $schema->getId(); + } + + return $ids; + }//end schemaIdsOfRegister() + /** * Absolute path to the shipped descriptor. * 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/Itsm/ItsmFlowGateway.php b/lib/Service/Itsm/ItsmFlowGateway.php new file mode 100644 index 000000000..f8abc9642 --- /dev/null +++ b/lib/Service/Itsm/ItsmFlowGateway.php @@ -0,0 +1,223 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Itsm; + +use Psr\Container\ContainerInterface; +use RuntimeException; +use Throwable; + +/** + * Validates, saves, publishes and runs flows through OpenRegister. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ +class ItsmFlowGateway { + + /** + * OpenRegister's preflight, which checks a flow against the live node registry. + * + * @var string + */ + private const PREFLIGHT = 'OCA\OpenRegister\Service\Flow\FlowNodePreflight'; + + /** + * OpenRegister's flow store. + * + * @var string + */ + private const FLOWS = 'OCA\OpenRegister\Service\Flow\FlowService'; + + /** + * OpenRegister's flow versions, which publish a draft. + * + * @var string + */ + private const VERSIONS = 'OCA\OpenRegister\Service\Flow\FlowVersionService'; + + /** + * OpenRegister's object service. + * + * @var string + */ + private const OBJECTS = 'OCA\OpenRegister\Service\ObjectService'; + + /** + * Constructor. + * + * @param ContainerInterface $container The server container. + */ + public function __construct( + private readonly ContainerInterface $container, + ) { + }//end __construct() + + /** + * Whether OpenRegister's flow engine is there. + * + * @return bool True when the flow classes resolve. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public function available(): bool { + return class_exists('\\' . self::FLOWS) === true && class_exists('\\' . self::PREFLIGHT) === true; + }//end available() + + /** + * Check a flow document against the live node registry. + * + * @param array $flow The flow document. + * + * @return array{blocking: list>, warnings: list>} The findings. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public function inspect(array $flow): array { + $result = $this->service(class: self::PREFLIGHT)->inspect($flow); + + return [ + 'blocking' => array_values((array) ($result['blocking'] ?? [])), + 'warnings' => array_values((array) ($result['warnings'] ?? [])), + ]; + }//end inspect() + + /** + * Save a flow, publish it and switch it on. + * + * @param array $flow The flow document. + * @param string|null $uuid The flow to update, or null to create one. + * + * @return string The flow's uuid. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public function saveAndPublish(array $flow, ?string $uuid): string { + $flows = $this->service(class: self::FLOWS); + if ($uuid !== null && $this->exists(uuid: $uuid) === false) { + $uuid = null; + } + + // A published version is immutable: open a draft first, so a second + // set-up edits the flow instead of being refused. + if ($uuid !== null) { + $existing = $flows->find($uuid); + if ((string) $existing->getLifecycleStatus() !== 'draft') { + $this->service(class: self::VERSIONS)->createDraft($existing); + } + } + + $saved = $flows->save($flow, $uuid); + $this->service(class: self::VERSIONS)->publish($saved); + + $enabled = $flows->save(['enabled' => true], (string) $saved->getUuid()); + + return (string) $enabled->getUuid(); + }//end saveAndPublish() + + /** + * Start a run of a flow with a payload, which seeds the run's first item. + * + * @param string $uuid The flow. + * @param array $payload The payload. + * + * @return string The run's uuid. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import + */ + public function run(string $uuid, array $payload): string { + $run = $this->service(class: self::FLOWS)->run($uuid, [], ['payload' => $payload], false); + + return (string) $run->getUuid(); + }//end run() + + /** + * Read one object of a register and schema by id, uuid or slug. + * + * @param string $register The register slug. + * @param string $schema The schema slug. + * @param string $id The id, uuid or slug. + * + * @return array|null The object, or null when there is none. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public function findObject(string $register, string $schema, string $id): ?array { + try { + $found = $this->service(class: self::OBJECTS)->find($id, [], false, $register, $schema, false, false); + } catch (Throwable $e) { + return null; + } + + if (is_object($found) === false) { + return null; + } + + $object = (array) $found->jsonSerialize(); + $object['uuid'] = (string) $found->getUuid(); + + return $object; + }//end findObject() + + /** + * Whether a flow still exists. + * + * @param string $uuid The flow. + * + * @return bool True when it does. + */ + private function exists(string $uuid): bool { + try { + $this->service(class: self::FLOWS)->find($uuid); + } catch (Throwable $e) { + return false; + } + + return true; + }//end exists() + + /** + * Resolve one OpenRegister service. + * + * @param string $class The class name. + * + * @return object The service. + * + * @throws RuntimeException When OpenRegister does not provide it. + */ + private function service(string $class): object { + try { + $service = $this->container->get($class); + } catch (Throwable $e) { + throw new RuntimeException('OpenRegister does not provide ' . $class . ': ' . $e->getMessage(), 0, $e); + } + + if (is_object($service) === false) { + throw new RuntimeException('OpenRegister does not provide ' . $class . '.'); + } + + return $service; + }//end service() +}//end class diff --git a/lib/Service/ItsmExchangeService.php b/lib/Service/ItsmExchangeService.php new file mode 100644 index 000000000..e14991f39 --- /dev/null +++ b/lib/Service/ItsmExchangeService.php @@ -0,0 +1,528 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use InvalidArgumentException; +use OCA\Stackiq\AppInfo\Application; +use OCA\Stackiq\Service\Itsm\ItsmFlowGateway; +use OCP\IAppConfig; +use OCP\IURLGenerator; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Fills, checks and creates the service desk exchange flows. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ +class ItsmExchangeService { + + /** + * App setting that holds the desk, the organisation and the flow uuids. + * + * @var string + */ + public const CONFIG_KEY = 'itsm_exchange'; + + /** + * App setting the Integrations page reads as the exchange's switch. + * + * @var string + */ + public const ENABLED_KEY = 'itsm_exchange_enabled'; + + /** + * The register every stackiq flow writes to. + * + * @var string + */ + public const REGISTER = 'stackiq'; + + /** + * The nightly import time, as a five-field cron expression. + * + * @var string + */ + public const CRON = '0 2 * * *'; + + /** + * One profile per service desk: integriq's slugs and the outbound endpoints. + * + * The slugs, endpoints and answer paths are the ones integriq's change + * connectors-service-desk-templates ships and its mocks answer. Endpoints + * are relative to the source's location; `%BASE%` becomes the tenant's + * scheme and host, for record links. TOPdesk lists asset links one asset + * at a time, so its relations use the per-application template. + * + * @var array> + */ + public const DESKS = [ + 'topdesk' => [ + 'label' => 'TOPdesk', + 'source' => 'topdesk', + 'relationsTemplate' => 'itsm-inbound-relations-per-application.json', + 'createEndpoint' => '/assetmgmt/assets', + 'createMethod' => 'POST', + 'updateEndpoint' => '/assetmgmt/assets/{{ usage.recordId }}', + 'updateMethod' => 'POST', + 'callQuery' => [], + 'responseId' => 'data.id', + 'recordUrl' => '%BASE%/tas/secure/assetmgmt/card.html?unid={{ response.body.data.id }}', + ], + 'servicenow' => [ + 'label' => 'ServiceNow', + 'source' => 'servicenow', + 'relationsTemplate' => 'itsm-inbound-relations.json', + 'createEndpoint' => '/api/now/table/cmdb_ci_appl', + 'createMethod' => 'POST', + 'updateEndpoint' => '/api/now/table/cmdb_ci_appl/{{ usage.recordId }}', + 'updateMethod' => 'PATCH', + 'callQuery' => ['sysparm_input_display_value' => 'true'], + 'responseId' => 'result.sys_id', + 'recordUrl' => '%BASE%/nav_to.do?uri=cmdb_ci_appl.do?sys_id={{ response.body.result.sys_id }}', + ], + ]; + + /** + * The flows the set-up creates: key => template file, feed and preset suffix. + * + * @var array + */ + public const FLOWS = [ + 'applications' => ['template' => 'itsm-inbound-applications.json', 'feed' => 'applications', 'preset' => 'application-inbound'], + 'relations' => ['template' => 'itsm-inbound-relations.json', 'feed' => 'relations', 'preset' => 'relation-inbound'], + 'licences' => [ + 'template' => 'itsm-inbound-contracts.json', + 'feed' => 'licences', + 'preset' => 'licence-inbound', + 'contractType' => 'Licence', + 'feedLabel' => 'licences', + ], + 'contracts' => [ + 'template' => 'itsm-inbound-contracts.json', + 'feed' => 'contracts', + 'preset' => 'contract-inbound', + 'contractType' => 'SLA', + 'feedLabel' => 'contracts', + ], + 'outbound' => ['template' => 'itsm-outbound-applications.json', 'feed' => 'outbound', 'preset' => 'application-outbound'], + 'file' => ['template' => 'itsm-file-applications.json', 'feed' => 'file', 'preset' => 'file'], + ]; + + /** + * Constructor. + * + * @param ItsmFlowGateway $gateway OpenRegister's flow store. + * @param IAppConfig $appConfig The app settings. + * @param IURLGenerator $urlGenerator Builds the catalogue link sent to the desk. + * @param LoggerInterface $logger The logger. + * @param ConnectionReportService|null $connectionReports Tells integriq what the set-up met. + * @param string|null $templateDir Where the flow templates live; tests point it elsewhere. + */ + public function __construct( + private readonly ItsmFlowGateway $gateway, + private readonly IAppConfig $appConfig, + private readonly IURLGenerator $urlGenerator, + private readonly LoggerInterface $logger, + private readonly ?ConnectionReportService $connectionReports = null, + private readonly ?string $templateDir = null, + ) { + }//end __construct() + + /** + * What is set up today. + * + * @return array The desk, the organisation, the flows and the desks on offer. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is + */ + public function status(): array { + $config = $this->config(); + $desks = []; + foreach (self::DESKS as $key => $desk) { + $desks[] = ['id' => $key, 'label' => $desk['label']]; + } + + return [ + 'available' => $this->gateway->available(), + 'enabled' => $this->appConfig->getValueBool(Application::APP_ID, self::ENABLED_KEY, false), + 'desk' => ($config['desk'] ?? null), + 'organisation' => ($config['organisation'] ?? null), + 'flows' => ($config['flows'] ?? []), + 'setUpAt' => ($config['setUpAt'] ?? null), + 'desks' => $desks, + ]; + }//end status() + + /** + * Fill every template for one desk and one organisation. + * + * @param string $desk The desk key (topdesk, servicenow). + * @param string $organisation The uuid of the organisation whose applications are exchanged. + * @param string $runAs The user the scheduled imports run as. + * @param string $location The desk source's location; its scheme and host make the record links. + * @param string $templateId The desk's asset template for a new record (TOPdesk needs one), or empty. + * + * @return array> The filled flow documents, by flow key. + * + * @throws InvalidArgumentException When the desk is unknown or a template is missing. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public function buildFlows(string $desk, string $organisation, string $runAs, string $location, string $templateId = ''): array { + if (isset(self::DESKS[$desk]) === false) { + throw new InvalidArgumentException('Unknown service desk "' . $desk . '". Choose one of: ' . implode(', ', array_keys(self::DESKS)) . '.'); + } + + $profile = self::DESKS[$desk]; + $appUrl = rtrim($this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_ID), '/'); + $base = self::baseOf(location: $location); + $flows = []; + foreach (self::FLOWS as $key => $flow) { + $deskKey = $desk; + if ($key === 'file') { + $deskKey = 'file'; + } + + $values = [ + 'REGISTER' => self::REGISTER, + 'CONSUMER' => $organisation, + 'RUN_AS' => $runAs, + 'CRON' => self::CRON, + 'DESK' => $deskKey, + 'DESK_LABEL' => $profile['label'], + 'SOURCE' => $profile['source'], + 'SYNC' => 'itsm-' . $deskKey . '-' . $flow['feed'], + 'PRESET' => 'itsm-' . $deskKey . '-' . $flow['preset'], + 'CONTRACT_TYPE' => ($flow['contractType'] ?? 'Licence'), + 'FEED_LABEL' => ($flow['feedLabel'] ?? $flow['feed']), + 'APP_URL' => $appUrl, + 'CREATE_ENDPOINT' => $profile['createEndpoint'], + 'CREATE_METHOD' => $profile['createMethod'], + 'UPDATE_ENDPOINT' => $profile['updateEndpoint'], + 'UPDATE_METHOD' => $profile['updateMethod'], + 'RESPONSE_ID' => $profile['responseId'], + 'RECORD_URL' => str_replace('%BASE%', $base, $profile['recordUrl']), + 'DESK_BASE' => $base, + 'TEMPLATE_ID' => $templateId, + 'CALL_QUERY' => $profile['callQuery'], + ]; + if ($key === 'file') { + $values['SYNC'] = 'itsm-file-applications'; + $values['PRESET'] = 'itsm-file-application-inbound'; + } + + $template = $flow['template']; + if ($key === 'relations') { + $template = $profile['relationsTemplate']; + } + + $flows[$key] = self::fill(value: $this->template(file: $template), values: $values); + }//end foreach + + return $flows; + }//end buildFlows() + + /** + * Set up the exchange: check every flow, then create or update them all. + * + * Nothing is saved unless every flow passes preflight, so the instance never + * holds half an exchange. Running it again updates the flows it created. + * + * @param string $desk The desk key. + * @param string $organisation The uuid of the organisation whose applications are exchanged. + * @param string $runAs The user the scheduled imports run as. + * @param string $templateId The desk's asset template for new records, when the desk needs one. + * + * @return array `created`, and either `flows` or `blocking` per flow key. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public function setUp(string $desk, string $organisation, string $runAs, string $templateId = ''): array { + $found = $this->preconditions(desk: $desk, organisation: $organisation); + if (is_string($found) === true) { + return $this->refuse(message: $found); + } + + // The flows compare this with a usage's consumer uuid, so store the uuid + // even when the admin gave an id or a slug. + $organisation = $found['organisation']; + try { + $flows = $this->buildFlows( + desk: $desk, + organisation: $organisation, + runAs: $runAs, + location: (string) ($found['source']['location'] ?? ''), + templateId: $templateId + ); + + $blocking = $this->blockingFindings(flows: $flows); + } catch (Throwable $e) { + $this->logger->error('[ItsmExchangeService] Checking the exchange flows failed', ['exception' => $e]); + return $this->refuse(message: 'Nothing was created. The flows could not be checked: ' . $e->getMessage()); + } + + if ($blocking !== []) { + $first = (array) reset($blocking); + $entry = (array) ($first[0] ?? []); + return $this->refuse( + message: 'Nothing was created. OpenRegister refused flow "' . (string) array_key_first($blocking) . '": step ' + . (string) ($entry['step'] ?? '?') . ', ' . (string) ($entry['reason'] ?? 'unknown reason') . '.', + blocking: $blocking + ); + } + + $stored = (array) ($this->config()['flows'] ?? []); + $saved = []; + try { + foreach ($flows as $key => $flow) { + $saved[$key] = $this->gateway->saveAndPublish(flow: $flow, uuid: $this->storedUuid(stored: $stored, key: $key)); + } + } catch (Throwable $e) { + $this->logger->error('[ItsmExchangeService] Saving the exchange flows failed', ['exception' => $e]); + $this->storeConfig(desk: $desk, organisation: $organisation, flows: array_merge($stored, $saved)); + return $this->refuse(message: 'Saving the flows failed after ' . count($saved) . ' of ' . count($flows) . ': ' . $e->getMessage()); + } + + $this->storeConfig(desk: $desk, organisation: $organisation, flows: $saved); + $this->appConfig->setValueBool(Application::APP_ID, self::ENABLED_KEY, true); + $this->connectionReports?->itsmSetUp( + created: true, + message: count($saved) . ' flows set up for ' . self::DESKS[$desk]['label'] . '. The first import runs tonight.' + ); + + return ['created' => true, 'desk' => $desk, 'flows' => $saved]; + }//end setUp() + + /** + * What must be there before any flow is built: the engine, the desk, the organisation and integriq's source. + * + * @param string $desk The desk key. + * @param string $organisation The organisation uuid. + * + * @return array{source: array, organisation: string}|string The integriq source and the + * organisation's uuid, or why the + * set-up cannot start. + */ + private function preconditions(string $desk, string $organisation): array|string { + if ($this->gateway->available() === false) { + return 'OpenRegister\'s flow engine is not available, so no exchange can be set up.'; + } + + if (isset(self::DESKS[$desk]) === false) { + return 'Unknown service desk "' . $desk . '".'; + } + + $found = $this->gateway->findObject(register: self::REGISTER, schema: 'organization', id: $organisation); + if ($found === null || (string) ($found['uuid'] ?? '') === '') { + return 'The organisation ' . $organisation . ' does not exist in stackiq.'; + } + + $profile = self::DESKS[$desk]; + $source = $this->gateway->findObject(register: 'integriq', schema: 'source', id: $profile['source']); + if ($source === null) { + return 'Integriq has no source "' . $profile['source'] . '". Add the ' . $profile['label'] . ' source in integriq first.'; + } + + return ['source' => $source, 'organisation' => (string) $found['uuid']]; + }//end preconditions() + + /** + * Preflight every flow and keep the blocking findings. + * + * @param array> $flows The filled flows by key. + * + * @return array>> The blocking findings by flow key; empty when all pass. + */ + private function blockingFindings(array $flows): array { + $blocking = []; + foreach ($flows as $key => $flow) { + $findings = $this->gateway->inspect(flow: $flow); + if ($findings['blocking'] !== []) { + $blocking[$key] = $findings['blocking']; + } + } + + return $blocking; + }//end blockingFindings() + + /** + * The uuid a flow was stored under by an earlier set-up, or null. + * + * @param array $stored The stored flow uuids. + * @param string $key The flow key. + * + * @return string|null The uuid, or null when there is none. + */ + private function storedUuid(array $stored, string $key): ?string { + $previous = ($stored[$key] ?? null); + if (is_string($previous) === false || $previous === '') { + return null; + } + + return $previous; + }//end storedUuid() + + /** + * Replace every `%KEY%` placeholder in the strings of a value. + * + * A string that is exactly one placeholder takes the value as it is, so a + * list or an object can be filled in; any other string gets text. + * + * @param mixed $value The template, or a part of it. + * @param array $values The placeholder values, by key without the percent signs. + * + * @return mixed The filled value. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public static function fill(mixed $value, array $values): mixed { + if (is_array($value) === true) { + $filled = []; + foreach ($value as $key => $item) { + $filled[$key] = self::fill(value: $item, values: $values); + } + + return $filled; + } + + if (is_string($value) === false) { + return $value; + } + + if (preg_match('/^%([A-Z_]+)%$/', $value, $whole) === 1 && array_key_exists($whole[1], $values) === true) { + return $values[$whole[1]]; + } + + $search = []; + $replace = []; + foreach ($values as $key => $text) { + if (is_array($text) === true) { + continue; + } + + $search[] = '%' . $key . '%'; + $replace[] = (string) $text; + } + + return str_replace($search, $replace, $value); + }//end fill() + + /** + * The scheme, host and port of a location, without its path. + * + * @param string $location The source's location. + * + * @return string The base, or an empty string when the location has no host. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + public static function baseOf(string $location): string { + $parts = parse_url(trim($location)); + if (is_array($parts) === false || isset($parts['host']) === false) { + return ''; + } + + $base = ($parts['scheme'] ?? 'https') . '://' . $parts['host']; + if (isset($parts['port']) === true) { + $base .= ':' . $parts['port']; + } + + return $base; + }//end baseOf() + + /** + * Read one template. + * + * @param string $file The file name. + * + * @return array The template. + * + * @throws InvalidArgumentException When it is missing or not JSON. + */ + private function template(string $file): array { + $path = ($this->templateDir ?? __DIR__ . '/../Settings/flows') . '/' . $file; + $content = ''; + if (is_file($path) === true) { + $content = (string) file_get_contents($path); + } + + $decoded = json_decode($content, true); + if (is_array($decoded) === false) { + throw new InvalidArgumentException('The flow template ' . $file . ' is missing or not JSON.'); + } + + return $decoded; + }//end template() + + /** + * The stored set-up. + * + * @return array The stored set-up, or an empty array. + */ + private function config(): array { + $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::CONFIG_KEY, '{}'), true); + if (is_array($decoded) === false) { + return []; + } + + return $decoded; + }//end config() + + /** + * Store the set-up. + * + * @param string $desk The desk key. + * @param string $organisation The organisation uuid. + * @param array $flows The flow uuids by key. + * + * @return void + */ + private function storeConfig(string $desk, string $organisation, array $flows): void { + $this->appConfig->setValueString( + Application::APP_ID, + self::CONFIG_KEY, + (string) json_encode(['desk' => $desk, 'organisation' => $organisation, 'flows' => $flows, 'setUpAt' => date(DATE_ATOM)]) + ); + }//end storeConfig() + + /** + * A refused set-up, reported to the Integrations page. + * + * @param string $message What stopped it. + * @param array $blocking The preflight findings, by flow key. + * + * @return array The answer. + */ + private function refuse(string $message, array $blocking = []): array { + $this->connectionReports?->itsmSetUp(created: false, message: $message); + + return ['created' => false, 'message' => $message, 'blocking' => $blocking]; + }//end refuse() +}//end class diff --git a/lib/Service/ItsmFileImportService.php b/lib/Service/ItsmFileImportService.php new file mode 100644 index 000000000..7e53c9dba --- /dev/null +++ b/lib/Service/ItsmFileImportService.php @@ -0,0 +1,318 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import + * + * 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 OCA\Stackiq\Service\Itsm\ItsmFlowGateway; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Reads a spreadsheet and starts the file import flow with its rows. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ +class ItsmFileImportService { + + /** + * The most rows one import takes. + * + * @var integer + */ + public const MAX_ROWS = 5000; + + /** + * The largest file one import reads, in bytes (10 MiB, as the CMDB import). + * + * @var integer + */ + public const MAX_FILE_BYTES = 10485760; + + /** + * PhpSpreadsheet's XLSX reader, as OpenRegister ships it. + * + * @var string + */ + public const XLSX_READER = '\PhpOffice\PhpSpreadsheet\Reader\Xlsx'; + + /** + * The column every row must fill. + * + * @var string + */ + public const KEY_COLUMN = 'recordId'; + + /** + * Constructor. + * + * @param ItsmFlowGateway $gateway OpenRegister's flow store. + * @param IAppConfig $appConfig The app settings. + * @param LoggerInterface $logger Logs a flow that could not be started. + */ + public function __construct( + private readonly ItsmFlowGateway $gateway, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Import one file. + * + * @param string $path The uploaded file on disk. + * @param string $name The name it was uploaded with, which tells CSV from XLSX. + * + * @return array `started` with the run and the row count, or `started: false` with the reason. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import + */ + public function import(string $path, string $name): array { + $config = json_decode($this->appConfig->getValueString(Application::APP_ID, ItsmExchangeService::CONFIG_KEY, '{}'), true); + $flow = null; + if (is_array($config) === true) { + $flow = ($config['flows']['file'] ?? null); + } + if (is_string($flow) === false || $flow === '') { + return ['started' => false, 'message' => 'Set up the exchange first. The file import uses the flow the set-up creates.']; + } + + if (is_file($path) === true && filesize($path) > self::MAX_FILE_BYTES) { + return ['started' => false, 'message' => 'The file is larger than ' . (self::MAX_FILE_BYTES / 1048576) . ' MB; one import takes at most that.']; + } + + try { + $rows = $this->readRows(path: $path, name: $name); + } catch (Throwable $e) { + return ['started' => false, 'message' => 'The file could not be read: ' . $e->getMessage()]; + } + + $problem = $this->checkRows(rows: $rows); + if ($problem !== null) { + return ['started' => false, 'message' => $problem]; + } + + try { + $run = $this->gateway->run(uuid: $flow, payload: ['rows' => $rows]); + } catch (Throwable $e) { + $this->logger->error('[ItsmFileImportService] Starting the file import flow failed', ['exception' => $e]); + return ['started' => false, 'message' => 'The file import flow could not be started: ' . $e->getMessage()]; + } + + return ['started' => true, 'run' => $run, 'rows' => count($rows)]; + }//end import() + + /** + * Read the rows of a CSV or XLSX file, keyed by the header row. + * + * @param string $path The file. + * @param string $name Its name. + * + * Reading stops one row past MAX_ROWS, so an oversized file is refused + * without reading the rest of it. + * + * @return list> The rows, empty cells left out. + * + * @throws RuntimeException When the type is not CSV or XLSX, or XLSX cannot be read here. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import + */ + public function readRows(string $path, string $name): array { + $header = null; + $rows = []; + foreach ($this->readTable(path: $path, name: $name) as $cells) { + if ($header === null) { + $header = array_map(static fn ($cell): string => trim((string) $cell), $cells); + continue; + } + + if (count($rows) > self::MAX_ROWS) { + break; + } + + $row = []; + foreach ($header as $index => $column) { + $cell = trim((string) ($cells[$index] ?? '')); + if ($column !== '' && $cell !== '') { + $row[$column] = $cell; + } + } + + if ($row !== []) { + $rows[] = $row; + } + } + + return $rows; + }//end readRows() + + /** + * Read a file into rows of cells, by its extension. + * + * @param string $path The file. + * @param string $name Its name. + * + * @return iterable> The cells, row by row. + * + * @throws RuntimeException When the type is not CSV or XLSX. + */ + private function readTable(string $path, string $name): iterable { + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if ($extension === 'csv') { + return $this->readCsv(path: $path); + } + + if ($extension === 'xlsx') { + return $this->readXlsx(path: $path); + } + + throw new RuntimeException('only .csv and .xlsx files can be imported, not .' . $extension); + }//end readTable() + + /** + * Why the rows cannot be imported, or null when they can. + * + * @param list> $rows The rows. + * + * @return string|null The reason, naming the first bad row as the spreadsheet numbers it. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import + */ + public function checkRows(array $rows): ?string { + if ($rows === []) { + return 'The file holds no rows under its header.'; + } + + if (count($rows) > self::MAX_ROWS) { + return 'The file holds more than ' . self::MAX_ROWS . ' rows; one import takes at most ' . self::MAX_ROWS . '.'; + } + + foreach ($rows as $index => $row) { + if (($row[self::KEY_COLUMN] ?? '') === '') { + return 'Row ' . ($index + 2) . ' has no ' . self::KEY_COLUMN . '. Every row needs one, so a second import updates instead of adding.'; + } + } + + return null; + }//end checkRows() + + /** + * Read a CSV file into rows of cells. Comma or semicolon, whichever the header uses. + * + * @param string $path The file. + * + * @return \Generator> The cells, row by row; the file closes when reading stops. + */ + private function readCsv(string $path): \Generator { + $handle = fopen($path, 'r'); + if ($handle === false) { + throw new RuntimeException('cannot open the uploaded file'); + } + + try { + $first = (string) fgets($handle); + $delimiter = ','; + if (substr_count($first, ';') > substr_count($first, ',')) { + $delimiter = ';'; + } + + rewind($handle); + $isFirst = true; + while (($cells = fgetcsv($handle, null, $delimiter, '"', '\\')) !== false) { + $cells = array_map(static fn ($cell): string => (string) $cell, $cells); + if ($isFirst === true && isset($cells[0]) === true) { + $cells[0] = (string) preg_replace('/^\xEF\xBB\xBF/', '', $cells[0]); + } + + $isFirst = false; + yield $cells; + } + } finally { + fclose($handle); + } + }//end readCsv() + + /** + * Read the first sheet of an XLSX file into rows of cells, with PhpSpreadsheet as OpenRegister ships it. + * + * Always the XLSX reader, whatever the content looks like, with data only. + * A formula gives the value Excel cached; it is never calculated here. + * + * @param string $path The file. + * + * @return \Generator> The cells, row by row; the workbook is released when reading stops. + */ + private function readXlsx(string $path): \Generator { + $readerClass = self::XLSX_READER; + if (class_exists($readerClass) === false) { + throw new RuntimeException('reading .xlsx needs PhpSpreadsheet, which OpenRegister provides; save the sheet as .csv instead'); + } + + $reader = new $readerClass(); + $reader->setReadDataOnly(true); + $spreadsheet = $reader->load($path); + try { + foreach ($spreadsheet->getActiveSheet()->getRowIterator() as $row) { + $iterator = $row->getCellIterator(); + $iterator->setIterateOnlyExistingCells(false); + $cells = []; + foreach ($iterator as $cell) { + $cells[] = self::cellText(cell: $cell); + } + + yield $cells; + } + } finally { + $spreadsheet->disconnectWorksheets(); + } + }//end readXlsx() + + /** + * The text of one XLSX cell; for a formula, the value Excel cached. + * + * @param object $cell The PhpSpreadsheet cell. + * + * @return string The text, or an empty string for a value that is not text or a number. + */ + private static function cellText(object $cell): string { + $value = $cell->getValue(); + if ($cell->getDataType() === 'f') { + $value = $cell->getOldCalculatedValue(); + } + + if (is_object($value) === true && method_exists($value, 'getPlainText') === true) { + return (string) $value->getPlainText(); + } + + if (is_scalar($value) === false) { + return ''; + } + + return (string) $value; + }//end cellText() +}//end class diff --git a/lib/Service/MaintenanceAnnouncerCheck.php b/lib/Service/MaintenanceAnnouncerCheck.php new file mode 100644 index 000000000..ac8e68feb --- /dev/null +++ b/lib/Service/MaintenanceAnnouncerCheck.php @@ -0,0 +1,138 @@ + + * @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 OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IGroupManager; + +/** + * Whether a maintenance window comes from someone allowed to reach the product's owners. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ +class MaintenanceAnnouncerCheck { + + /** + * The groups whose members may announce maintenance on any product. + * + * @var array + */ + private const CATALOG_ADMIN_GROUPS = ['admin', 'software-catalog-admins']; + + /** + * Constructor. + * + * @param SettingsService $settingsService The module register and schema lookups. + * @param IGroupManager $groupManager The group manager, for the catalogue's administrators. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly IGroupManager $groupManager, + ) { + }//end __construct() + + /** + * Whether a window may notify the owners of its product. + * + * Yes when a catalogue administrator created it, or when the organisation + * that owns the window is the product's supplier (`provider`). The + * organisation that owns the product record never counts, not even for a + * product that names no provider: a product entered by an administrator or + * an import carries the importer's organisation, often the default one, and + * a supplier without an organisation of its own works under that same + * default organisation. A product without a provider is therefore announced + * by a catalogue administrator, or once its supplier is set. A product that no longer exists refuses; a read that fails for any + * other reason is thrown, so the caller writes nothing rather than treating + * a real supplier's window as refused. + * + * @param ObjectServiceInterface $objectService OpenRegister's object service. + * @param ObjectEntityInterface $window The maintenance window. + * @param string $moduleId The product's id. + * + * @return boolean True when the owners may be notified. + * + * @throws \Throwable When the product cannot be read for a reason other than that it does not exist. + * + * @spec openspec/specs/maintenance-and-supplier-roadmap/spec.md#requirement-req-msr-003-the-owners-of-every-usage-are-notified + */ + public function mayAnnounce(ObjectServiceInterface $objectService, ObjectEntityInterface $window, string $moduleId): bool { + if ($this->isCatalogAdmin(uid: (string) $window->getOwner()) === true) { + return true; + } + + $organisation = (string) $window->getOrganisation(); + if ($organisation === '') { + return false; + } + + try { + $module = $objectService->find( + id: $moduleId, + register: $this->settingsService->getRegisterIdForObjectType('module'), + schema: $this->settingsService->getSchemaIdForObjectType('module'), + _rbac: false, + _multitenancy: false + ); + } catch (DoesNotExistException $e) { + return false; + } + + if ($module === null) { + return false; + } + + $provider = ($module->getObject()['provider'] ?? null); + if (is_array($provider) === true) { + $provider = ($provider['id'] ?? ($provider['uuid'] ?? null)); + } + + if (is_string($provider) === false || $provider === '') { + return false; + } + + return $organisation === $provider; + }//end mayAnnounce() + + /** + * Whether a user is a Nextcloud or catalogue administrator. + * + * @param string $uid The user id, or an empty string for none. + * + * @return boolean True for a member of an administrator group. + */ + private function isCatalogAdmin(string $uid): bool { + if ($uid === '') { + return false; + } + + foreach (self::CATALOG_ADMIN_GROUPS as $group) { + if ($this->groupManager->isInGroup($uid, $group) === true) { + return true; + } + } + + return false; + }//end isCatalogAdmin() +}//end class diff --git a/lib/Service/MaintenanceRecipientService.php b/lib/Service/MaintenanceRecipientService.php new file mode 100644 index 000000000..e9d7c2c77 --- /dev/null +++ b/lib/Service/MaintenanceRecipientService.php @@ -0,0 +1,355 @@ + + * @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. + * @param MaintenanceAnnouncerCheck $announcers Who may have a product's owners notified. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly StackiqContactSyncService $contacts, + private readonly IUserManager $userManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + private readonly MaintenanceAnnouncerCheck $announcers, + ) { + }//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 recorded, or null when no owners were recorded. + * + * @throws \Throwable When the window or the product cannot be read (DoesNotExistException + * for a window that is gone), or the window cannot be written; the job + * decides whether to try again. + * + * @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; + } + + $window = $objectService->find(id: $uuid, register: $register, schema: $schema, _rbac: false, _multitenancy: false); + 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. + * + * Only the supplier of the product, or a catalogue administrator, may have + * its owners notified. A window from any other organisation is refused, + * whether or not it carries a resolved time: its `notifyUserIds` is + * written back empty, so neither the announcement nor the reminder a day + * before (`maintenance-starts-tomorrow`) reaches anyone it names. + * + * @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 no owners were recorded. + * + * @throws \Throwable When the product or the owners cannot be read, or the window cannot be 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(); + $moduleId = self::referenceId(value: ($data['module'] ?? null)); + $objectService = $this->getObjectService(); + if ($moduleId === null || $objectService === null) { + return null; + } + + if ($this->announcers->mayAnnounce(objectService: $objectService, window: $window, moduleId: $moduleId) === false) { + $this->logger->warning( + 'MaintenanceRecipientService: the window is not from the supplier of the product; no owners are notified', + ['uuid' => $window->getUuid(), 'module' => $moduleId, 'organisation' => $window->getOrganisation()] + ); + if (empty($data['notifyUserIds']) === false) { + $data['notifyUserIds'] = []; + $this->saveWindow(objectService: $objectService, window: $window, data: $data); + } + + return null; + } + + if (empty($data['recipientsResolvedAt']) === false) { + return null; + } + + $userIds = $this->ownerUserIds(objectService: $objectService, moduleId: $moduleId); + + $data['notifyUserIds'] = $userIds; + $data['recipientsResolvedAt'] = ($now ?? new DateTimeImmutable())->format(DateTimeInterface::ATOM); + $this->saveWindow(objectService: $objectService, window: $window, data: $data); + + return $userIds; + }//end recordRecipients() + + /** + * Write a maintenance window back, without RBAC: the two notification fields + * are writable only by the catalogue's administrators over the API. + * + * @param ObjectServiceInterface $objectService OpenRegister's object service. + * @param ObjectEntityInterface $window The window. + * @param array $data Its data to store. + * + * @return void + * + * @throws \Throwable When OpenRegister refuses or fails the write. + */ + private function saveWindow(ObjectServiceInterface $objectService, ObjectEntityInterface $window, array $data): void { + $objectService->saveObject( + object: $data, + extend: [], + register: $window->getRegister(), + schema: $window->getSchema(), + uuid: $window->getUuid(), + _rbac: false, + _multitenancy: false + ); + }//end saveWindow() + + /** + * 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 []; + } + + // A failed read is thrown, not taken as "nobody to notify": the job tries + // again, while an empty list would be recorded as resolved for good. + $people = $objectService->searchObjects( + query: ['register' => $register, 'schema' => $schema, '_limit' => count($contactIds)], + _rbac: false, + _multitenancy: false, + ids: $contactIds + ); + + $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 []; + } + + $usages = $objectService->searchObjects( + query: ['register' => $register, 'schema' => $schema, 'module' => $moduleId, '_limit' => self::USAGE_LIMIT], + _rbac: false, + _multitenancy: false + ); + + $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/ModuleVersionPublicationService.php b/lib/Service/ModuleVersionPublicationService.php new file mode 100644 index 000000000..af09b6f8b --- /dev/null +++ b/lib/Service/ModuleVersionPublicationService.php @@ -0,0 +1,435 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\Stackiq\BackgroundJob\ModuleVersionPublicationJob; +use OCP\BackgroundJob\IJobList; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Copies a module's publication onto its versions. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ +class ModuleVersionPublicationService { + + /** + * How many versions one search reads; a module with more is read page by page. + * + * @var integer + */ + public const VERSION_LIMIT = 500; + + /** + * Constructor. + * + * @param SettingsService $settingsService Resolves the module and moduleVersion schemas. + * @param ContainerInterface $container Resolves OpenRegister's object service. + * @param LoggerInterface $logger The logger. + * @param IJobList $jobList Queues the copy onto a module's versions. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + private readonly IJobList $jobList, + ) { + }//end __construct() + + /** + * The two fields a version mirrors from its module. + * + * @param array $module The module's data. + * + * @return array{modulePublicationDate: string|null, moduleRegisteredBy: string|null} + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public static function mirrorOf(array $module): array { + return [ + 'modulePublicationDate' => self::text(value: ($module['publicationDate'] ?? null)), + 'moduleRegisteredBy' => self::text(value: ($module['registeredBy'] ?? null)), + ]; + }//end mirrorOf() + + /** + * A non-empty string, or null. + * + * @param mixed $value The value. + * + * @return string|null The string, or null when it is empty or not a string. + */ + private static function text(mixed $value): ?string { + if (is_string($value) === false || $value === '') { + return null; + } + + return $value; + }//end text() + + /** + * React to a saved object: a module queues the update of its versions, a version reads its module. + * + * A module update that leaves its publication date and registrant as they + * were has nothing to copy, so nothing is queued. + * + * @param ObjectEntityInterface $object The saved object. + * @param ObjectEntityInterface|null $previous The object before an update, or null for a new one. + * + * @return integer The number of versions written in this request: 0 for a module, whose versions the job writes. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public function objectSaved(ObjectEntityInterface $object, ?ObjectEntityInterface $previous = null): int { + $schema = (string) $object->getSchema(); + if ($schema === (string) $this->settingsService->getSchemaIdForObjectType('module')) { + if ($previous !== null + && self::mirrorOf(module: (array) $previous->getObject()) === self::mirrorOf(module: (array) $object->getObject()) + ) { + return 0; + } + + $this->queueVersions(moduleUuid: (string) $object->getUuid(), deleted: false); + return 0; + } + + if ($schema === (string) $this->settingsService->getSchemaIdForObjectType('moduleVersion')) { + return $this->versionSaved(version: $object); + } + + return 0; + }//end objectSaved() + + /** + * React to a deleted object: the versions of a deleted module stop following a publication. + * + * @param ObjectEntityInterface $object The deleted object. + * + * @return integer The number of versions written in this request: always 0, the job writes them. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public function objectDeleted(ObjectEntityInterface $object): int { + if ((string) $object->getSchema() !== (string) $this->settingsService->getSchemaIdForObjectType('module')) { + return 0; + } + + $this->queueVersions(moduleUuid: (string) $object->getUuid(), deleted: true); + return 0; + }//end objectDeleted() + + /** + * Queue the copy of a module's publication onto its versions. + * + * The job list keeps one entry per argument, so a module saved twice + * before cron runs is copied once. + * + * @param string $moduleUuid The module. + * @param boolean $deleted Whether the module was deleted. + * + * @return void + */ + private function queueVersions(string $moduleUuid, bool $deleted): void { + $this->jobList->add(ModuleVersionPublicationJob::class, ['module' => $moduleUuid, 'deleted' => $deleted]); + }//end queueVersions() + + /** + * Take every version of a deleted module out of public view. + * + * @param string $moduleUuid The module. + * + * @return array{written: int, failed: int} The versions written, and the versions or searches that failed. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public function clearVersions(string $moduleUuid): array { + return $this->copyOntoVersions(moduleUuid: $moduleUuid, mirror: self::mirrorOf(module: [])); + }//end clearVersions() + + /** + * Copy a module's publication onto its versions and say what could not be copied. + * + * @param ObjectEntityInterface $module The module. + * + * @return array{written: int, failed: int} The versions written, and the versions or searches that failed. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public function backfillModule(ObjectEntityInterface $module): array { + return $this->copyOntoVersions(moduleUuid: (string) $module->getUuid(), mirror: self::mirrorOf(module: (array) $module->getObject())); + }//end backfillModule() + + /** + * Copy a mirror onto every version of a module that differs, page by page. + * + * @param string $moduleUuid The module. + * @param array{modulePublicationDate: string|null, moduleRegisteredBy: string|null} $mirror The values to hold. + * + * @return array{written: int, failed: int} The versions written, and the versions or searches that failed. + */ + private function copyOntoVersions(string $moduleUuid, array $mirror): array { + $objects = $this->objectService(); + $register = $this->settingsService->getRegisterIdForObjectType('moduleVersion'); + $schema = $this->settingsService->getSchemaIdForObjectType('moduleVersion'); + $result = ['written' => 0, 'failed' => 0]; + if ($objects === null || $register === null || $schema === null) { + return $result; + } + + $offset = 0; + do { + try { + $versions = (array) $objects->searchObjects( + query: [ + 'register' => $register, + 'schema' => $schema, + 'module' => $moduleUuid, + '_limit' => self::VERSION_LIMIT, + '_offset' => $offset, + ], + _rbac: false, + _multitenancy: false + ); + } catch (Throwable $e) { + $this->logFailure( + message: 'ModuleVersionPublicationService: could not read the versions', + context: ['module' => $moduleUuid, 'error' => $e->getMessage()], + depublishes: (self::isPublicNow(mirror: $mirror) === false) + ); + $result['failed']++; + return $result; + } + + $page = $this->writeVersions(objects: $objects, versions: $versions, mirror: $mirror); + $result['written'] += $page['written']; + $result['failed'] += $page['failed']; + + $offset += self::VERSION_LIMIT; + $pageSize = count($versions); + } while ($pageSize === self::VERSION_LIMIT); + + return $result; + }//end copyOntoVersions() + + /** + * Write a mirror onto each version of one page. + * + * @param ObjectServiceInterface $objects The object service. + * @param array $versions The page. + * @param array{modulePublicationDate: string|null, moduleRegisteredBy: string|null} $mirror The values to hold. + * + * @return array{written: int, failed: int} The versions written and the writes that failed. + */ + private function writeVersions(ObjectServiceInterface $objects, array $versions, array $mirror): array { + $result = ['written' => 0, 'failed' => 0]; + foreach ($versions as $version) { + if (($version instanceof ObjectEntityInterface) === false) { + continue; + } + + $outcome = $this->write(objects: $objects, version: $version, mirror: $mirror); + if ($outcome === true) { + $result['written']++; + } + + if ($outcome === null) { + $result['failed']++; + } + } + + return $result; + }//end writeVersions() + + /** + * Whether a version holding this mirror is public now, by the moduleVersion read rule. + * + * @param array $mirror The mirrored fields. + * + * @return boolean True when an anonymous reader may read it. + */ + private static function isPublicNow(array $mirror): bool { + if (($mirror['moduleRegisteredBy'] ?? null) === 'Supplier') { + return true; + } + + $date = ($mirror['modulePublicationDate'] ?? null); + if (is_string($date) === false || $date === '') { + return false; + } + + $time = strtotime($date); + + return $time !== false && $time <= time(); + }//end isPublicNow() + + /** + * Log a mirror that could not be written: critical when it leaves a version public that should not be. + * + * @param string $message The message. + * @param array $context The context. + * @param boolean $depublishes Whether the write would have taken a version out of public view. + * + * @return void + */ + private function logFailure(string $message, array $context, bool $depublishes): void { + if ($depublishes === true) { + $this->logger->critical($message . '; the version stays public until a later copy onto it succeeds', $context); + return; + } + + $this->logger->error($message, $context); + }//end logFailure() + + /** + * Copy the module's publication onto a saved version, when it differs. + * + * @param ObjectEntityInterface $version The saved version. + * + * @return integer 1 when the version was written, else 0. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-002-a-module-version-is-public-only-while-its-application-is + */ + public function versionSaved(ObjectEntityInterface $version): int { + $objects = $this->objectService(); + $moduleId = self::referenceOf(value: (((array) $version->getObject())['module'] ?? null)); + if ($objects === null || $moduleId === null) { + return 0; + } + + try { + $module = $objects->find( + id: $moduleId, + register: $this->settingsService->getRegisterIdForObjectType('module'), + schema: $this->settingsService->getSchemaIdForObjectType('module'), + _rbac: false, + _multitenancy: false + ); + } catch (Throwable $e) { + $module = null; + } + + $data = []; + if ($module !== null) { + $data = (array) $module->getObject(); + } + + if ($this->write(objects: $objects, version: $version, mirror: self::mirrorOf(module: $data)) === true) { + return 1; + } + + return 0; + }//end versionSaved() + + /** + * The id a reference holds: a uuid string, or an object with `id` or `uuid`. + * + * @param mixed $value The reference. + * + * @return string|null The id, or null when there is none. + */ + private static function referenceOf(mixed $value): ?string { + if (is_array($value) === true) { + $value = ($value['id'] ?? ($value['uuid'] ?? null)); + } + + return self::text(value: $value); + }//end referenceOf() + + /** + * Write the mirror onto a version when it differs from what the version holds. + * + * @param ObjectServiceInterface $objects The object service. + * @param ObjectEntityInterface $version The version. + * @param array{modulePublicationDate: string|null, moduleRegisteredBy: string|null} $mirror The values to hold. + * + * The version is saved without validation: only the two mirrored fields + * change, and a version holding older data the schema no longer accepts + * must still follow its module. + * + * @return boolean|null True when it was written, false when it was in step, null when the write failed. + */ + private function write(ObjectServiceInterface $objects, ObjectEntityInterface $version, array $mirror): ?bool { + $data = (array) $version->getObject(); + if (($data['modulePublicationDate'] ?? null) === $mirror['modulePublicationDate'] + && ($data['moduleRegisteredBy'] ?? null) === $mirror['moduleRegisteredBy'] + ) { + return false; + } + + try { + $objects->saveObject( + object: array_merge($data, $mirror), + extend: [], + register: $version->getRegister(), + schema: $version->getSchema(), + uuid: $version->getUuid(), + _rbac: false, + _multitenancy: false, + _validation: false + ); + } catch (Throwable $e) { + $this->logFailure( + message: 'ModuleVersionPublicationService: could not copy the publication onto a version', + context: ['uuid' => $version->getUuid(), 'error' => $e->getMessage()], + depublishes: (self::isPublicNow(mirror: $data) === true && self::isPublicNow(mirror: $mirror) === false) + ); + return null; + } + + return true; + }//end write() + + /** + * Lazily resolve OpenRegister's object service. + * + * @return ObjectServiceInterface|null The service, or null when OpenRegister is absent. + */ + private function objectService(): ?ObjectServiceInterface { + try { + $service = $this->container->get(ObjectServiceInterface::class); + if ($service instanceof ObjectServiceInterface) { + return $service; + } + } catch (Throwable $e) { + $this->logger->debug('ModuleVersionPublicationService: ObjectService not resolvable', ['error' => $e->getMessage()]); + } + + return null; + }//end objectService() +}//end class 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..cf3bc750d 100644 --- a/lib/Service/PortfolioReportService.php +++ b/lib/Service/PortfolioReportService.php @@ -151,6 +151,30 @@ public function buildReport(string $organisationUuid): array { ]; }//end buildReport() + /** + * A cell a spreadsheet opens as text, never as a formula. + * + * Text that starts with `=`, `+`, `-`, `@`, a tab or a carriage return is + * run as a formula by Excel and LibreOffice when the CSV is opened. Such a + * cell gets a leading apostrophe, so it shows as the text it is. A plain + * number (a negative cost) is left as it is. Module names and rationales + * can come from an imported third-party file, so every cell goes through + * this. + * + * @param string $value The cell. + * + * @return string + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report + */ + public static function csvSafeCell(string $value): string { + if ($value === '' || is_numeric($value) === true || in_array($value[0], ['=', '+', '-', '@', "\t", "\r"], true) === false) { + return $value; + } + + return "'" . $value; + }//end csvSafeCell() + /** * Build the CSV export of the same bounded, organisation-scoped row set * the JSON report uses — never a separate unbounded/unscoped data path. @@ -181,24 +205,39 @@ public function buildCsv(string $organisationUuid): string { 'hostingModel', 'annualisedCost', 'oneOffCost', + 'businessValue', + 'technicalFit', + 'riskScore', + 'scoredOn', + 'suggestedTimeClassification', + 'timeMismatch', ] ); foreach ($built['rows'] as $row) { fputcsv( $handle, - [ - $organisationUuid, - $row['moduleName'], - $row['timeClassification'] ?? '', - $row['timeRationale'] ?? '', - $row['timeReviewDate'] ?? '', - $row['lifecyclePhase'], - $this->derivation->eolStatusLabel(row: $row), - implode('|', $row['hostingModel']), - (string)$row['annualisedCost'], - (string)$row['oneOffCost'], - ] + array_map( + static fn ($cell): string => self::csvSafeCell(value: (string)$cell), + [ + $organisationUuid, + $row['moduleName'], + $row['timeClassification'] ?? '', + $row['timeRationale'] ?? '', + $row['timeReviewDate'] ?? '', + $row['lifecyclePhase'], + $this->derivation->eolStatusLabel(row: $row), + 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 +348,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/ProgressStore.php b/lib/Service/ProgressStore.php new file mode 100644 index 000000000..c9e2c24ed --- /dev/null +++ b/lib/Service/ProgressStore.php @@ -0,0 +1,325 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: 1.0.0 + * @link https://github.com/ConductionNL/stackiq + * + * @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 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use OCA\Stackiq\AppInfo\Application; +use OCP\IAppConfig; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; + +/** + * Entries that live STORE_TTL seconds and that every request can read. + * + * The store is Nextcloud's distributed cache when every server and the CLI + * share it (Redis, Memcached). Without such a cache (no memcache at all, or + * only APCu, which each node and the CLI keep to themselves) the app config + * stands in: the database every request reads. Writes of a running operation + * there are spaced out to one per second, cancel requests are read at most + * once a second, and every read goes to the database rather than to the + * config cache of the reading request. + * + * That read costs a reload of the whole app config, lazy values of every app + * included (a normal request loads only the non-lazy rows), and with APCu it + * drops the node's cached copy. A progress stream does it once a second; a + * running import about twice a second, as the write after a cancel read + * reloads it again. That is accepted as the price of a store every request + * sees, on instances that run without a shared cache; a dedicated table is + * the alternative if it ever shows up. + * + * @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 + */ +class ProgressStore { + + /** + * How long an entry lives after its last write, in seconds. + */ + public const STORE_TTL = 3600; + + /** + * Cache classes not shared between servers or between the web server and the CLI. + */ + private const UNSHARED_CACHES = [ + '', + 'OC\Memcache\APCu', + 'OC\Memcache\ArrayCache', + 'OC\Memcache\NullCache', + ]; + + /** + * Key prefix of the app config entries that stand in for the cache. + */ + private const CONFIG_PREFIX = 'op_'; + + /** + * Least number of seconds between two app config writes or cancel reads of one operation. + */ + private const CONFIG_INTERVAL = 1; + + /** + * The shared cache, or null when the app config stands in for it. + * + * @var ICache|null + */ + private ?ICache $cache = null; + + /** + * Per operation: when this request last wrote its snapshot to the app config, and in which phase and status. + * + * @var array + */ + private array $lastWrites = []; + + /** + * Per operation: the cancel answer this request last read from the app config. + * + * @var array + */ + private array $cancelChecks = []; + + /** + * Constructor. + * + * @param ICacheFactory $cacheFactory The cache factory. + * @param IConfig $config System config, which names the distributed cache class. + * @param IAppConfig $appConfig App config, the store when no shared cache is configured. + * + * @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( + ICacheFactory $cacheFactory, + IConfig $config, + private readonly IAppConfig $appConfig, + ) { + if (self::hasSharedCache(cacheFactory: $cacheFactory, config: $config) === true) { + $this->cache = $cacheFactory->createDistributed(prefix: 'stackiq_progress'); + } + }//end __construct() + + /** + * Whether the distributed cache is one every server and the CLI share. + * + * Nextcloud falls back to the local cache class when `memcache.distributed` + * is not set, and to a cache that keeps nothing when no memcache is set at all. + * + * @param ICacheFactory $cacheFactory The cache factory. + * @param IConfig $config The system config. + * + * @return bool True for a shared cache such as Redis or Memcached. + */ + private static function hasSharedCache(ICacheFactory $cacheFactory, IConfig $config): bool { + if ($cacheFactory->isAvailable() === false) { + return false; + } + + $class = $config->getSystemValueString('memcache.distributed', ''); + if ($class === '') { + $class = $config->getSystemValueString('memcache.local', ''); + } + + return in_array(ltrim($class, '\\'), self::UNSHARED_CACHES, true) === false; + }//end hasSharedCache() + + /** + * Store an operation's snapshot. + * + * In the app config a running operation that stays in the same phase is + * written at most once a second; a new phase or status is always written. + * + * @param string $operationId The operation. + * @param array $progress The snapshot. + * + * @return void + * + * @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 setProgress(string $operationId, array $progress): void { + if ($this->cache === null) { + $state = ($progress['status'] ?? '') . '/' . ($progress['phase'] ?? ''); + $last = ($this->lastWrites[$operationId] ?? null); + if ($last !== null && ($progress['status'] ?? null) === 'running' && $last['state'] === $state && time() - $last['at'] < self::CONFIG_INTERVAL) { + return; + } + + $this->lastWrites[$operationId] = ['at' => time(), 'state' => $state]; + } + + $this->set(key: 'progress_' . $operationId, value: $progress); + }//end setProgress() + + /** + * Read an operation's snapshot. + * + * @param string $operationId The operation. + * + * @return array|null The snapshot, or null when there is none or it expired. + * + * @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): ?array { + $progress = $this->get(key: 'progress_' . $operationId); + if (is_array($progress) === true) { + return $progress; + } + + return null; + }//end getProgress() + + /** + * Record a cancel request for an operation. + * + * @param string $operationId The operation. + * + * @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 requestCancel(string $operationId): void { + $this->set(key: 'cancel_' . $operationId, value: true); + }//end requestCancel() + + /** + * Whether a cancel was requested for an operation. + * + * An import asks between every few rows; the app config is read at most + * once a second per operation, and a cancel once seen stays seen. + * + * @param string $operationId The operation. + * + * @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 { + if ($this->cache !== null) { + return $this->cache->get(key: 'cancel_' . $operationId) === true; + } + + $last = ($this->cancelChecks[$operationId] ?? null); + if ($last !== null && ($last['cancelled'] === true || time() - $last['at'] < self::CONFIG_INTERVAL)) { + return $last['cancelled']; + } + + $cancelled = $this->get(key: 'cancel_' . $operationId) === true; + $this->cancelChecks[$operationId] = ['at' => time(), 'cancelled' => $cancelled]; + + return $cancelled; + }//end isCancelRequested() + + /** + * Forget the cancel request of an operation that stopped. + * + * @param string $operationId The operation. + * + * @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 clearCancel(string $operationId): void { + unset($this->cancelChecks[$operationId]); + if ($this->cache !== null) { + $this->cache->remove(key: 'cancel_' . $operationId); + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, self::configKey(key: 'cancel_' . $operationId)); + }//end clearCancel() + + /** + * Delete app config entries whose time is up; the cache drops its own. + * + * @return void + * + * @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 removeExpired(): void { + if ($this->cache !== null) { + return; + } + + foreach ($this->appConfig->searchKeys(Application::APP_ID, self::CONFIG_PREFIX, lazy: true) as $configKey) { + $entry = $this->appConfig->getValueArray(Application::APP_ID, $configKey, [], lazy: true); + if ((int) ($entry['expires'] ?? 0) < time()) { + $this->appConfig->deleteKey(Application::APP_ID, $configKey); + } + } + }//end removeExpired() + + /** + * Read an entry. + * + * The app config is read from the database, not from this request's config + * cache, so a value another request wrote a moment ago is seen. + * + * @param string $key The entry. + * + * @return mixed The value, or null when there is none or it expired. + */ + private function get(string $key): mixed { + if ($this->cache !== null) { + return $this->cache->get(key: $key); + } + + $this->appConfig->clearCache(); + $entry = $this->appConfig->getValueArray(Application::APP_ID, self::configKey(key: $key), [], lazy: true); + if ((int) ($entry['expires'] ?? 0) < time()) { + return null; + } + + return ($entry['value'] ?? null); + }//end get() + + /** + * Write an entry that lives STORE_TTL seconds. + * + * @param string $key The entry. + * @param mixed $value The value. + * + * @return void + */ + private function set(string $key, mixed $value): void { + if ($this->cache !== null) { + $this->cache->set(key: $key, value: $value, ttl: self::STORE_TTL); + return; + } + + $this->appConfig->setValueArray( + Application::APP_ID, + self::configKey(key: $key), + ['expires' => time() + self::STORE_TTL, 'value' => $value], + lazy: true + ); + }//end set() + + /** + * The app config key of an entry, hashed: an operation id may be longer than a key may be (64). + * + * @param string $key The entry, `progress_` or `cancel_`. + * + * @return string The app config key. + */ + private static function configKey(string $key): string { + [$kind, $operationId] = array_pad(explode('_', $key, 2), 2, ''); + + return self::CONFIG_PREFIX . $kind . '_' . sha1($operationId); + }//end configKey() +}//end class diff --git a/lib/Service/ProgressTracker.php b/lib/Service/ProgressTracker.php index 3e90f6422..b0c9ce229 100644 --- a/lib/Service/ProgressTracker.php +++ b/lib/Service/ProgressTracker.php @@ -19,12 +19,27 @@ namespace OCA\Stackiq\Service; -use OCP\ISession; +use OCP\IAppConfig; +use OCP\ICacheFactory; +use OCP\IConfig; +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. Without a cache every + * server and the CLI share, ProgressStore keeps it in the app config instead. + * Who may read an operation is decided by SettingsController::getProgress(), + * not by where it is stored. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) Each public method is one step of an + * operation's life (start, phase, progress, warning, error, statistics, complete, fail, + * cancel, clear a cancel) that an import calls on the same snapshot; splitting them would hand that + * snapshot from class to class. + * * @category Service * @package OCA\Stackiq\Service * @author Conduction b.v. @@ -73,16 +88,32 @@ class ProgressTracker { 'statistics' => [], ]; + /** + * The shared store for progress snapshots and cancel requests. + * + * @var ProgressStore + */ + private ProgressStore $store; + /** * Constructor for ProgressTracker * - * @param ISession $session The session service for storing progress + * @param ICacheFactory $cacheFactory Cache factory; progress goes into its distributed cache when that is shared + * @param IUserSession $userSession The signed-in user, the default owner of a new operation * @param LoggerInterface $logger The logger interface + * @param IConfig $config System config, which names the distributed cache class + * @param IAppConfig $appConfig App config, the store when no shared cache is configured + * + * @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, + IConfig $config, + IAppConfig $appConfig, ) { + $this->store = new ProgressStore(cacheFactory: $cacheFactory, config: $config, appConfig: $appConfig); }//end __construct() /** @@ -90,19 +121,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, @@ -117,6 +164,7 @@ public function startOperation(string $operationType, array $options = [], ?stri 'statistics' => $options['statistics'] ?? [], ]; + $this->store->removeExpired(); $this->saveProgress(); $this->logger->info( @@ -298,6 +346,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 +369,107 @@ public function completeOperation(array $finalStatistics = []): void { ); }//end completeOperation() + /** + * Mark the current operation as failed, keeping the percentage it reached. + * + * A page following the operation then sees it stop as failed rather than + * as running until the snapshot expires, or as completed at 100%. + * + * @param string $message Why the operation failed + * + * @return void + * + * @spec openspec/specs/progress-tracking/spec.md + */ + public function failOperation(string $message): void { + $this->progress['errors'][] = [ + 'message' => $message, + 'context' => [], + 'timestamp' => time(), + ]; + $this->progress['phase_description'] = 'Failed'; + $this->progress['status'] = 'failed'; + $this->progress['estimated_completion'] = time(); + $this->saveProgress(); + + if ($this->progress['operation_id'] !== null) { + $this->store->clearCancel(operationId: $this->progress['operation_id']); + } + + $this->logger->error( + 'Operation failed', + [ + 'operation_id' => $this->progress['operation_id'], + 'message' => $message, + ] + ); + }//end failOperation() + + /** + * 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->requestCancel(operationId: $operationId); + }//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->isCancelRequested(operationId: $operationId); + }//end isCancelRequested() + + /** + * Drop a cancel request for an operation, from any request. + * + * For a caller that reuses an id: a cancel that arrived after the previous + * run's last check would otherwise stop the next run before its first item. + * + * @param string $operationId The operation whose cancel request goes + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function clearCancelRequested(string $operationId): void { + $this->store->clearCancel(operationId: $operationId); + }//end clearCancelRequested() + + /** + * 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->clearCancel(operationId: $this->progress['operation_id']); + } + }//end cancelOperation() + /** * Get current progress state * @@ -327,18 +477,12 @@ 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) { - return $storedProgress; - } - - return null; + // Load an operation another request or a background job wrote. + return $this->store->getProgress(operationId: $operationId); } if ($this->progress['operation_id'] !== null) { @@ -405,19 +549,24 @@ private function calculateEstimatedCompletion(): ?int { }//end calculateEstimatedCompletion() /** - * Save progress to session + * Save progress to the shared store. + * + * Each write renews the entry for ProgressStore::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->setProgress(operationId: $this->progress['operation_id'], progress: $this->progress); } }//end saveProgress() /** - * Clean up old progress entries from session + * Clean up old progress entries. + * + * Nothing to do: every entry in the shared store expires + * ProgressStore::STORE_TTL seconds after its last write, and app config + * entries are removed when the next operation starts. * * @param int $maxAge Maximum age in seconds (default: 1 hour) * @@ -425,8 +574,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..b8fa30d18 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. @@ -1675,7 +1677,10 @@ public function loadSettings(bool $force = false): array { continue; } - $softwareCatalogSettings = self::deepMergeConfig(base: $softwareCatalogSettings, overlay: $fragmentData); + $softwareCatalogSettings = self::keepHighestSchemaVersions( + before: $softwareCatalogSettings, + after: self::deepMergeConfig(base: $softwareCatalogSettings, overlay: $fragmentData) + ); $fragmentSig .= basename($fragmentFile) . ':' . md5($fragmentContent) . ';'; } }//end if @@ -2099,13 +2104,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 +4229,7 @@ private function configureVoorzieningen(): array { 'moduleVersion' => 'moduleVersie_schema', 'sector' => 'sector_schema', 'sbomComponent' => 'sbomComponent_schema', + 'maintenanceWindow' => 'maintenanceWindow_schema', ]; $config = [ 'register' => (string)($targetRegister['id'] ?? '') ]; @@ -4641,6 +4663,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 +5212,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 +6894,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. * @@ -7298,6 +7348,35 @@ public function setEolSyncStatus(array $status): void { $this->config->setValueString($this->appName, self::EOL_SYNC_STATUS_KEY, json_encode($status)); }//end setEolSyncStatus() + /** + * After a fragment merge, give every schema the highest version any file declared. + * + * A fragment's scalar `version` overwrites the one before it, so with plain + * merging the fragment that sorts LAST decides a schema's version. A later + * fragment that raised a version lost it to an earlier-named one that set a + * lower number, and OpenRegister skips a schema whose version did not go up, + * so the later fragment's properties never deployed. The highest number wins + * instead, whatever the file names. + * + * @param array $before The register before this fragment. + * @param array $after The register after merging it. + * + * @return array The merged register with the highest version per schema. + * + * @spec openspec/changes/publication-field-rules/specs/publication-field-rules/spec.md#requirement-req-pfr-003-a-fragment-never-lowers-a-schema-version + */ + private static function keepHighestSchemaVersions(array $before, array $after): array { + foreach (($after['components']['schemas'] ?? []) as $key => $schema) { + $previous = (string) ($before['components']['schemas'][$key]['version'] ?? ''); + $current = (string) ($schema['version'] ?? ''); + if ($previous !== '' && ($current === '' || version_compare($previous, $current, '>') === true)) { + $after['components']['schemas'][$key]['version'] = $previous; + } + } + + return $after; + }//end keepHighestSchemaVersions() + /** * Deep-merge a register fragment onto the base config (ADR-037). * 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/StackiqContactSyncService.php b/lib/Service/StackiqContactSyncService.php index 617cdac3b..2c09cd6fb 100644 --- a/lib/Service/StackiqContactSyncService.php +++ b/lib/Service/StackiqContactSyncService.php @@ -29,7 +29,10 @@ use OCP\Constants; use OCP\Contacts\IManager as IContactsManager; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Throwable; /** * Search, import and create Nextcloud contacts for stackiq @@ -50,15 +53,26 @@ * guard. Both are breadth of mapping rather than depth of logic. */ class StackiqContactSyncService { + /** + * Nextcloud's CardDAV backend, which creates an address book (not part of OCP). + */ + public const CARDDAV_BACKEND_CLASS = 'OCA\DAV\CardDAV\CardDavBackend'; + /** * Constructor. * * @param IContactsManager $contactsManager The Nextcloud contacts manager. * @param LoggerInterface $logger The logger. + * @param ContainerInterface|null $container Resolves the CardDAV backend, for a named address book. + * @param IUserSession|null $userSession The user whose named address book is written. + * + * @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md */ public function __construct( private readonly IContactsManager $contactsManager, private readonly LoggerInterface $logger, + private readonly ?ContainerInterface $container = null, + private readonly ?IUserSession $userSession = null, ) { }//end __construct() @@ -162,6 +176,276 @@ public function syncToContacts(string $objectType, array $record): ?string { return $this->createContactForRecord(objectType: $objectType, record: $record); }//end syncToContacts() + /** + * Resolve the contact of a record, creating it in a dedicated address book of the signed-in user. + * + * A contact the record already links (`contactsUid`) is kept. Otherwise + * a contact with the record's e-mail address is reused only when it is + * in the address book with this URI: a contact in another address book + * of the user is never matched, so the user's own contacts are never + * linked to imported records. A new contact goes into that address book, + * which is created with the display name when the user has none, and + * never into the user's own first writable address book. + * + * @param string $objectType The relationship type ('contactPerson'|'organization'). + * @param array $record The relationship record. + * @param string $addressBookUri The address book's URI, stable across languages. + * @param string $displayName The display name for a new address book. + * + * @return ?string The contacts UID, or null when it could not be resolved or created. + * + * @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md + */ + public function syncToNamedAddressBook(string $objectType, array $record, string $addressBookUri, string $displayName): ?string { + if ($this->isAvailable() === false) { + return null; + } + + $existingUid = (string)($record['contactsUid'] ?? ''); + if ($existingUid !== '' && $this->findContactByUid(uid: $existingUid) !== null) { + return $existingUid; + } + + $matched = $this->namedAddressBookContactByEmail(record: $record, addressBookUri: $addressBookUri); + if ($matched !== null) { + return $matched; + } + + $properties = $this->recordToVCard(objectType: $objectType, record: $record); + if (($properties['FN'] ?? '') === '') { + $this->logger->warning('[StackiqContactSync] Record has no identity to create a contact from', ['objectType' => $objectType]); + return null; + } + + try { + $backend = $this->container?->get(static::CARDDAV_BACKEND_CLASS); + $uid = $this->userSession?->getUser()?->getUID(); + if (is_object($backend) === false || $uid === null) { + $this->logger->warning('[StackiqContactSync] No CardDAV backend or no user; cannot create the contact', ['objectType' => $objectType]); + return null; + } + + $addressBookId = $this->namedAddressBookId( + backend: $backend, + principal: 'principals/users/' . $uid, + uri: $addressBookUri, + displayName: $displayName + ); + $contactUid = self::newUid(); + $backend->createCard($addressBookId, $contactUid . '.vcf', self::serialiseVCard(uid: $contactUid, properties: $properties)); + } catch (Throwable $e) { + $this->logger->warning( + '[StackiqContactSync] The contact could not be created in the named address book', + ['objectType' => $objectType, 'exception' => get_class($e)] + ); + return null; + } + + return $contactUid; + }//end syncToNamedAddressBook() + + /** + * Search the signed-in user's address book with this URI, and no other. + * + * @param string $query The search query. + * @param string $addressBookUri The address book's URI. + * @param array $properties The vCard properties to search, such as FN or EMAIL. + * + * @return array> The matching contacts, as searchContacts() returns them; + * none when the user has no such address book. + * + * @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md + */ + public function searchNamedAddressBook(string $query, string $addressBookUri, array $properties): array { + $contacts = []; + foreach ($this->namedAddressBookResults(query: $query, addressBookUri: $addressBookUri, properties: $properties) as $result) { + $contacts[] = [ + 'uid' => (string)$result['UID'], + 'name' => $this->firstValue(value: ($result['FN'] ?? '')), + 'email' => $this->firstValue(value: ($result['EMAIL'] ?? '')), + 'addressBookKey' => (string)($result['addressbook-key'] ?? ''), + ]; + } + + return $contacts; + }//end searchNamedAddressBook() + + /** + * The contact in the named address book with the record's e-mail address, or null. + * + * @param array $record The relationship record. + * @param string $addressBookUri The address book's URI. + * + * @return string|null The contact UID. + */ + private function namedAddressBookContactByEmail(array $record, string $addressBookUri): ?string { + $email = trim((string)($record['e-mailadres'] ?? $record['email'] ?? '')); + foreach ($this->namedAddressBookResults(query: $email, addressBookUri: $addressBookUri, properties: ['EMAIL']) as $result) { + if ($this->valueMatches(value: ($result['EMAIL'] ?? ''), needle: $email) === true) { + return (string)$result['UID']; + } + } + + return null; + }//end namedAddressBookContactByEmail() + + /** + * The raw search results that lie in the user's address book with this URI. + * + * The contacts manager searches every address book of the user and tags + * each result with its address book's key, the CardDAV address book id; + * only results with the named address book's id are kept. + * + * @param string $query The search query. + * @param string $addressBookUri The address book's URI. + * @param array $properties The vCard properties to search. + * + * @return array> + */ + private function namedAddressBookResults(string $query, string $addressBookUri, array $properties): array { + if ($this->isAvailable() === false || trim($query) === '') { + return []; + } + + $key = $this->existingNamedAddressBookKey(addressBookUri: $addressBookUri); + if ($key === null) { + return []; + } + + $found = []; + foreach ($this->contactsManager->search($query, $properties, ['limit' => 50]) as $result) { + if (isset($result['UID']) === true && (string)($result['addressbook-key'] ?? '') === $key) { + $found[] = $result; + } + } + + return $found; + }//end namedAddressBookResults() + + /** + * The key of the signed-in user's address book with this URI, without creating it. + * + * @param string $addressBookUri The address book's URI. + * + * @return string|null The CardDAV address book id as a string, or null when there is none. + */ + private function existingNamedAddressBookKey(string $addressBookUri): ?string { + try { + $backend = $this->container?->get(static::CARDDAV_BACKEND_CLASS); + $uid = $this->userSession?->getUser()?->getUID(); + if (is_object($backend) === false || $uid === null) { + return null; + } + + $book = $backend->getAddressBooksByUri('principals/users/' . $uid, $addressBookUri); + } catch (Throwable $e) { + $this->logger->warning('[StackiqContactSync] The named address book could not be read', ['exception' => get_class($e)]); + return null; + } + + if (is_array($book) === false || isset($book['id']) === false) { + return null; + } + + return (string)$book['id']; + }//end existingNamedAddressBookKey() + + /** + * The id of a principal's address book with this URI, created when absent. + * + * @param object $backend The CardDAV backend. + * @param string $principal The principal URI. + * @param string $uri The address book URI. + * @param string $displayName The display name for a new address book. + * + * @return int + */ + private function namedAddressBookId(object $backend, string $principal, string $uri, string $displayName): int { + $book = $backend->getAddressBooksByUri($principal, $uri); + if (is_array($book) === true && isset($book['id']) === true) { + return (int)$book['id']; + } + + return (int)$backend->createAddressBook($principal, $uri, ['{DAV:}displayname' => $displayName]); + }//end namedAddressBookId() + + /** + * A vCard 3.0 for a new contact, from the property set recordToVCard() builds. + * + * Text values are escaped as RFC 6350 requires; `N` is structured, so only + * its components are escaped. Lines are folded at 75 octets. + * + * @param string $uid The contact UID. + * @param array $properties The vCard property set. + * + * @return string + * + * @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md + */ + public static function serialiseVCard(string $uid, array $properties): string { + $lines = ['BEGIN:VCARD', 'VERSION:3.0', 'UID:' . self::escapeVCardText(text: $uid)]; + foreach ($properties as $name => $value) { + if (is_scalar($value) === false || (string)$value === '' || in_array($name, ['UID', 'VERSION'], true) === true) { + continue; + } + + $text = self::escapeVCardText(text: (string)$value); + if ($name === 'N') { + $text = implode(';', array_map(static fn (string $part): string => self::escapeVCardText(text: $part), explode(';', (string)$value))); + } + + $lines[] = self::foldVCardLine(line: strtoupper((string)$name) . ':' . $text); + } + + $lines[] = 'END:VCARD'; + + return implode("\r\n", $lines) . "\r\n"; + }//end serialiseVCard() + + /** + * Escape a vCard text value: backslash, comma, semicolon and line breaks. + * + * @param string $text The value. + * + * @return string + */ + private static function escapeVCardText(string $text): string { + return str_replace(["\\", ',', ';', "\r\n", "\n", "\r"], ["\\\\", '\\,', '\\;', '\\n', '\\n', '\\n'], $text); + }//end escapeVCardText() + + /** + * Fold a vCard content line at 75 octets, never inside a UTF-8 character. + * + * @param string $line The unfolded line. + * + * @return string + */ + private static function foldVCardLine(string $line): string { + $folded = []; + while (strlen($line) > 75) { + $cut = mb_strcut($line, 0, 75, 'UTF-8'); + $folded[] = $cut; + $line = ' ' . substr($line, strlen($cut)); + } + + $folded[] = $line; + + return implode("\r\n", $folded); + }//end foldVCardLine() + + /** + * A random UUID v4 for a new contact. + * + * @return string + */ + private static function newUid(): string { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + }//end newUid() + /** * Find a Nextcloud contact by its exact UID. * 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/StackiqAdmin.php b/lib/Settings/StackiqAdmin.php index c21e8db02..1fc6e68ee 100644 --- a/lib/Settings/StackiqAdmin.php +++ b/lib/Settings/StackiqAdmin.php @@ -118,9 +118,12 @@ public function getName(): ?string { * App config keys an authorized (delegated) admin may manage. * * Returned as a map of appId => list of allowed config keys. Stackiq - * exposes no delegatable sub-keys, so this is intentionally empty; the - * `#[AuthorizedAdminSetting]` attribute still scopes the endpoints to full - * admins (fail-closed). Required by IDelegatedSettings — its absence is a + * exposes no delegatable sub-keys, so this is intentionally empty. This + * list does not decide who reaches an endpoint: a route with + * `#[AuthorizedAdminSetting(settings: StackiqAdmin::class)]` admits the + * groups an admin delegated these settings to, and a route without an auth + * attribute (the CMDB import) stays for full Nextcloud admins only. + * Required by IDelegatedSettings — its absence is a * fatal class-loading error that blanks every Nextcloud settings page. * * @return array Map of appId to allowed config keys. diff --git a/lib/Settings/cmdb-import/topdesk-business-owner.json b/lib/Settings/cmdb-import/topdesk-business-owner.json new file mode 100644 index 000000000..f987d51ee --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-business-owner.json @@ -0,0 +1,12 @@ +{ + "id": "stackiq-topdesk-business-owner", + "name": "TOPdesk CMDB export to business owner identity", + "description": "The application owner (Applicatie Eigenaar (Persoon)); the value may be a function instead of a name and is used as the display name either way. The import resolves it in Nextcloud Contacts by exact display name and links a contactPerson of the municipality as usage.businessOwner, with the owner's function as its role. No other person column is read.", + "sourceFormat": "excel", + "version": "2.0.0", + "fieldMappings": [ + { "source": "Applicatie Eigenaar (Persoon)", "target": "name", "required": true, "transform": { "type": "trim" } }, + { "source": "Applicatie Eigenaar (Functie)", "target": "role", "transform": { "type": "trim" } } + ], + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-manufacturer.json b/lib/Settings/cmdb-import/topdesk-manufacturer.json new file mode 100644 index 000000000..dec8b10c9 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-manufacturer.json @@ -0,0 +1,12 @@ +{ + "id": "stackiq-topdesk-manufacturer", + "name": "TOPdesk CMDB export to stackiq supplier organisation", + "description": "The Vendor column (the maker of the software) becomes one organisation of type Supplier per distinct name, registered as a Supplier so the public catalogue can show it as the module's provider. An empty Vendor means the row has no provider. Leverancier and Hostingpartij are not read.", + "sourceFormat": "excel", + "version": "2.1.0", + "fieldMappings": [ + { "source": "Vendor", "target": "name", "required": true, "transform": { "type": "trim" } } + ], + "defaults": { "type": "Supplier", "status": "Active", "registeredBy": "Supplier" }, + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-module.json b/lib/Settings/cmdb-import/topdesk-module.json new file mode 100644 index 000000000..3a7ca7496 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-module.json @@ -0,0 +1,47 @@ +{ + "id": "stackiq-topdesk-module", + "name": "TOPdesk CMDB export to stackiq module", + "description": "One application row of a CMDB sheet becomes a stackiq module. A mapping marked required that fails skips the row; any other failing mapping drops that field with a warning. Nickname and Roepnaam both feed shortDescription; Roepnaam wins when both are filled. Applicatiesoort is kept as is in applicationType; only its SaaS/PaaS/IaaS/on-premises values also set the hosting model, every other kind leaves that empty without a warning.", + "sourceFormat": "excel", + "version": "2.0.0", + "fieldMappings": [ + { "source": "Applicatie Naam", "target": "name", "required": true, "transform": { "type": "trim" } }, + { "source": "APPID", "target": "externalNumber", "required": true, "transform": { "type": "trim" } }, + { "source": "Applicatie Code", "target": "externalId", "transform": { "type": "trim" } }, + { "source": "Nickname", "target": "shortDescription", "transform": { "type": "trim" } }, + { "source": "Roepnaam", "target": "shortDescription", "transform": { "type": "trim" } }, + { "source": "Functionele Omschrijving", "target": "longDescription", "transform": { "type": "trim" } }, + { "source": "Applicatiesoort", "target": "applicationType", "transform": { "type": "trim" } }, + { + "source": "Applicatiesoort", + "target": "cloudDienstverleningsmodel", + "transform": { + "type": "lookup", + "map": { + "Saas": ["SaaS"], "SaaS": ["SaaS"], "SAAS": ["SaaS"], "saas": ["SaaS"], + "Paas": ["PaaS"], "PaaS": ["PaaS"], "PAAS": ["PaaS"], + "Iaas": ["IaaS"], "IaaS": ["IaaS"], "IAAS": ["IaaS"], + "On-premise": ["On-premises (self-managed)"], "On-premises": ["On-premises (self-managed)"], "On premise": ["On-premises (self-managed)"] + }, + "default": null + } + }, + { + "source": "BNN Classificatie", + "target": "bbnLevel", + "transform": { + "type": "lookup", + "map": { + "BBN1": "BBN1", "BBN 1": "BBN1", "bbn1": "BBN1", "bbn 1": "BBN1", "BNN1": "BBN1", "BNN 1": "BBN1", + "BBN2": "BBN2", "BBN 2": "BBN2", "bbn2": "BBN2", "bbn 2": "BBN2", "BNN2": "BBN2", "BNN 2": "BBN2", + "BBN3": "BBN3", "BBN 3": "BBN3", "bbn3": "BBN3", "bbn 3": "BBN3", "BNN3": "BBN3", "BNN 3": "BBN3", + "1": "BBN1", "2": "BBN2", "2+": "BBN2+", "3": "BBN3", + "BBN2+": "BBN2+", "BBN 2+": "BBN2+", "BNN2+": "BBN2+", "BNN 2+": "BBN2+" + } + } + }, + { "source": "Datum", "target": "externalCreatedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, + { "source": "Referentie datum wijziging", "target": "externalModifiedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } } + ], + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-municipality.json b/lib/Settings/cmdb-import/topdesk-municipality.json new file mode 100644 index 000000000..eb16fe829 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-municipality.json @@ -0,0 +1,12 @@ +{ + "id": "stackiq-topdesk-municipality", + "name": "CMDB import options to stackiq municipality", + "description": "Maps the import options row (municipalityName), not a sheet row, to the consuming organisation of type Municipality.", + "sourceFormat": "excel", + "version": "1.0.0", + "fieldMappings": [ + { "source": "municipalityName", "target": "name", "required": true, "transform": { "type": "trim" } } + ], + "defaults": { "type": "Municipality", "status": "Active" }, + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-profile.json b/lib/Settings/cmdb-import/topdesk-profile.json new file mode 100644 index 000000000..79dc43253 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-profile.json @@ -0,0 +1,48 @@ +{ + "id": "topdesk-cmdb", + "name": "TOPdesk CMDB export", + "version": "2.0.0", + "description": "How stackiq reads a TOPdesk CMDB export (xlsx): the two CMDB sheets, the key and required columns, date and id columns, values that mean empty, the pack per target and the limits. Columns that neither this profile nor a pack names are never read.", + "maxFileBytes": 10485760, + "maxRowsPerSheet": 10000, + "maxUncompressedBytes": 52428800, + "maxPartBytes": 10485760, + "maxSharedStrings": 200000, + "maxReferencedStringBytes": 67108864, + "sheets": [ + { + "name": "Onbeh Applicaties CMDB", + "constants": { "Beheer": "Beheer geregeld: nee" }, + "absentColumns": ["Nickname"] + }, + { + "name": "Beheerde Applicaties CMDB", + "constants": { "Beheer": "Beheer geregeld: ja" } + } + ], + "sheetPrecedence": ["Beheerde Applicaties CMDB", "Onbeh Applicaties CMDB"], + "keyColumn": "APPID", + "nameColumn": "Applicatie Naam", + "requiredColumns": ["APPID", "Applicatie Naam"], + "dateColumns": ["Datum", "Referentie datum wijziging", "End-of-Life Functioneel"], + "idColumns": ["APPID"], + "emptyValues": { + "BNN Classificatie": ["NB"] + }, + "externalKeyPrefix": "topdesk", + "packs": { + "module": "topdesk-module.json", + "manufacturer": "topdesk-manufacturer.json", + "municipality": "topdesk-municipality.json", + "usage": "topdesk-usage.json", + "businessOwner": "topdesk-business-owner.json" + }, + "createOnly": { + "module": { "type": "Application" }, + "usage": ["interneAnnotation", "timeClassification"] + }, + "neverWritten": { + "module": ["publicationDate", "depublicationDate"] + }, + "missingRecords": ["keep"] +} diff --git a/lib/Settings/cmdb-import/topdesk-usage.json b/lib/Settings/cmdb-import/topdesk-usage.json new file mode 100644 index 000000000..0038df197 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-usage.json @@ -0,0 +1,45 @@ +{ + "id": "stackiq-topdesk-usage", + "name": "TOPdesk CMDB export to stackiq usage", + "description": "The usage that links the application to the municipality. The internal note records whether maintenance is arranged (the sheet the row came from), the cluster and the owner's department. An unknown status or TIME value drops the field with a warning.", + "sourceFormat": "excel", + "version": "2.0.0", + "fieldMappings": [ + { + "source": "Applicatie Status", + "target": "status", + "transform": { + "type": "lookup", + "map": { + "In productie": "In production", + "In voorraad": "Planned", + "In ontwikkeling": "Acquisition", + "Uit te faseren": "To be phased out", + "Uitgefaseerd": "Phased out", + "Moet verwijderd worden": "To be phased out", + "Verwijderd": "Phased out", + "Besteld": "Acquisition", + "Wordt getest": "Acquisition", + "Stand-by voor continuïteit": "In production" + } + } + }, + { + "source": "Classificatie", + "target": "timeClassification", + "transform": { + "type": "lookup", + "map": { + "Tolerate": "Tolerate", "Tolereren": "Tolerate", + "Invest": "Invest", "Investeren": "Invest", + "Migrate": "Migrate", "Migreren": "Migrate", + "Eliminate": "Eliminate", "Elimineren": "Eliminate", + "1. Tolereren (wordt ingelezen)": "Tolerate" + } + } + }, + { "source": "End-of-Life Functioneel", "target": "startDateOutPhased", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, + { "source": "Beheer", "target": "interneAnnotation", "transform": { "type": "concat", "fields": ["Cluster", "Applicatie Eigenaar (Afdeling)"], "separator": " / " } } + ], + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json new file mode 100644 index 000000000..d9fc531fe --- /dev/null +++ b/lib/Settings/connections.json @@ -0,0 +1,59 @@ +{ + "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." + }, + { + "key": "itsm", + "title": "Service desk", + "description": "Keeps applications, connections, licences and contracts in step with TOPdesk or ServiceNow, through integriq flows. The service desk owns its fields, stackiq owns its own.", + "order": 40, + "settingsUrl": "/settings/admin/stackiq#section-itsm", + "reportedOnly": true, + "switch": { + "configKey": "itsm_exchange_enabled" + }, + "disabledMessage": "The service desk exchange is not set up. Set it up in the Service desk exchange section.", + "unconfiguredMessage": "Not checked yet. The first import run reports here." + } + ] +} diff --git a/lib/Settings/flows/itsm-file-applications.json b/lib/Settings/flows/itsm-file-applications.json new file mode 100644 index 000000000..eb51deed4 --- /dev/null +++ b/lib/Settings/flows/itsm-file-applications.json @@ -0,0 +1,318 @@ +{ + "name": "File import: applications", + "app": "stackiq", + "description": "Imports applications from an uploaded CSV or XLSX file through the same mapping and matching as the service desk import. Importing the same file again updates rather than duplicates.", + "executionMode": "async", + "limits": { + "maxTransitions": 5000 + }, + "nodes": [ + { + "id": "start", + "type": "openregister.trigger-manual", + "config": {} + }, + { + "id": "each", + "type": "openregister.explode", + "config": { + "path": "rows", + "as": "source" + } + }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, + { + "id": "map-all", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "record" + } + }, + { + "id": "map-owned", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "owned", + "ownership": "inbound", + "exists": "record.recordId" + } + }, + { + "id": "complete", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "!!": { + "var": "json.record.recordId" + } + }, + { + "!!": { + "var": "json.record.name" + } + }, + { + "!!": { + "var": "json.record.supplierName" + } + } + ] + } + } + }, + { + "id": "decide", + "type": "openconnector.contract", + "config": { + "synchronization": "%SYNC%", + "idPosition": "owned.recordId", + "hashPosition": "owned", + "output": "contract" + } + }, + { + "id": "changed", + "type": "openregister.filter", + "config": { + "condition": { + "in": [ + { + "var": "json.contract.outcome" + }, + [ + "create", + "update" + ] + ] + } + } + }, + { + "id": "flags", + "type": "openregister.set-fields", + "config": { + "compute": { + "isCreate": { + "!": { + "var": "json.contract.targetId" + } + }, + "isUpdate": { + "!!": { + "var": "json.contract.targetId" + } + }, + "syncedAt": { + "now": [] + } + } + } + }, + { + "id": "supplier", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "organization", + "operation": "upsert", + "match": [ + { + "property": "name", + "value": "{{ record.supplierName }}" + }, + { + "property": "type", + "value": "Supplier" + } + ], + "fields": { + "name": "{{ record.supplierName }}", + "type": "Supplier" + }, + "output": "supplier" + } + }, + { + "id": "module", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "module", + "operation": "upsert", + "match": [ + { + "property": "name", + "value": "{{ record.name }}" + }, + { + "property": "provider", + "value": "{{ supplier.uuid }}" + } + ], + "fields": { + "name": "{{ record.name }}", + "provider": "{{ supplier.uuid }}" + }, + "output": "module" + } + }, + { + "id": "usage-create", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "operation": "upsert", + "skipWhen": "isUpdate", + "match": [ + { + "property": "module", + "value": "{{ module.uuid }}" + }, + { + "property": "consumer", + "value": "%CONSUMER%" + } + ], + "fields": { + "module": "{{ module.uuid }}", + "consumer": "%CONSUMER%", + "status": "{{ record.status }}", + "installedVersion": "{{ record.installedVersion }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ record.recordId }}", + "serviceDeskUrl": "{{ record.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "usageWritten" + } + }, + { + "id": "usage-update", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "operation": "update", + "skipWhen": "isCreate", + "match": [ + { + "property": "@self.uuid", + "value": "{{ contract.targetId }}" + } + ], + "fields": { + "module": "{{ module.uuid }}", + "status": "{{ owned.status }}", + "installedVersion": "{{ owned.installedVersion }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ owned.recordId }}", + "serviceDeskUrl": "{{ owned.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "usageWritten" + } + }, + { + "id": "commit", + "type": "openconnector.contract-commit", + "config": { + "synchronization": "%SYNC%", + "contractPosition": "contract", + "targetIdPosition": "usageWritten.uuid", + "targetHashPosition": "owned" + } + }, + { + "id": "end", + "type": "openregister.end", + "config": {} + } + ], + "edges": [ + { + "id": "e-start-each", + "from": "start", + "to": "each" + }, + { + "id": "e-each-desk", + "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", + "to": "map-all" + }, + { + "id": "e-map-all-map-owned", + "from": "map-all", + "to": "map-owned" + }, + { + "id": "e-map-owned-complete", + "from": "map-owned", + "to": "complete" + }, + { + "id": "e-complete-decide", + "from": "complete", + "to": "decide" + }, + { + "id": "e-decide-changed", + "from": "decide", + "to": "changed" + }, + { + "id": "e-changed-flags", + "from": "changed", + "to": "flags" + }, + { + "id": "e-flags-supplier", + "from": "flags", + "to": "supplier" + }, + { + "id": "e-supplier-module", + "from": "supplier", + "to": "module" + }, + { + "id": "e-module-usage-create", + "from": "module", + "to": "usage-create" + }, + { + "id": "e-usage-create-usage-update", + "from": "usage-create", + "to": "usage-update" + }, + { + "id": "e-usage-update-commit", + "from": "usage-update", + "to": "commit" + }, + { + "id": "e-commit-end", + "from": "commit", + "to": "end" + } + ] +} diff --git a/lib/Settings/flows/itsm-inbound-applications.json b/lib/Settings/flows/itsm-inbound-applications.json new file mode 100644 index 000000000..d1958d09e --- /dev/null +++ b/lib/Settings/flows/itsm-inbound-applications.json @@ -0,0 +1,336 @@ +{ + "name": "Service desk import: applications (%DESK_LABEL%)", + "app": "stackiq", + "description": "Reads the application records from the service desk and creates or updates the supplier, the application and your organisation's use of it. Matches on the service desk record first, then on name and supplier. On an update it writes only the fields the service desk owns.", + "trigger": "schedule", + "cron": "%CRON%", + "executionMode": "async", + "limits": { + "maxTransitions": 5000 + }, + "nodes": [ + { + "id": "start", + "type": "openregister.trigger-schedule", + "config": { + "cron": "%CRON%", + "runAs": "%RUN_AS%" + } + }, + { + "id": "pages", + "type": "openconnector.source-paginate", + "config": { + "synchronization": "%SYNC%", + "output": "page" + } + }, + { + "id": "each", + "type": "openregister.explode", + "config": { + "path": "page.results", + "as": "source" + } + }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, + { + "id": "map-all", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "record" + } + }, + { + "id": "map-owned", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "owned", + "ownership": "inbound", + "exists": "record.recordId" + } + }, + { + "id": "complete", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "!!": { + "var": "json.record.recordId" + } + }, + { + "!!": { + "var": "json.record.name" + } + }, + { + "!!": { + "var": "json.record.supplierName" + } + } + ] + } + } + }, + { + "id": "decide", + "type": "openconnector.contract", + "config": { + "synchronization": "%SYNC%", + "idPosition": "owned.recordId", + "hashPosition": "owned", + "output": "contract" + } + }, + { + "id": "changed", + "type": "openregister.filter", + "config": { + "condition": { + "in": [ + { + "var": "json.contract.outcome" + }, + [ + "create", + "update" + ] + ] + } + } + }, + { + "id": "flags", + "type": "openregister.set-fields", + "config": { + "compute": { + "isCreate": { + "!": { + "var": "json.contract.targetId" + } + }, + "isUpdate": { + "!!": { + "var": "json.contract.targetId" + } + }, + "syncedAt": { + "now": [] + } + } + } + }, + { + "id": "supplier", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "organization", + "operation": "upsert", + "match": [ + { + "property": "name", + "value": "{{ record.supplierName }}" + }, + { + "property": "type", + "value": "Supplier" + } + ], + "fields": { + "name": "{{ record.supplierName }}", + "type": "Supplier" + }, + "output": "supplier" + } + }, + { + "id": "module", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "module", + "operation": "upsert", + "match": [ + { + "property": "name", + "value": "{{ record.name }}" + }, + { + "property": "provider", + "value": "{{ supplier.uuid }}" + } + ], + "fields": { + "name": "{{ record.name }}", + "provider": "{{ supplier.uuid }}" + }, + "output": "module" + } + }, + { + "id": "usage-create", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "operation": "upsert", + "skipWhen": "isUpdate", + "match": [ + { + "property": "module", + "value": "{{ module.uuid }}" + }, + { + "property": "consumer", + "value": "%CONSUMER%" + } + ], + "fields": { + "module": "{{ module.uuid }}", + "consumer": "%CONSUMER%", + "status": "{{ record.status }}", + "installedVersion": "{{ record.installedVersion }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ record.recordId }}", + "serviceDeskUrl": "{{ record.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "usageWritten" + } + }, + { + "id": "usage-update", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "operation": "update", + "skipWhen": "isCreate", + "match": [ + { + "property": "@self.uuid", + "value": "{{ contract.targetId }}" + } + ], + "fields": { + "module": "{{ module.uuid }}", + "status": "{{ owned.status }}", + "installedVersion": "{{ owned.installedVersion }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ owned.recordId }}", + "serviceDeskUrl": "{{ owned.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "usageWritten" + } + }, + { + "id": "commit", + "type": "openconnector.contract-commit", + "config": { + "synchronization": "%SYNC%", + "contractPosition": "contract", + "targetIdPosition": "usageWritten.uuid", + "targetHashPosition": "owned" + } + }, + { + "id": "end", + "type": "openregister.end", + "config": {} + } + ], + "edges": [ + { + "id": "e-start-pages", + "from": "start", + "to": "pages" + }, + { + "id": "e-pages-each", + "from": "pages", + "to": "each" + }, + { + "id": "e-each-desk", + "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", + "to": "map-all" + }, + { + "id": "e-map-all-map-owned", + "from": "map-all", + "to": "map-owned" + }, + { + "id": "e-map-owned-complete", + "from": "map-owned", + "to": "complete" + }, + { + "id": "e-complete-decide", + "from": "complete", + "to": "decide" + }, + { + "id": "e-decide-changed", + "from": "decide", + "to": "changed" + }, + { + "id": "e-changed-flags", + "from": "changed", + "to": "flags" + }, + { + "id": "e-flags-supplier", + "from": "flags", + "to": "supplier" + }, + { + "id": "e-supplier-module", + "from": "supplier", + "to": "module" + }, + { + "id": "e-module-usage-create", + "from": "module", + "to": "usage-create" + }, + { + "id": "e-usage-create-usage-update", + "from": "usage-create", + "to": "usage-update" + }, + { + "id": "e-usage-update-commit", + "from": "usage-update", + "to": "commit" + }, + { + "id": "e-commit-end", + "from": "commit", + "to": "end" + } + ] +} diff --git a/lib/Settings/flows/itsm-inbound-contracts.json b/lib/Settings/flows/itsm-inbound-contracts.json new file mode 100644 index 000000000..1cc4c2375 --- /dev/null +++ b/lib/Settings/flows/itsm-inbound-contracts.json @@ -0,0 +1,373 @@ +{ + "name": "Service desk import: %FEED_LABEL% (%DESK_LABEL%)", + "app": "stackiq", + "description": "Reads licences or contracts from the service desk and records them on the application in use they belong to. A new one is created with every field; after that stackiq owns its licence and contract fields, and the import only refreshes the service desk reference.", + "trigger": "schedule", + "cron": "%CRON%", + "executionMode": "async", + "limits": { + "maxTransitions": 5000 + }, + "nodes": [ + { + "id": "start", + "type": "openregister.trigger-schedule", + "config": { + "cron": "%CRON%", + "runAs": "%RUN_AS%" + } + }, + { + "id": "pages", + "type": "openconnector.source-paginate", + "config": { + "synchronization": "%SYNC%", + "output": "page" + } + }, + { + "id": "each", + "type": "openregister.explode", + "config": { + "path": "page.results", + "as": "source" + } + }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, + { + "id": "map-all", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "record" + } + }, + { + "id": "map-owned", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "owned", + "ownership": "inbound", + "exists": "record.recordId" + } + }, + { + "id": "complete", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "!!": { + "var": "json.record.recordId" + } + }, + { + "!!": { + "var": "json.record.applicationRecordId" + } + }, + { + "!!": { + "var": "json.record.startDate" + } + } + ] + } + } + }, + { + "id": "decide", + "type": "openconnector.contract", + "config": { + "synchronization": "%SYNC%", + "idPosition": "owned.recordId", + "hashPosition": "owned", + "output": "contract" + } + }, + { + "id": "changed", + "type": "openregister.filter", + "config": { + "condition": { + "in": [ + { + "var": "json.contract.outcome" + }, + [ + "create", + "update" + ] + ] + } + } + }, + { + "id": "application", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "filters": { + "serviceDeskRecordId": "{{ record.applicationRecordId }}" + }, + "limit": 1, + "fanOut": false, + "output": "usage" + } + }, + { + "id": "has-application", + "type": "openregister.filter", + "config": { + "condition": { + "!!": { + "var": "json.usage.0.uuid" + } + } + } + }, + { + "id": "flags", + "type": "openregister.set-fields", + "config": { + "compute": { + "isCreate": { + "!": { + "var": "json.contract.targetId" + } + }, + "isUpdate": { + "!!": { + "var": "json.contract.targetId" + } + }, + "noSupplier": { + "!": { + "var": "json.record.supplierName" + } + }, + "syncedAt": { + "now": [] + }, + "contractNumber": { + "or": [ + { + "var": "json.record.contractNumber" + }, + { + "var": "json.record.vendorReference" + }, + { + "var": "json.record.recordId" + } + ] + }, + "contractType": { + "or": [ + { + "var": "json.record.contractType" + }, + "%CONTRACT_TYPE%" + ] + } + } + } + }, + { + "id": "supplier", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "organization", + "operation": "upsert", + "skipWhen": "noSupplier", + "match": [ + { + "property": "name", + "value": "{{ record.supplierName }}" + }, + { + "property": "type", + "value": "Supplier" + } + ], + "fields": { + "name": "{{ record.supplierName }}", + "type": "Supplier" + }, + "output": "supplier" + } + }, + { + "id": "contract-create", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "catalogContract", + "operation": "upsert", + "skipWhen": "isUpdate", + "match": [ + { + "property": "serviceDeskRecordId", + "value": "{{ record.recordId }}" + } + ], + "fields": { + "usage": "{{ usage.0.uuid }}", + "supplier": "{{ supplier.uuid }}", + "contractNumber": "{{ contractNumber }}", + "vendorReference": "{{ record.vendorReference }}", + "contractType": "{{ contractType }}", + "status": "Active", + "startDate": "{{ record.startDate }}", + "endDate": "{{ record.endDate }}", + "cost": "{{ record.cost }}", + "costPeriod": "{{ record.costPeriod }}", + "currency": "{{ record.currency }}", + "licenceMetric": "{{ record.licenceMetric }}", + "licencesBought": "{{ record.licencesBought }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ record.recordId }}", + "serviceDeskUrl": "{{ record.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "contractWritten" + } + }, + { + "id": "contract-update", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "catalogContract", + "operation": "update", + "skipWhen": "isCreate", + "match": [ + { + "property": "@self.uuid", + "value": "{{ contract.targetId }}" + } + ], + "fields": { + "usage": "{{ usage.0.uuid }}", + "supplier": "{{ supplier.uuid }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ owned.recordId }}", + "serviceDeskUrl": "{{ owned.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "contractWritten" + } + }, + { + "id": "commit", + "type": "openconnector.contract-commit", + "config": { + "synchronization": "%SYNC%", + "contractPosition": "contract", + "targetIdPosition": "contractWritten.uuid", + "targetHashPosition": "owned" + } + }, + { + "id": "end", + "type": "openregister.end", + "config": {} + } + ], + "edges": [ + { + "id": "e-start-pages", + "from": "start", + "to": "pages" + }, + { + "id": "e-pages-each", + "from": "pages", + "to": "each" + }, + { + "id": "e-each-desk", + "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", + "to": "map-all" + }, + { + "id": "e-map-all-map-owned", + "from": "map-all", + "to": "map-owned" + }, + { + "id": "e-map-owned-complete", + "from": "map-owned", + "to": "complete" + }, + { + "id": "e-complete-decide", + "from": "complete", + "to": "decide" + }, + { + "id": "e-decide-changed", + "from": "decide", + "to": "changed" + }, + { + "id": "e-changed-application", + "from": "changed", + "to": "application" + }, + { + "id": "e-application-has-application", + "from": "application", + "to": "has-application" + }, + { + "id": "e-has-application-flags", + "from": "has-application", + "to": "flags" + }, + { + "id": "e-flags-supplier", + "from": "flags", + "to": "supplier" + }, + { + "id": "e-supplier-contract-create", + "from": "supplier", + "to": "contract-create" + }, + { + "id": "e-contract-create-contract-update", + "from": "contract-create", + "to": "contract-update" + }, + { + "id": "e-contract-update-commit", + "from": "contract-update", + "to": "commit" + }, + { + "id": "e-commit-end", + "from": "commit", + "to": "end" + } + ] +} diff --git a/lib/Settings/flows/itsm-inbound-relations-per-application.json b/lib/Settings/flows/itsm-inbound-relations-per-application.json new file mode 100644 index 000000000..4a444cb28 --- /dev/null +++ b/lib/Settings/flows/itsm-inbound-relations-per-application.json @@ -0,0 +1,347 @@ +{ + "name": "Service desk import: relations (%DESK_LABEL%)", + "app": "stackiq", + "description": "TOPdesk lists the links of one asset at a time, so this import asks for the links of every application in use that came from TOPdesk, and records each as a connection between the applications. A link whose other end is not imported yet waits for the next run.", + "trigger": "schedule", + "cron": "%CRON%", + "executionMode": "async", + "limits": { + "maxTransitions": 5000 + }, + "nodes": [ + { + "id": "start", + "type": "openregister.trigger-schedule", + "config": { + "cron": "%CRON%", + "runAs": "%RUN_AS%" + } + }, + { + "id": "usages", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "filters": { + "serviceDeskSystem": "%DESK%" + }, + "limit": 1000, + "fanOut": true + } + }, + { + "id": "links", + "type": "openconnector.source-call", + "config": { + "source": "%SOURCE%", + "endpoint": "/assetmgmt/assetLinks", + "method": "GET", + "query": { + "sourceId": "{{ serviceDeskRecordId }}" + }, + "output": "links" + } + }, + { + "id": "each", + "type": "openregister.explode", + "config": { + "path": "links.body", + "as": "link" + } + }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source.applicationRecordId": "{{ serviceDeskRecordId }}", + "source.link": "{{ link }}", + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, + { + "id": "map-all", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "record" + } + }, + { + "id": "map-owned", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "owned", + "ownership": "inbound", + "exists": "record.recordId" + } + }, + { + "id": "complete", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "!!": { + "var": "json.record.recordId" + } + }, + { + "!!": { + "var": "json.record.fromRecordId" + } + }, + { + "!!": { + "var": "json.record.toRecordId" + } + } + ] + } + } + }, + { + "id": "decide", + "type": "openconnector.contract", + "config": { + "synchronization": "%SYNC%", + "idPosition": "owned.recordId", + "hashPosition": "owned", + "output": "contract" + } + }, + { + "id": "changed", + "type": "openregister.filter", + "config": { + "condition": { + "in": [ + { + "var": "json.contract.outcome" + }, + [ + "create", + "update" + ] + ] + } + } + }, + { + "id": "from", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "filters": { + "serviceDeskRecordId": "{{ record.fromRecordId }}" + }, + "limit": 1, + "fanOut": false, + "output": "fromUsage" + } + }, + { + "id": "to", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "filters": { + "serviceDeskRecordId": "{{ record.toRecordId }}" + }, + "limit": 1, + "fanOut": false, + "output": "toUsage" + } + }, + { + "id": "both-ends", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "!!": { + "var": "json.fromUsage.0.module" + } + }, + { + "!!": { + "var": "json.toUsage.0.module" + } + } + ] + } + } + }, + { + "id": "flags", + "type": "openregister.set-fields", + "config": { + "compute": { + "syncedAt": { + "now": [] + }, + "connectionName": { + "or": [ + { + "var": "json.record.name" + }, + { + "cat": [ + { + "var": "json.record.fromRecordId" + }, + " - ", + { + "var": "json.record.toRecordId" + } + ] + } + ] + }, + "connectionType": { + "or": [ + { + "var": "json.record.type" + }, + "n/a" + ] + } + } + } + }, + { + "id": "connection", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "connection", + "operation": "upsert", + "match": [ + { + "property": "serviceDeskRecordId", + "value": "{{ owned.recordId }}" + } + ], + "fields": { + "name": "{{ connectionName }}", + "type": "{{ connectionType }}", + "dataExchangeDirection": "{{ owned.direction }}", + "moduleA": "{{ fromUsage.0.module }}", + "moduleB": "{{ toUsage.0.module }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ owned.recordId }}", + "serviceDeskUrl": "{{ owned.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "connectionWritten" + } + }, + { + "id": "commit", + "type": "openconnector.contract-commit", + "config": { + "synchronization": "%SYNC%", + "contractPosition": "contract", + "targetIdPosition": "connectionWritten.uuid", + "targetHashPosition": "owned" + } + }, + { + "id": "end", + "type": "openregister.end", + "config": {} + } + ], + "edges": [ + { + "id": "e-start-usages", + "from": "start", + "to": "usages" + }, + { + "id": "e-usages-links", + "from": "usages", + "to": "links" + }, + { + "id": "e-links-each", + "from": "links", + "to": "each" + }, + { + "id": "e-each-desk", + "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", + "to": "map-all" + }, + { + "id": "e-map-all-map-owned", + "from": "map-all", + "to": "map-owned" + }, + { + "id": "e-map-owned-complete", + "from": "map-owned", + "to": "complete" + }, + { + "id": "e-complete-decide", + "from": "complete", + "to": "decide" + }, + { + "id": "e-decide-changed", + "from": "decide", + "to": "changed" + }, + { + "id": "e-changed-from", + "from": "changed", + "to": "from" + }, + { + "id": "e-from-to", + "from": "from", + "to": "to" + }, + { + "id": "e-to-both-ends", + "from": "to", + "to": "both-ends" + }, + { + "id": "e-both-ends-flags", + "from": "both-ends", + "to": "flags" + }, + { + "id": "e-flags-connection", + "from": "flags", + "to": "connection" + }, + { + "id": "e-connection-commit", + "from": "connection", + "to": "commit" + }, + { + "id": "e-commit-end", + "from": "commit", + "to": "end" + } + ] +} diff --git a/lib/Settings/flows/itsm-inbound-relations.json b/lib/Settings/flows/itsm-inbound-relations.json new file mode 100644 index 000000000..c3df3153b --- /dev/null +++ b/lib/Settings/flows/itsm-inbound-relations.json @@ -0,0 +1,322 @@ +{ + "name": "Service desk import: relations (%DESK_LABEL%)", + "app": "stackiq", + "description": "Reads the relations between application records from the service desk and records them as connections between the applications. A relation whose ends are not imported yet waits for the next run.", + "trigger": "schedule", + "cron": "%CRON%", + "executionMode": "async", + "limits": { + "maxTransitions": 5000 + }, + "nodes": [ + { + "id": "start", + "type": "openregister.trigger-schedule", + "config": { + "cron": "%CRON%", + "runAs": "%RUN_AS%" + } + }, + { + "id": "pages", + "type": "openconnector.source-paginate", + "config": { + "synchronization": "%SYNC%", + "output": "page" + } + }, + { + "id": "each", + "type": "openregister.explode", + "config": { + "path": "page.results", + "as": "source" + } + }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, + { + "id": "map-all", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "record" + } + }, + { + "id": "map-owned", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "source", + "output": "owned", + "ownership": "inbound", + "exists": "record.recordId" + } + }, + { + "id": "complete", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "!!": { + "var": "json.record.recordId" + } + }, + { + "!!": { + "var": "json.record.fromRecordId" + } + }, + { + "!!": { + "var": "json.record.toRecordId" + } + } + ] + } + } + }, + { + "id": "decide", + "type": "openconnector.contract", + "config": { + "synchronization": "%SYNC%", + "idPosition": "owned.recordId", + "hashPosition": "owned", + "output": "contract" + } + }, + { + "id": "changed", + "type": "openregister.filter", + "config": { + "condition": { + "in": [ + { + "var": "json.contract.outcome" + }, + [ + "create", + "update" + ] + ] + } + } + }, + { + "id": "from", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "filters": { + "serviceDeskRecordId": "{{ record.fromRecordId }}" + }, + "limit": 1, + "fanOut": false, + "output": "fromUsage" + } + }, + { + "id": "to", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "filters": { + "serviceDeskRecordId": "{{ record.toRecordId }}" + }, + "limit": 1, + "fanOut": false, + "output": "toUsage" + } + }, + { + "id": "both-ends", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "!!": { + "var": "json.fromUsage.0.module" + } + }, + { + "!!": { + "var": "json.toUsage.0.module" + } + } + ] + } + } + }, + { + "id": "flags", + "type": "openregister.set-fields", + "config": { + "compute": { + "syncedAt": { + "now": [] + }, + "connectionName": { + "or": [ + { + "var": "json.record.name" + }, + { + "cat": [ + { + "var": "json.record.fromRecordId" + }, + " - ", + { + "var": "json.record.toRecordId" + } + ] + } + ] + }, + "connectionType": { + "or": [ + { + "var": "json.record.type" + }, + "n/a" + ] + } + } + } + }, + { + "id": "connection", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "connection", + "operation": "upsert", + "match": [ + { + "property": "serviceDeskRecordId", + "value": "{{ owned.recordId }}" + } + ], + "fields": { + "name": "{{ connectionName }}", + "type": "{{ connectionType }}", + "dataExchangeDirection": "{{ owned.direction }}", + "moduleA": "{{ fromUsage.0.module }}", + "moduleB": "{{ toUsage.0.module }}", + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ owned.recordId }}", + "serviceDeskUrl": "{{ owned.recordUrl }}", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "connectionWritten" + } + }, + { + "id": "commit", + "type": "openconnector.contract-commit", + "config": { + "synchronization": "%SYNC%", + "contractPosition": "contract", + "targetIdPosition": "connectionWritten.uuid", + "targetHashPosition": "owned" + } + }, + { + "id": "end", + "type": "openregister.end", + "config": {} + } + ], + "edges": [ + { + "id": "e-start-pages", + "from": "start", + "to": "pages" + }, + { + "id": "e-pages-each", + "from": "pages", + "to": "each" + }, + { + "id": "e-each-desk", + "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", + "to": "map-all" + }, + { + "id": "e-map-all-map-owned", + "from": "map-all", + "to": "map-owned" + }, + { + "id": "e-map-owned-complete", + "from": "map-owned", + "to": "complete" + }, + { + "id": "e-complete-decide", + "from": "complete", + "to": "decide" + }, + { + "id": "e-decide-changed", + "from": "decide", + "to": "changed" + }, + { + "id": "e-changed-from", + "from": "changed", + "to": "from" + }, + { + "id": "e-from-to", + "from": "from", + "to": "to" + }, + { + "id": "e-to-both-ends", + "from": "to", + "to": "both-ends" + }, + { + "id": "e-both-ends-flags", + "from": "both-ends", + "to": "flags" + }, + { + "id": "e-flags-connection", + "from": "flags", + "to": "connection" + }, + { + "id": "e-connection-commit", + "from": "connection", + "to": "commit" + }, + { + "id": "e-commit-end", + "from": "commit", + "to": "end" + } + ] +} diff --git a/lib/Settings/flows/itsm-outbound-applications.json b/lib/Settings/flows/itsm-outbound-applications.json new file mode 100644 index 000000000..b5cc62032 --- /dev/null +++ b/lib/Settings/flows/itsm-outbound-applications.json @@ -0,0 +1,458 @@ +{ + "name": "Service desk export: applications in use (%DESK_LABEL%)", + "app": "stackiq", + "description": "Sends an application in use to the service desk when stackiq-owned fields change: licences, contract, BBN level, TIME class and publication. A record the service desk does not know yet is created there with every field, and its record id comes back. A change the import wrote is not sent back.", + "trigger": "object.updated", + "triggerRegister": "%REGISTER%", + "triggerSchema": "usage", + "executionMode": "async", + "limits": { + "maxTransitions": 5000 + }, + "nodes": [ + { + "id": "on-created", + "type": "openregister.trigger-object", + "config": { + "event": "object.created", + "register": "%REGISTER%", + "schema": "usage" + } + }, + { + "id": "on-updated", + "type": "openregister.trigger-object", + "config": { + "event": "object.updated", + "register": "%REGISTER%", + "schema": "usage" + } + }, + { + "id": "ours", + "type": "openregister.filter", + "config": { + "condition": { + "and": [ + { + "==": [ + { + "var": "json.consumer" + }, + "%CONSUMER%" + ] + }, + { + "!!": { + "var": "json.module" + } + } + ] + } + } + }, + { + "id": "read-module", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "module", + "filters": { + "@self": { + "uuid": "{{ module }}" + } + }, + "limit": 1, + "fanOut": false, + "output": "moduleRead" + } + }, + { + "id": "ids", + "type": "openregister.set-fields", + "config": { + "compute": { + "providerId": { + "or": [ + { + "var": "json.moduleRead.0.provider" + }, + "00000000-0000-0000-0000-000000000000" + ] + }, + "usageId": { + "var": "json.@self.id" + } + } + } + }, + { + "id": "read-supplier", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "organization", + "filters": { + "@self": { + "uuid": "{{ providerId }}" + } + }, + "limit": 1, + "fanOut": false, + "output": "supplierRead" + } + }, + { + "id": "read-contract", + "type": "openregister.object-read", + "config": { + "register": "%REGISTER%", + "schema": "catalogContract", + "filters": { + "usage": "{{ usageId }}" + }, + "limit": 1, + "fanOut": false, + "output": "contractRead" + } + }, + { + "id": "build", + "type": "openregister.set-fields", + "config": { + "set": { + "usage.uuid": "{{ @self.id }}", + "usage.catalogueUrl": "%APP_URL%/gebruik/{{ @self.id }}", + "usage.recordId": "{{ serviceDeskRecordId }}", + "usage.name": "{{ moduleRead.0.name }}", + "usage.supplierName": "{{ supplierRead.0.name }}", + "usage.installedVersion": "{{ installedVersion }}", + "usage.status": "{{ status }}", + "usage.bbnLevel": "{{ moduleRead.0.bbnLevel }}", + "usage.timeClassification": "{{ timeClassification }}", + "usage.publicationDate": "{{ publicationDate }}", + "usage.licencesBought": "{{ contractRead.0.licencesBought }}", + "usage.licencesInUse": "{{ contractRead.0.licencesInUse }}", + "usage.licenceMetric": "{{ contractRead.0.licenceMetric }}", + "usage.contractNumber": "{{ contractRead.0.contractNumber }}", + "usage.contractEndDate": "{{ contractRead.0.endDate }}", + "usage._desk.baseUrl": "%DESK_BASE%", + "usage._desk.templateId": "%TEMPLATE_ID%" + } + } + }, + { + "id": "owned", + "type": "openregister.set-fields", + "config": { + "set": { + "stackiqOwned.bbnLevel": "{{ usage.bbnLevel }}", + "stackiqOwned.timeClassification": "{{ usage.timeClassification }}", + "stackiqOwned.publicationDate": "{{ usage.publicationDate }}", + "stackiqOwned.licencesBought": "{{ usage.licencesBought }}", + "stackiqOwned.licencesInUse": "{{ usage.licencesInUse }}", + "stackiqOwned.licenceMetric": "{{ usage.licenceMetric }}", + "stackiqOwned.contractNumber": "{{ usage.contractNumber }}", + "stackiqOwned.contractEndDate": "{{ usage.contractEndDate }}" + } + } + }, + { + "id": "worth-sending", + "type": "openregister.filter", + "config": { + "condition": { + "or": [ + { + "!": { + "var": "json.usage.recordId" + } + }, + { + "!!": { + "var": "json.stackiqOwned.bbnLevel" + } + }, + { + "!!": { + "var": "json.stackiqOwned.timeClassification" + } + }, + { + "!!": { + "var": "json.stackiqOwned.publicationDate" + } + }, + { + "!!": { + "var": "json.stackiqOwned.licencesBought" + } + }, + { + "!!": { + "var": "json.stackiqOwned.licencesInUse" + } + }, + { + "!!": { + "var": "json.stackiqOwned.licenceMetric" + } + }, + { + "!!": { + "var": "json.stackiqOwned.contractNumber" + } + }, + { + "!!": { + "var": "json.stackiqOwned.contractEndDate" + } + } + ] + } + } + }, + { + "id": "map", + "type": "openconnector.apply-mapping", + "config": { + "mapping": "%PRESET%", + "input": "usage", + "output": "send", + "ownership": "outbound", + "exists": "usage.recordId" + } + }, + { + "id": "decide", + "type": "openconnector.contract", + "config": { + "synchronization": "%SYNC%", + "idPosition": "usage.uuid", + "hashPosition": "stackiqOwned", + "output": "contract" + } + }, + { + "id": "changed", + "type": "openregister.filter", + "config": { + "condition": { + "in": [ + { + "var": "json.contract.outcome" + }, + [ + "create", + "update" + ] + ] + } + } + }, + { + "id": "known", + "type": "openregister.set-fields", + "config": { + "compute": { + "syncedAt": { + "now": [] + } + } + }, + "exits": [ + { + "id": "create", + "condition": { + "!": { + "var": "json.usage.recordId" + } + } + }, + { + "id": "update" + } + ] + }, + { + "id": "call-create", + "type": "openconnector.source-call", + "config": { + "source": "%SOURCE%", + "endpoint": "%CREATE_ENDPOINT%", + "method": "%CREATE_METHOD%", + "bodyFrom": "send", + "output": "response", + "query": "%CALL_QUERY%" + } + }, + { + "id": "link-back", + "type": "openregister.object-write", + "config": { + "register": "%REGISTER%", + "schema": "usage", + "operation": "update", + "match": [ + { + "property": "@self.uuid", + "value": "{{ usage.uuid }}" + } + ], + "fields": { + "serviceDeskSystem": "%DESK%", + "serviceDeskRecordId": "{{ response.body.%RESPONSE_ID% }}", + "serviceDeskUrl": "%RECORD_URL%", + "serviceDeskSyncedAt": "{{ syncedAt }}" + }, + "output": "linked" + } + }, + { + "id": "commit-create", + "type": "openconnector.contract-commit", + "config": { + "synchronization": "%SYNC%", + "contractPosition": "contract", + "targetIdPosition": "response.body.%RESPONSE_ID%", + "targetHashPosition": "send" + } + }, + { + "id": "end-create", + "type": "openregister.end", + "config": {} + }, + { + "id": "call-update", + "type": "openconnector.source-call", + "config": { + "source": "%SOURCE%", + "endpoint": "%UPDATE_ENDPOINT%", + "method": "%UPDATE_METHOD%", + "bodyFrom": "send", + "output": "response", + "query": "%CALL_QUERY%" + } + }, + { + "id": "commit-update", + "type": "openconnector.contract-commit", + "config": { + "synchronization": "%SYNC%", + "contractPosition": "contract", + "targetIdPosition": "usage.recordId", + "targetHashPosition": "send" + } + }, + { + "id": "end-update", + "type": "openregister.end", + "config": {} + } + ], + "edges": [ + { + "id": "e-on-created-ours", + "from": "on-created", + "to": "ours" + }, + { + "id": "e-on-updated-ours", + "from": "on-updated", + "to": "ours" + }, + { + "id": "e-ours-read-module", + "from": "ours", + "to": "read-module" + }, + { + "id": "e-read-module-ids", + "from": "read-module", + "to": "ids" + }, + { + "id": "e-ids-read-supplier", + "from": "ids", + "to": "read-supplier" + }, + { + "id": "e-read-supplier-read-contract", + "from": "read-supplier", + "to": "read-contract" + }, + { + "id": "e-read-contract-build", + "from": "read-contract", + "to": "build" + }, + { + "id": "e-build-owned", + "from": "build", + "to": "owned" + }, + { + "id": "e-owned-worth-sending", + "from": "owned", + "to": "worth-sending" + }, + { + "id": "e-worth-sending-map", + "from": "worth-sending", + "to": "map" + }, + { + "id": "e-map-decide", + "from": "map", + "to": "decide" + }, + { + "id": "e-decide-changed", + "from": "decide", + "to": "changed" + }, + { + "id": "e-changed-known", + "from": "changed", + "to": "known" + }, + { + "id": "e-known-create", + "from": "known", + "to": "call-create", + "fromExit": "create" + }, + { + "id": "e-known-update", + "from": "known", + "to": "call-update", + "fromExit": "update" + }, + { + "id": "e-call-create-link-back", + "from": "call-create", + "to": "link-back" + }, + { + "id": "e-link-back-commit-create", + "from": "link-back", + "to": "commit-create" + }, + { + "id": "e-commit-create-end-create", + "from": "commit-create", + "to": "end-create" + }, + { + "id": "e-call-update-commit-update", + "from": "call-update", + "to": "commit-update" + }, + { + "id": "e-commit-update-end-update", + "from": "commit-update", + "to": "end-update" + } + ] +} 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/maintenance-recipient-rules.json b/lib/Settings/register.d/maintenance-recipient-rules.json new file mode 100644 index 000000000..d2f923da6 --- /dev/null +++ b/lib/Settings/register.d/maintenance-recipient-rules.json @@ -0,0 +1,26 @@ +{ + "$comment": "maintenance-recipient-rules: the maintenance-announced rule fires when recipientsResolvedAt changes and notifies the user ids in notifyUserIds. Both fields are written by MaintenanceRecipientsJob (without RBAC), so over the API only the catalogue's system administrators may write them; a supplier that writes either one is refused. Who may READ notifyUserIds is tracked in stackiq#1216.", + "components": { + "schemas": { + "maintenanceWindow": { + "version": "0.1.1", + "properties": { + "notifyUserIds": { + "authorization": { + "update": [ + "software-catalog-admins" + ] + } + }, + "recipientsResolvedAt": { + "authorization": { + "update": [ + "software-catalog-admins" + ] + } + } + } + } + } + } +} diff --git a/lib/Settings/register.d/publication-field-rules.json b/lib/Settings/register.d/publication-field-rules.json new file mode 100644 index 000000000..a939858af --- /dev/null +++ b/lib/Settings/register.d/publication-field-rules.json @@ -0,0 +1,521 @@ +{ + "$comment": "publication-field-rules: what of stackiq stays private when lane oc-pub publishes the landscape through OpenCatalogi. Property read rules keep contacts, owners, costs, service desk references and internal assessments for signed-in users; anonymous readers never get them. usage becomes public only from its publicationDate. moduleVersion follows its application's publication through two mirrored fields. A fragment REPLACES an authorization list, so usage carries all its existing read rules plus the public one; usage.interneAnnotation and organization.contactpersonen keep the rules they already have.", + "components": { + "schemas": { + "module": { + "version": "0.3.7", + "properties": { + "contactPerson": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "usages": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "dpiaDocumentRef": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "verwerkingsregisterRef": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "externalId": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "externalNumber": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "externalKey": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "externalCreatedAt": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "externalModifiedAt": { + "authorization": { + "read": [ + "authenticated" + ] + } + } + } + }, + "moduleVersion": { + "version": "0.1.6", + "properties": { + "usages": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "modulePublicationDate": { + "type": "string", + "format": "date-time", + "title": "Application published", + "description": "The publication date of the application this version belongs to, copied from it. A version is public only while its application is.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 90 + }, + "moduleRegisteredBy": { + "type": "string", + "enum": [ + "Municipality", + "Application", + "Collaboration", + "Supplier" + ], + "title": "Application registered by", + "description": "Who registered the application this version belongs to, copied from it. A supplier's application is public, and so are its versions.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 91 + } + }, + "authorization": { + "read": [ + "authenticated", + { + "group": "public", + "match": { + "modulePublicationDate": { + "$lte": "$now" + } + } + }, + { + "group": "public", + "match": { + "moduleRegisteredBy": "Supplier" + } + } + ] + } + }, + "suite": { + "version": "0.1.5", + "properties": { + "contactPerson": { + "authorization": { + "read": [ + "authenticated" + ] + } + } + } + }, + "catalogService": { + "version": "0.2.2", + "properties": { + "contactPerson": { + "authorization": { + "read": [ + "authenticated" + ] + } + } + } + }, + "connection": { + "version": "0.3.5", + "properties": { + "longDescription": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "dateInDevelopment": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "dateInUse": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "dateEndSupport": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "dateWithdrawn": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "nonMunicipalProvision": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "realisedWithIntermediaryModule": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "provider": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "service": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "registeredBy": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskSystem": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskRecordId": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskUrl": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskSyncedAt": { + "authorization": { + "read": [ + "authenticated" + ] + } + } + } + }, + "usage": { + "version": "1.5.5", + "properties": { + "provider": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "contactPerson": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "businessOwner": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "technicalOwner": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "participants": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "startDateAcquisition": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "startDatePlanned": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "startDateOutPhasing": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "startDateOutPhased": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "amefElements": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "elementRef": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "plannedReplacement": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "plannedReplacementDate": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "timeClassification": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "timeRationale": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "timeReviewDate": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "installedVersion": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskSystem": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskRecordId": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskUrl": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "serviceDeskSyncedAt": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "businessValue": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "technicalFit": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "riskScore": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "scoredOn": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "suggestedTimeClassification": { + "authorization": { + "read": [ + "authenticated" + ] + } + } + }, + "authorization": { + "read": [ + { + "group": "gebruik-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "gebruik-beheerder", + "match": { + "consumer": "$organisation" + } + }, + { + "group": "aanbod-beheerder", + "match": { + "_organisation": "$organisation" + } + }, + { + "group": "aanbod-beheerder", + "match": { + "provider": "$organisation" + } + }, + { + "group": "public", + "match": { + "publicationDate": { + "$lte": "$now" + } + } + } + ] + } + }, + "organization": { + "version": "0.5.3", + "properties": { + "contactsUid": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "xml": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "pki": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "registrationStatus": { + "authorization": { + "read": [ + "authenticated" + ] + } + }, + "mergedInto": { + "authorization": { + "read": [ + "authenticated" + ] + } + } + } + } + } + } +} diff --git a/lib/Settings/register.d/sharing-itsm-exchange.json b/lib/Settings/register.d/sharing-itsm-exchange.json new file mode 100644 index 000000000..25434a1c2 --- /dev/null +++ b/lib/Settings/register.d/sharing-itsm-exchange.json @@ -0,0 +1,218 @@ +{ + "components": { + "schemas": { + "usage": { + "version": "1.5.4", + "properties": { + "serviceDeskSystem": { + "type": "string", + "enum": [ + "topdesk", + "servicenow", + "file" + ], + "x-enum-labels": { + "topdesk": "TOPdesk", + "servicenow": "ServiceNow", + "file": "File import" + }, + "title": "Service desk", + "description": "The service desk this record is kept in step with.", + "visible": true, + "hideOnForm": true, + "facetable": true, + "order": 60 + }, + "serviceDeskRecordId": { + "type": "string", + "maxLength": 255, + "title": "Service desk record", + "description": "The record id in the service desk. The import matches on it first.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 61 + }, + "serviceDeskUrl": { + "type": "string", + "format": "uri", + "title": "Service desk link", + "description": "Opens the record in the service desk.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 62 + }, + "serviceDeskSyncedAt": { + "type": "string", + "format": "date-time", + "title": "Last synchronised", + "description": "When the service desk exchange last wrote this record.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 63 + }, + "installedVersion": { + "type": "string", + "maxLength": 100, + "title": "Installed version", + "description": "The version the service desk records as installed. The service desk owns it.", + "visible": true, + "facetable": false, + "order": 64 + }, + "publicationDate": { + "type": "string", + "format": "date-time", + "title": "Publication date", + "description": "Publish this application in use in the public catalogue from this date. Leave empty to keep it internal.", + "visible": true, + "facetable": false, + "order": 65 + } + } + }, + "connection": { + "version": "0.3.4", + "properties": { + "serviceDeskSystem": { + "type": "string", + "enum": [ + "topdesk", + "servicenow", + "file" + ], + "x-enum-labels": { + "topdesk": "TOPdesk", + "servicenow": "ServiceNow", + "file": "File import" + }, + "title": "Service desk", + "description": "The service desk this record is kept in step with.", + "visible": true, + "hideOnForm": true, + "facetable": true, + "order": 60 + }, + "serviceDeskRecordId": { + "type": "string", + "maxLength": 255, + "title": "Service desk record", + "description": "The record id in the service desk. The import matches on it first.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 61 + }, + "serviceDeskUrl": { + "type": "string", + "format": "uri", + "title": "Service desk link", + "description": "Opens the record in the service desk.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 62 + }, + "serviceDeskSyncedAt": { + "type": "string", + "format": "date-time", + "title": "Last synchronised", + "description": "When the service desk exchange last wrote this record.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 63 + } + } + }, + "catalogContract": { + "version": "0.1.4", + "properties": { + "serviceDeskSystem": { + "type": "string", + "enum": [ + "topdesk", + "servicenow", + "file" + ], + "x-enum-labels": { + "topdesk": "TOPdesk", + "servicenow": "ServiceNow", + "file": "File import" + }, + "title": "Service desk", + "description": "The service desk this record is kept in step with.", + "visible": true, + "hideOnForm": true, + "facetable": true, + "order": 60 + }, + "serviceDeskRecordId": { + "type": "string", + "maxLength": 255, + "title": "Service desk record", + "description": "The record id in the service desk. The import matches on it first.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 61 + }, + "serviceDeskUrl": { + "type": "string", + "format": "uri", + "title": "Service desk link", + "description": "Opens the record in the service desk.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 62 + }, + "serviceDeskSyncedAt": { + "type": "string", + "format": "date-time", + "title": "Last synchronised", + "description": "When the service desk exchange last wrote this record.", + "visible": true, + "hideOnForm": true, + "facetable": false, + "order": 63 + }, + "vendorReference": { + "type": "string", + "maxLength": 255, + "title": "Supplier reference", + "description": "The supplier's own number for this contract or licence agreement.", + "visible": true, + "facetable": false, + "order": 17 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "default": "EUR", + "title": "Currency", + "description": "The currency of the costs, as a three-letter ISO 4217 code.", + "visible": true, + "facetable": false, + "order": 18, + "example": "EUR" + }, + "supplier": { + "type": "object", + "$ref": "#/components/schemas/organization", + "objectConfiguration": { + "handling": "related-object" + }, + "title": "Supplier", + "description": "The organisation this contract or licence was bought from.", + "visible": true, + "facetable": false, + "order": 19 + } + } + } + } + } +} diff --git a/lib/Settings/register.d/topdesk-cmdb-import.json b/lib/Settings/register.d/topdesk-cmdb-import.json new file mode 100644 index 000000000..5fb300099 --- /dev/null +++ b/lib/Settings/register.d/topdesk-cmdb-import.json @@ -0,0 +1,200 @@ +{ + "components": { + "schemas": { + "module": { + "version": "0.3.8", + "properties": { + "externalId": { + "type": "string", + "title": "Source id", + "description": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.", + "maxLength": 100, + "visible": true, + "facetable": false, + "order": 60 + }, + "externalNumber": { + "type": "string", + "title": "Source number", + "description": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.", + "maxLength": 50, + "visible": true, + "facetable": false, + "order": 61 + }, + "externalKey": { + "type": "string", + "title": "Import key", + "description": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; only a Nextcloud admin can change it.", + "maxLength": 200, + "authorization": { + "update": ["admin"] + }, + "visible": true, + "facetable": false, + "table": { + "default": false + }, + "order": 62 + }, + "externalCreatedAt": { + "type": "string", + "format": "date", + "title": "Created in source", + "description": "The date the application was registered in the source system.", + "visible": true, + "facetable": false, + "order": 63 + }, + "externalModifiedAt": { + "type": "string", + "format": "date", + "title": "Changed in source", + "description": "The date the application was last changed in the source system.", + "visible": true, + "facetable": false, + "order": 64 + }, + "applicationType": { + "type": "string", + "title": "Application type", + "description": "The kind of application as the source system records it, such as the TOPdesk Applicatiesoort (Webapplicatie, Client/server, Saas). Kept as the source has it.", + "maxLength": 100, + "visible": true, + "facetable": true, + "order": 65 + }, + "bbnLevel": { + "title": "BBN level", + "description": "The BBN level (Baseline Informatiebeveiliging Overheid) this application is classified at. BBN2+ is a level the TOPdesk CMDB export uses.", + "enum": ["BBN2+"] + } + } + }, + "usage": { + "version": "1.5.6", + "configuration": { + "x-openregister-lifecycle": { + "transitions": { + "importAcquisition": { + "from": [ + "Planned", + "In production", + "To be phased out", + "Phased out" + ], + "to": "Acquisition", + "authorization": [ + "admin" + ], + "description": "Set the usage to \"Acquisition\" as the service desk (TOPdesk CMDB import) records it, whatever the current state. Only administrators: the import follows the source, other users follow the transitions above." + }, + "importPlanned": { + "from": [ + "Acquisition", + "In production", + "To be phased out", + "Phased out" + ], + "to": "Planned", + "authorization": [ + "admin" + ], + "description": "Set the usage to \"Planned\" as the service desk (TOPdesk CMDB import) records it, whatever the current state. Only administrators: the import follows the source, other users follow the transitions above." + }, + "importInProduction": { + "from": [ + "Acquisition", + "Planned", + "To be phased out", + "Phased out" + ], + "to": "In production", + "authorization": [ + "admin" + ], + "description": "Set the usage to \"In production\" as the service desk (TOPdesk CMDB import) records it, whatever the current state. Only administrators: the import follows the source, other users follow the transitions above." + }, + "importToBePhasedOut": { + "from": [ + "Acquisition", + "Planned", + "In production", + "Phased out" + ], + "to": "To be phased out", + "authorization": [ + "admin" + ], + "description": "Set the usage to \"To be phased out\" as the service desk (TOPdesk CMDB import) records it, whatever the current state. Only administrators: the import follows the source, other users follow the transitions above." + }, + "importPhasedOut": { + "from": [ + "Acquisition", + "Planned", + "In production", + "To be phased out" + ], + "to": "Phased out", + "authorization": [ + "admin" + ], + "description": "Set the usage to \"Phased out\" as the service desk (TOPdesk CMDB import) records it, whatever the current state. Only administrators: the import follows the source, other users follow the transitions above." + } + } + } + } + } + }, + "objects": [ + { + "@self": { + "register": "stackiq", + "schema": "module", + "slug": "voorbeeld-zaaksysteem", + "version": "0.0.1" + }, + "name": "Voorbeeld Zaaksysteem", + "type": "Application", + "longDescription": "Registreert en volgt zaken van intake tot archivering.", + "externalId": "APP-00001", + "externalNumber": "101", + "externalCreatedAt": "2023-07-04", + "externalModifiedAt": "2026-07-29", + "bbnLevel": "BBN2" + }, + { + "@self": { + "register": "stackiq", + "schema": "module", + "slug": "voorbeeld-afsprakenplanner", + "version": "0.0.1" + }, + "name": "Voorbeeld Afsprakenplanner", + "type": "Application", + "longDescription": "Laat inwoners online een afspraak maken bij de balie.", + "externalId": "APP-00002", + "externalNumber": "102", + "externalCreatedAt": "2022-03-16", + "externalModifiedAt": "2026-09-01", + "bbnLevel": "BBN1" + }, + { + "@self": { + "register": "stackiq", + "schema": "module", + "slug": "voorbeeld-belastingapplicatie", + "version": "0.0.1" + }, + "name": "Voorbeeld Belastingapplicatie", + "type": "Application", + "longDescription": "Berekent en verstuurt gemeentelijke belastingaanslagen.", + "externalId": "AIA-00003", + "externalNumber": "103", + "externalCreatedAt": "2024-01-15", + "externalModifiedAt": "2026-05-20", + "bbnLevel": "BBN2" + } + ] + } +} diff --git a/lib/Settings/register.d/value-assessment.json b/lib/Settings/register.d/value-assessment.json new file mode 100644 index 000000000..e7dedce9e --- /dev/null +++ b/lib/Settings/register.d/value-assessment.json @@ -0,0 +1,145 @@ +{ + "components": { + "schemas": { + "usage": { + "version": "1.5.4", + "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..7a2976760 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.7", + "changelog": "2.5.7: a contract no longer requires a catalogue service, because a licence bought for an application in use has none; the service desk exchange imports such licences (sharing-itsm-exchange). The fragment sharing-itsm-exchange.json adds the service desk reference to usage (1.5.4), connection (0.3.4) and catalogContract (0.1.4), and the licence fields vendorReference, currency and supplier to catalogContract. 2.5.6: contactsUid is no longer required on organization (0.5.2) and contactPerson (0.0.28). It is a link to the Nextcloud addressbook that the contacts sync fills in after the record exists (MigrateContactsToNc, OrganizationContactSyncJob), so a required column made OpenRegister create it NOT NULL and every seed organisation failed to save on a fresh install (the organization table stayed empty). OpenRegister drops the NOT NULL on the existing column when the schema syncs. 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": "", @@ -1789,17 +1791,14 @@ "x-schema-org": "schema:Person", "title": "Contact person", "description": "Contactgegevens van een persoon", - "version": "0.0.27", + "version": "0.0.28", "omschrijving": "", "icon": "AccountMultiple", - "required": [ - "contactsUid" - ], + "required": [], "properties": { "contactsUid": { "type": "string", "description": "Verwijzing (UID) naar de Nextcloud-contactpersoon in het adresboek (OCP\\Contacts\\IManager). Identiteit (naam, e-mail, telefoon) leeft in Nextcloud Contacts; dit record bewaart alleen de catalogus-specifieke rol/relatie.", - "required": true, "visible": true, "facetable": false, "title": "Contact-UID", @@ -2023,11 +2022,10 @@ "x-schema-org": "schema:Organization", "title": "Organization", "description": "An organisation that offers provisions. Absorbs the former ArchiMate `organization` schema: its identity and statutory identifiers (name, summary, description, oin, tooi, rsin, pki, image) and its ArchiMate round-trip `xml` are declared here, so there is one organisation schema rather than two that shared no property.", - "version": "0.5.1", + "version": "0.5.2", "omschrijving": "", "icon": "OfficeBuildingOutline", "required": [ - "contactsUid", "type" ], "properties": { @@ -2146,7 +2144,6 @@ "contactsUid": { "description": "Verwijzing (UID) naar de Nextcloud-contactpersoon van het type organisatie in het adresboek (OCP\\Contacts\\IManager). Identiteit (naam, e-mail, website, logo, CBS/KvK-code) leeft in Nextcloud Contacts; dit record bewaart alleen de catalogus-specifieke relatie/rol.", "type": "string", - "required": true, "visible": true, "order": 0, "maxLength": 255, @@ -2656,7 +2653,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 +2712,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 +2893,7 @@ "To be phased out", "Phased out" ], - "facetable": false, + "facetable": true, "title": "Status", "example": "Bijvoorbeeld: Gepland" }, @@ -3199,7 +3198,7 @@ ] }, "configuration": { - "objectNameField": "consumer", + "objectNameField": "{{ module }} ({{ consumer }})", "objectDescriptionField": "module", "allowFiles": true, "allowedTags": [ @@ -3210,37 +3209,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,11 +3266,10 @@ }, "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": [ - "service", "usage", "startDate", "contractNumber", @@ -3290,7 +3288,6 @@ "description": "De dienst waarop dit contract betrekking heeft", "type": "object", "facetable": false, - "required": true, "title": "Service", "order": 12, "objectConfiguration": { @@ -3530,30 +3527,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 +3562,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 +3576,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 +3607,7 @@ "description": "kies de techniek van de koppeling", "type": "string", "order": 1, - "facetable": false, + "facetable": true, "title": "Transport protocol", "enum": [ "n/a", @@ -3627,7 +3624,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 +3675,7 @@ "type": "string", "order": 8, "title": "Data exchange direction", - "facetable": false, + "facetable": true, "required": true, "enum": [ "AtoB", @@ -3728,7 +3725,7 @@ "title": "Inter-municipal facility", "objectConfiguration": { "handling": "related-object", - "queryParams": "gemmaType=Buitengemeentenlijke voorziening" + "queryParams": "gemmaType=Buitengemeentelijke voorziening" }, "$ref": "#/components/schemas/element" }, @@ -3920,36 +3917,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 +6984,6 @@ "BSD License (Berkeley Software Distribution)", "European Union Public Licence (EUPL), version 1.2" ], - "licence": "License", "title": "License" }, "referenceComponents": { @@ -7680,7 +7676,7 @@ }, "title": "Application version", "description": "Schema voor applicatieversies", - "version": "0.1.4", + "version": "0.1.5", "omschrijving": "", "icon": "ViewModule", "required": [], @@ -7887,31 +7883,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,36 +8051,674 @@ "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": [ + { + "@self": { + "register": "stackiq", + "schema": "organization", + "slug": "servicecenter-rijnland", + "version": "0.0.1" + }, + "name": "Servicecenter Rijnland", + "oin": "00000001001234567890", + "type": "Collaboration", + "status": "Active" + }, + { + "@self": { + "register": "stackiq", + "schema": "organization", + "slug": "gemeente-leiden", + "version": "0.0.1" + }, + "name": "Gemeente Leiden", + "oin": "00000001001234567891", + "type": "Municipality", + "status": "Active" + }, + { + "@self": { + "register": "stackiq", + "schema": "organization", + "slug": "gemeente-leiderdorp", + "version": "0.0.1" + }, + "name": "Gemeente Leiderdorp", + "oin": "00000001001234567892", + "type": "Municipality", + "status": "Active" + }, + { + "@self": { + "register": "stackiq", + "schema": "organization", + "slug": "gblt", + "version": "0.0.1" + }, + "name": "Gemeentebelastingen Coevorden Hardenberg (GBLT)", + "oin": "00000001001234567893", + "type": "Collaboration", + "status": "Active" + }, + { + "@self": { + "register": "stackiq", + "schema": "organization", + "slug": "gemeente-delft", + "version": "0.0.1" + }, + "name": "Gemeente Delft", + "oin": "00000001001234567894", + "type": "Municipality", + "status": "Active" + }, { "@self": { "register": "stackiq", "schema": "usage", "slug": "gebruik-topdesk-gem-leiden-deelnemers", - "version": "0.0.1" + "version": "1.0.0" }, "name": "Topdesk bij Servicecenter Rijnland (SSC)", - "status": "in-gebruik", + "status": "In production", "elementRef": "topdesk-ssc-rijnland", - "consumer": { - "name": "Servicecenter Rijnland", - "oin": "00000001001234567890", - "slug": "servicecenter-rijnland" - }, + "consumer": "@ref:organization:servicecenter-rijnland", "module": "topdesk-itsm", "participants": [ - { - "name": "Gemeente Leiden", - "oin": "00000001001234567891", - "slug": "gemeente-leiden" - }, - { - "name": "Gemeente Leiderdorp", - "oin": "00000001001234567892", - "slug": "gemeente-leiderdorp" - } + "@ref:organization:gemeente-leiden", + "@ref:organization:gemeente-leiderdorp" ] }, { @@ -8092,23 +8726,15 @@ "register": "stackiq", "schema": "usage", "slug": "gebruik-key2-gem-leiden-deelnemer", - "version": "0.0.1" + "version": "1.0.0" }, "name": "KEY2 Burgerzaken gedeeld via GBLT", - "status": "in-gebruik", + "status": "Planned", "elementRef": "key2-burgerzaken-gblt", - "consumer": { - "name": "Gemeentebelastingen Coevorden Hardenberg (GBLT)", - "oin": "00000001001234567893", - "slug": "gblt" - }, + "consumer": "@ref:organization:gblt", "module": "key2-burgerzaken", "participants": [ - { - "name": "Gemeente Leiden", - "oin": "00000001001234567891", - "slug": "gemeente-leiden" - } + "@ref:organization:gemeente-leiden" ] }, { @@ -8116,16 +8742,12 @@ "register": "stackiq", "schema": "usage", "slug": "gebruik-suite4-gem-delft-eigenaar", - "version": "0.0.1" + "version": "1.0.0" }, "name": "Suite4 Schuldhulpverlening - eigenaar Gemeente Delft", - "status": "in-gebruik", + "status": "In production", "elementRef": "suite4-schuldhulp-delft", - "consumer": { - "name": "Gemeente Delft", - "oin": "00000001001234567894", - "slug": "gemeente-delft" - }, + "consumer": "@ref:organization:gemeente-delft", "module": "suite4-schuldhulpverlening", "participants": [] } diff --git a/lib/Settings/stackiq_mock_register.json b/lib/Settings/stackiq_mock_register.json index 392c5ac03..d1bf7bb44 100644 --- a/lib/Settings/stackiq_mock_register.json +++ b/lib/Settings/stackiq_mock_register.json @@ -26,7287 +26,6 @@ "description": "Demo data for AMEF. Generated from the register's own schemas — see hydra-gates/scripts/lib/generate_mock_register.py." } }, - "schemas": { - "software-review": { - "properties": { - "auteur": { - "type": "string", - "description": "Display name of the submitting user. Stamped server-side by ReviewService from the authenticated Nextcloud session at submission time — a client-supplied value is always discarded and never persisted.", - "visible": true, - "facetable": false, - "title": "Auteur", - "order": 10, - "example": "Bijvoorbeeld: Jan Jansen" - }, - "status": { - "type": "string", - "enum": [ - "pending", - "approved", - "rejected" - ], - "default": "pending", - "description": "Moderation status. Every new review is forced to 'pending' server-side by ReviewService regardless of client input; only an admin approval/rejection decision (ModerationService, reusing the organisatie moderation pattern) may transition it. The public RBAC read rule below only ever matches 'approved'.", - "visible": true, - "facetable": true, - "title": "Status", - "order": 11, - "example": "Bijvoorbeeld: pending" - }, - "name": { - "description": "Naam van de beoordeling", - "type": "string", - "visible": true, - "required": true, - "facetable": false, - "title": "Name", - "order": 1 - }, - "shortDescription": { - "description": "Korte beschrijving van de beoordeling", - "type": "string", - "maxLength": 255, - "visible": true, - "facetable": false, - "title": "Summary", - "order": 2 - }, - "longDescription": { - "description": "Uitgebreide beschrijving van de beoordeling", - "type": "string", - "format": "markdown", - "visible": true, - "facetable": false, - "title": "Description", - "order": 3 - }, - "rating": { - "description": "Waardering van 1 tot en met 10", - "type": "integer", - "minimum": 1, - "maximum": 10, - "visible": true, - "required": true, - "facetable": false, - "title": "Rating", - "order": 4 - }, - "modules": { - "description": "Optioneel: specifieke applicaties die beoordeeld worden", - "type": "array", - "visible": true, - "facetable": false, - "title": "Applications", - "order": 6, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "inversedBy": "software-review" - } - }, - "diensten": { - "description": "Optioneel: specifieke diensten die beoordeeld worden", - "type": "array", - "visible": true, - "facetable": false, - "title": "Services", - "order": 7, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/catalogService" - } - }, - "koppelingen": { - "description": "Optioneel: specifieke koppelingen die beoordeeld worden", - "type": "array", - "visible": true, - "facetable": false, - "title": "Connections", - "order": 8, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/connection" - } - }, - "usage": { - "description": "Optioneel: het specifieke gebruik dat beoordeeld wordt", - "type": "object", - "visible": true, - "facetable": false, - "title": "Usage", - "order": 9, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/usage" - } - }, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - { - "group": "public", - "match": { - "status": "approved" - } - }, - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisatie-beheerder", - "organisaties-beheerder", - "gebruik-raadpleger" - ], - "update": [ - "software-catalog-admins", - { - "group": "organisatie-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "organisaties-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "functioneel-beheerder", - "match": { - "_organisation": "$organisation" - } - } - ], - "delete": [ - "software-catalog-admins" - ] - }, - "required": [ - "name", - "rating" - ], - "uri": null, - "slug": "software-review", - "x-openregister-notifications": { - "review-submitted": { - "trigger": { - "type": "created" - }, - "enabled": true, - "channels": [ - "nc-notification" - ], - "recipients": [ - { - "kind": "object-acl", - "permission": "manage" - }, - { - "kind": "groups", - "groups": [ - "software-catalog-admins" - ] - } - ], - "subject": { - "nl": "Nieuwe beoordeling: {{naam}} (waardering {{waardering}})", - "en": "New review: {{naam}} (rating {{waardering}})" - } - } - }, - "title": "Assessment", - "description": "Schema voor beoordelingen en waarderingen van applicaties en diensten. Dit schema is onderdeel van het vastgestelde datamodel maar wordt niet daadwerkelijk in de applicatie gebruikt.", - "version": "0.1.3", - "omschrijving": "", - "icon": "Star", - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "shortDescription", - "objectDescriptionField": "longDescription", - "autoPublish": false - } - }, - "bioMeasure": { - "uri": null, - "slug": "bioMeasure", - "title": "BIO measure", - "description": "Een BIO 2.0 (Baseline Informatiebeveiliging Overheid) maatregel uit de gepubliceerde maatregelenlijst. Wordt gebruikt als selectiebron voor compliance-registraties (compliancy.bioMaatregel) en het BIO-dekkingsrapport.", - "version": "0.0.1", - "omschrijving": "", - "icon": "ShieldLockOutline", - "required": [ - "code", - "name" - ], - "properties": { - "code": { - "description": "De BIO-maatregelcode (bijvoorbeeld 5.1.1)", - "type": "string", - "order": 1, - "facetable": true, - "required": true, - "title": "Code", - "maxLength": 20, - "table": { - "default": true - }, - "example": "Bijvoorbeeld: 5.1.1" - }, - "name": { - "description": "De naam van de BIO-maatregel", - "type": "string", - "order": 2, - "facetable": false, - "required": true, - "title": "Name", - "maxLength": 255, - "table": { - "default": true - }, - "example": "Bijvoorbeeld: Toegangsbeveiligingsbeleid" - }, - "omschrijving": { - "description": "Een uitgebreide omschrijving van de maatregel", - "type": "string", - "order": 3, - "facetable": false, - "format": "markdown", - "title": "Description", - "maxLength": 5000, - "example": "Bijvoorbeeld: De organisatie stelt een toegangsbeveiligingsbeleid vast en onderhoudt dit periodiek." - }, - "thema": { - "description": "Het BIO-thema waartoe de maatregel behoort", - "type": "string", - "order": 4, - "facetable": true, - "title": "Theme", - "table": { - "default": true - }, - "maxLength": 200, - "example": "Bijvoorbeeld: Toegangsbeveiliging" - }, - "bioVersion": { - "description": "De BIO-versie waarin deze maatregel is gepubliceerd", - "type": "string", - "order": 5, - "facetable": true, - "title": "BIO version", - "default": "BIO 2.0", - "maxLength": 50, - "example": "Bijvoorbeeld: BIO 2.0" - }, - "bbnLevel": { - "description": "De BBN-niveaus (Baseline Informatiebeveiliging Overheid) waarop deze maatregel van toepassing is", - "type": "array", - "order": 6, - "facetable": true, - "title": "BBN level", - "table": { - "default": true - }, - "items": { - "type": "string", - "enum": [ - "BBN1", - "BBN2", - "BBN3" - ] - }, - "example": [ - "BBN2", - "BBN3" - ] - }, - "bron": { - "description": "Verwijzing naar de gepubliceerde maatregel (baselineinformatiebeveiligingoverheid.nl)", - "type": "string", - "format": "url", - "order": 7, - "facetable": false, - "title": "Source", - "maxLength": 500, - "example": "Bijvoorbeeld: https://www.baselineinformatiebeveiligingoverheid.nl" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "thema", - "objectDescriptionField": "omschrijving", - "autoPublish": true - } - }, - "compliancy": { - "uri": null, - "slug": "compliancy", - "title": "Compliancy", - "description": "Schema voor compliancy en standaard ondersteuning", - "version": "0.0.12", - "omschrijving": "", - "icon": "CheckCircle", - "required": [], - "properties": { - "standardVersion": { - "description": "Standaardversie die door deze compliance wordt ondersteund", - "type": "object", - "order": 1, - "title": "Standard version", - "visible": true, - "facetable": false, - "objectConfiguration": { - "handling": "related-object", - "queryParams": "gemmaType=standaardversie" - }, - "$ref": "#/components/schemas/element" - }, - "standardGemma": { - "description": "Het gemma id van de standaardversie die door deze compliance wordt ondersteund", - "type": "string", - "order": 1, - "title": "GEMMA standard", - "visible": true, - "facetable": false - }, - "bioMeasure": { - "description": "BIO-maatregel die door deze compliance wordt ondersteund. Een compliancy record koppelt aan precies een van standaardversie of bioMaatregel, nooit beide.", - "type": "object", - "order": 2, - "title": "BIO measure", - "visible": true, - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/bioMeasure" - }, - "module": { - "description": "De applicatie waarvan de compliance wordt geregistreerd", - "type": "object", - "order": 4, - "title": "Application", - "visible": true, - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "inversedBy": "compliancy" - }, - "evidence": { - "description": "Bewijsstuk voor de compliance (bijvoorbeeld testrapport of certificaat)", - "type": "file", - "format": "base64", - "order": 5, - "title": "Evidence", - "visible": true, - "facetable": false, - "fileConfiguration": { - "allowedMimeTypes": [ - "application/pdf", - "image/jpeg", - "image/png", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ], - "maxSize": 10485760 - } - }, - "url": { - "description": "URL naar het bewijs van de compliance", - "type": "string", - "format": "url", - "order": 1, - "title": "URL", - "visible": true, - "facetable": false - }, - "evidenceReference": { - "description": "Nextcloud Files-verwijzing naar het bewijsstuk (link, niet opslaan). De voorkeursmanier om nieuw bewijs te koppelen; het legacy base64-veld 'evidence' blijft leesbaar voor bestaande records.", - "type": "string", - "order": 6, - "title": "Evidence (Nextcloud Files)", - "visible": true, - "facetable": false - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "objectNameField": "module", - "objectSummaryField": "standardVersion", - "objectDescriptionField": "url", - "allowFiles": true, - "allowedTags": [ - "testraport" - ], - "autoPublish": true - } - }, - "connection": { - "uri": null, - "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", - "omschrijving": "", - "icon": "TransitConnectionVariant", - "required": [ - "name" - ], - "properties": { - "name": { - "description": "Naam van de koppeling, default waarde [AppA] [<-richting->] [AppB]", - "type": "string", - "order": 1, - "required": true, - "facetable": false, - "title": "Name", - "default": "{{ moduleA }} {{ gegevensuitwisselingRichting | map: AnaarB=→, BnaarA=←, bi-directioneel=↔ }} {{ moduleB | buitengemeentelijkVoorziening }}", - "defaultBehavior": "falsy", - "table": { - "default": true - }, - "example": "Bijvoorbeeld: API Koppeling" - }, - "shortDescription": { - "description": "Een korte omschrijving van de koppeling (maximaal 256 karakters).", - "type": "string", - "table": { - "default": true - }, - "order": 5, - "facetable": false, - "title": "Short description", - "example": "Bijvoorbeeld: Korte beschrijving van de koppeling" - }, - "longDescription": { - "description": "Uitgebreide beschrijving van de koppeling", - "type": "string", - "format": "markdown", - "order": 6, - "facetable": false, - "title": "Long description", - "example": "Bijvoorbeeld: Uitgebreide beschrijving van de koppeling" - }, - "type": { - "description": "kies de techniek van de koppeling", - "type": "string", - "order": 1, - "facetable": false, - "title": "Transport protocol", - "enum": [ - "n/a", - "file transfer", - "digikoppeling", - "message que", - "upload to portal", - "webservices", - "api" - ], - "example": "Bijvoorbeeld: api" - }, - "status": { - "description": "Geef de status van de koppeling aan, default 'in gebruik'", - "type": "string", - "order": 3, - "facetable": false, - "title": "Status", - "default": "in use", - "table": { - "default": true - }, - "enum": [ - "in development", - "in use", - "end of support", - "withdrawn" - ] - }, - "dateInDevelopment": { - "description": "Startdatum van de ontwikkelingsfase", - "type": "string", - "format": "date", - "order": 4, - "facetable": false, - "title": "Date in development", - "example": "Bijvoorbeeld: 2025-01-01" - }, - "dateInUse": { - "description": "Startdatum van gebruik", - "type": "string", - "format": "date", - "order": 5, - "facetable": false, - "title": "Date in use" - }, - "dateEndSupport": { - "description": "Startdatum einde ondersteuning", - "type": "string", - "format": "date", - "order": 6, - "facetable": false, - "title": "End of support date" - }, - "dateWithdrawn": { - "description": "Datum waarop de koppeling teruggetrokken is", - "type": "string", - "format": "date", - "order": 7, - "facetable": false, - "title": "Date withdrawn" - }, - "dataExchangeDirection": { - "description": "Kies welke applicatie de gegevens verzendt en welke deze ontvangt.", - "type": "string", - "order": 8, - "title": "Data exchange direction", - "facetable": false, - "required": true, - "enum": [ - "AtoB", - "BtoA", - "bi-directional" - ] - }, - "moduleA": { - "description": "Geselecteerde applicatie, niet wijzigbaar", - "type": "object", - "order": 9, - "facetable": false, - "required": false, - "table": { - "default": true - }, - "title": "Application A", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "inversedBy": "connection" - }, - "moduleB": { - "description": "Kies de applicatie waarmee gekoppeld wordt.", - "type": "object", - "required": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "order": 10, - "facetable": false, - "table": { - "default": true - }, - "title": "Application B" - }, - "nonMunicipalProvision": { - "description": "Buitengemeentelijke voorziening waarmee gekoppeld wordt", - "type": "object", - "order": 11, - "facetable": false, - "table": { - "default": true - }, - "title": "Inter-municipal facility", - "objectConfiguration": { - "handling": "related-object", - "queryParams": "gemmaType=Buitengemeentenlijke voorziening" - }, - "$ref": "#/components/schemas/element" - }, - "standardVersions": { - "description": "Kies de standaardversie(s) waarop de koppeling is gebaseerd.", - "type": "array", - "order": 12, - "facetable": false, - "title": "Standard version", - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object", - "queryParams": "gemmaType=standaardversie" - }, - "$ref": "#/components/schemas/element" - } - }, - "realisedWithIntermediaryModule": { - "description": "Kies de intermediair, bijvoorbeeld een servicebus, via welke de koppeling loopt.", - "type": "object", - "order": 13, - "facetable": false, - "title": "Intermediary", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "inversedBy": "connection" - }, - "provider": { - "referenceSemanticType": "https://openregister.app/ns#Vendor", - "description": "De aanbieder van deze koppeling", - "type": "object", - "visible": false, - "order": 14, - "table": { - "default": true - }, - "facetable": false, - "title": "Provider", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organization" - }, - "service": { - "description": "De dienst die deze koppeling gebruikt", - "type": "object", - "visible": false, - "order": 15, - "facetable": false, - "title": "Service", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/catalogService", - "inversedBy": "koppelingen" - }, - "registeredBy": { - "description": "Technische property die wordt gebruikt voor RBAC-doeleinden (toegangsbeheer op basis van partijtype)", - "type": "string", - "order": 16, - "facetable": true, - "title": "Registered by", - "visible": false, - "hideOnForm": true, - "enum": [ - "Municipality", - "Application", - "Collaboration", - "Supplier" - ], - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "integrationType": { - "description": "Afgeleid gegeven: extern als de koppeling met een buitengemeentelijke voorziening is, anders intern", - "type": "string", - "order": 17, - "facetable": true, - "title": "Connection type", - "visible": false, - "hideOnForm": true, - "default": "{{ buitengemeentelijkVoorziening | ifFilled: external, internal }}", - "defaultBehavior": "always", - "enum": [ - "external", - "internal" - ] - }, - "publicationDate": { - "description": "Publication date as open data. Anonymous (public) readers can see this koppeling once publicatiedatum is set and not in the future. Set by the open-data publish action; cleared by depublish.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 50, - "facetable": false, - "title": "Publication date" - }, - "depublicationDate": { - "description": "Depublication date. When set (and not in the future) the koppeling is withdrawn from the open-data surface. Set by the depublish action.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 51, - "facetable": false, - "title": "Unpublication date" - } - }, - "archive": [], - "source": "internal", - "hardValidation": true, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "softwarecatalog", - "application": "softwarecatalog", - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "read": [ - { - "group": "gebruik-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "public", - "match": { - "publicationDate": { - "$lte": "$now" - } - } - }, - { - "group": "aanbod-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "aanbod-beheerder", - "match": { - "provider": "$organisation" - } - } - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ] - }, - "configuration": { - "autoPublish": false, - "objectNameField": "{{ moduleA }} {{ gegevensuitwisselingRichting | map: AnaarB=→, BnaarA=←, bi-directioneel=↔ }} {{ moduleB | buitengemeentelijkVoorziening }}", - "objectSummaryField": "shortDescription", - "objectDescriptionField": "longDescription", - "x-openregister-lifecycle": { - "field": "status", - "initial": "in ontwikkeling", - "final": [ - "teruggetrokken" - ], - "transitions": { - "release": { - "from": [ - "in ontwikkeling" - ], - "to": "in gebruik", - "description": "Release the koppeling." - }, - "sunset": { - "from": [ - "in gebruik" - ], - "to": "einde ondersteuning", - "description": "Mark the koppeling as end-of-support." - }, - "withdraw": { - "from": [ - "in gebruik", - "einde ondersteuning" - ], - "to": "teruggetrokken", - "description": "Withdraw the koppeling." - } - } - } - } - }, - "contactPerson": { - "uri": null, - "slug": "contactPerson", - "x-schema-org": "schema:Person", - "title": "Contact person", - "description": "Contactgegevens van een persoon", - "version": "0.0.27", - "omschrijving": "", - "icon": "AccountMultiple", - "required": [ - "contactsUid" - ], - "properties": { - "contactsUid": { - "type": "string", - "description": "Verwijzing (UID) naar de Nextcloud-contactpersoon in het adresboek (OCP\\Contacts\\IManager). Identiteit (naam, e-mail, telefoon) leeft in Nextcloud Contacts; dit record bewaart alleen de catalogus-specifieke rol/relatie.", - "required": true, - "visible": true, - "facetable": false, - "title": "Contact-UID", - "order": 0, - "maxLength": 255, - "example": "Bijvoorbeeld: 3f2b0c5e-1234-4a9b-8c1d-9f0e1a2b3c4d" - }, - "role": { - "description": "Functie van de medewerker", - "type": "string", - "visible": true, - "minLength": null, - "maxLength": 100, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Job title", - "order": 4, - "example": "Bijvoorbeeld: Beheerder" - }, - "organization": { - "type": "object", - "title": "Organization", - "description": "De organisatie waartoe deze contactpersoon behoort", - "visible": false, - "hideOnCollection": true, - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organization", - "order": 9 - }, - "notifications": { - "type": "array", - "title": "Notifications", - "description": "Lijst van notificaties voor deze contactpersoon", - "facetable": false, - "visible": false, - "hideOnCollection": true, - "items": { - "type": "string" - }, - "order": 11, - "example": "Bijvoorbeeld: ['Nieuw in organisatie', 'Gewijzigd in de organisatie']" - }, - "roles": { - "description": "De rollen die deze contactpersoon heeft", - "title": "Roles", - "type": "array", - "visible": true, - "facetable": false, - "order": 7, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "register": "", - "writeBack": false, - "removeAfterWriteBack": false, - "items": { - "type": "string", - "enum": [ - "Aanbod-beheerder", - "Gebruik-beheerder", - "Gebruik-raadpleger", - "Functioneel-beheerder", - "Organisatie-beheerder" - ] - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "example": "Bijvoorbeeld: [\"Aanbod-beheerder\", \"Functioneel-beheerder\"]", - "authorization": { - "update": [ - "gebruik-beheerder", - "admin" - ] - } - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - "gebruik-beheerder", - "gebruik-raadpleger", - { - "group": "aanbod-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "ambtenaar", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "functioneel-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "organisatie-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "organisaties-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "software-catalog-users", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "software-catalog-admins", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "vng-raadpleger", - "match": { - "_organisation": "$organisation" - } - } - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "contactsUid", - "objectDescriptionField": "role", - "autoPublish": true - } - }, - "catalogContract": { - "properties": { - "approvalDecisionId": { - "type": "string", - "description": "PROJECTION FIELD. The id of the decidesk Decision (decisionType contract / contract-renewal) raised for this contract's approval/sign-off, resolved via the ADR-019 integration registry. Empty until the contract is submitted for approval. stackiq never authors an approval decision locally; it raises it in decidesk and stores the returned id here for outcome reconciliation.", - "facetable": false, - "title": "Approval decision id", - "order": 20, - "example": "Bijvoorbeeld: dec-2025-0042" - }, - "approvalState": { - "type": "string", - "enum": [ - "none", - "pending", - "approved", - "rejected" - ], - "default": "none", - "description": "PROJECTION of the decidesk outcome for the approval/renewal decision — distinct from the catalog lifecycle field `status`. `none` = no decision raised; `pending` = a decidesk Decision is open; `approved` = decidesk reported an adopting outcome (this is the ONLY thing that drives the `In onderhandeling -> Actief` transition on `status`); `rejected` = decidesk rejected/withdrew (status stays `In onderhandeling`). stackiq NEVER sets `status = Actief` on its own authority — it is always a projection of an `approved` decidesk outcome. The date-driven `Actief -> Verlopen` expiry transition is catalog-local and is NOT a decision delegated to decidesk.", - "facetable": false, - "title": "Approval state", - "order": 21, - "example": "Bijvoorbeeld: pending" - }, - "service": { - "description": "De dienst waarop dit contract betrekking heeft", - "type": "object", - "facetable": false, - "required": true, - "title": "Service", - "order": 12, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/catalogService" - }, - "usage": { - "description": "Het gebruik van de voorziening waarop dit contract betrekking heeft", - "type": "object", - "facetable": false, - "required": true, - "title": "Usage", - "order": 13, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/usage" - }, - "startDate": { - "type": "string", - "format": "date", - "description": "De startdatum van het contract", - "facetable": false, - "required": true, - "title": "Start date", - "order": 10, - "example": "Bijvoorbeeld: 2025-01-01" - }, - "endDate": { - "type": "string", - "format": "date", - "description": "De einddatum van het contract (indien van toepassing)", - "facetable": false, - "title": "End date", - "order": 6, - "example": "Bijvoorbeeld: 2025-12-31" - }, - "contractNumber": { - "type": "string", - "description": "Het referentienummer van het contract", - "facetable": false, - "required": true, - "title": "Contract number", - "order": 3, - "example": "Bijvoorbeeld: CON-2025-001" - }, - "contractType": { - "type": "string", - "enum": [ - "SLA", - "Licence", - "Maintenance" - ], - "description": "Het type contract", - "facetable": false, - "required": true, - "title": "Contract type", - "order": 4, - "example": "Bijvoorbeeld: SLA" - }, - "cost": { - "type": "number", - "description": "De kosten verbonden aan het contract", - "facetable": false, - "title": "Costs", - "order": 7, - "example": "Bijvoorbeeld: 1000.00" - }, - "costPeriod": { - "type": "string", - "enum": [ - "Monthly", - "Annually", - "One-off" - ], - "description": "De periode waarop de kosten betrekking hebben", - "facetable": false, - "title": "Cost period", - "order": 8, - "example": "Bijvoorbeeld: Jaarlijks" - }, - "contactPersonProvider": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "Name", - "description": "Full name of the supplier contact person." - }, - "email": { - "type": "string", - "title": "Email Address", - "description": "Email address of the supplier contact person." - } - }, - "description": "De contactpersoon bij de aanbieder", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "title": "Contact person (provider)", - "order": 1 - }, - "contactPersonUser": { - "type": "object", - "properties": { - "name": { - "type": "string", - "title": "Name", - "description": "Full name of the user organisation contact person." - }, - "email": { - "type": "string", - "title": "Email Address", - "description": "Email address of the user organisation contact person." - } - }, - "description": "De contactpersoon bij de gebruiker", - "facetable": false, - "objectConfiguration": { - "handling": "nested-object" - }, - "title": "Contact person (user)", - "order": 2 - }, - "documentReference": { - "type": "string", - "description": "Referentie naar het contractdocument", - "facetable": false, - "title": "Document reference", - "order": 5, - "example": "Bijvoorbeeld: CON-2025-001.pdf" - }, - "status": { - "type": "string", - "enum": [ - "Active", - "Expired", - "In negotiation" - ], - "description": "De status van het contract", - "facetable": false, - "required": true, - "title": "Status", - "order": 11, - "example": "Bijvoorbeeld: Actief" - }, - "opmerkingen": { - "type": "string", - "description": "Aanvullende informatie over het contract", - "facetable": false, - "title": "Remarks", - "order": 9, - "example": "Bijvoorbeeld: Aanvullende opmerkingen over het contract" - }, - "decisions": { - "type": "array", - "title": "Decisions", - "description": "Approval/renewal decisions for this contract. References decidesk Decision objects (ADR-066); Decidesk owns the making and eIDAS signing.", - "x-external-register": "decidesk", - "items": { - "type": "string", - "format": "uuid", - "x-external-register": "decidesk" - }, - "referenceType": "decision", - "x-allow-create": true - } - }, - "required": [ - "catalogService", - "usage", - "startDate", - "contractNumber", - "contractType", - "status" - ], - "uri": null, - "slug": "catalogContract", - "x-openregister-notifications": { - "contract-expiry": { - "trigger": { - "type": "scheduled", - "intervalSec": 86400, - "filter": { - "status": { - "operator": "equals", - "value": "Actief" - }, - "endDate": { - "operator": "withinNext", - "value": "P90D" - } - } - }, - "enabled": true, - "channels": [ - "nc-notification", - "email" - ], - "recipients": [ - { - "kind": "groups", - "groups": [ - "software-catalog-admins" - ] - }, - { - "kind": "object-acl", - "permission": "manage" - } - ], - "subject": { - "nl": "Contract verloopt: {{contractNummer}} (einddatum {{eindDatum}})", - "en": "Contract expiring: {{contractNummer}} (end date {{eindDatum}})" - } - } - }, - "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", - "omschrijving": "", - "icon": "FileSign", - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - "ambtenaar", - "software-catalog-admins", - { - "group": "functioneel-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "gebruik-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "vng-raadpleger", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "software-catalog-users", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "organisatie-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "organisaties-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "gebruik-raadpleger", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "aanbod-beheerder", - "match": { - "_organisation": "$organisation" - } - } - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "contractNumber", - "objectDescriptionField": "contractType", - "autoPublish": false, - "linkedTypes": [ - "decidesk-decisions" - ], - "x-openregister-lifecycle": { - "field": "status", - "initial": "In onderhandeling", - "final": [ - "Verlopen" - ], - "transitions": { - "sign": { - "from": [ - "In onderhandeling" - ], - "to": "Actief", - "description": "Sign and activate the contract." - }, - "expire": { - "from": [ - "Actief" - ], - "to": "Verlopen", - "description": "Mark the contract as expired." - }, - "renegotiate": { - "from": [ - "Verlopen" - ], - "to": "In onderhandeling", - "description": "Re-open negotiation on an expired contract." - } - } - } - } - }, - "element": { - "uri": null, - "slug": "element", - "title": "Element", - "description": "AMEF Element - Architectuur elementen uit het ArchiMate model", - "version": "0.0.11", - "omschrijving": "", - "icon": "Cube", - "required": [ - "identifier", - "type", - "properties" - ], - "properties": { - "identifier": { - "description": "De identifier van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-009fa62f25844aa3a87d252bf2b6bb0c", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "identifier", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "type": { - "description": "Het type van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Capability", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "type", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "name": { - "description": "De naam van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Publiceren en gebruiken van informatie over datadiensten", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "name", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "nameLong": { - "description": "De name-language van dit Element", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "nameLong", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "documentation": { - "description": "De documentation van dit Element", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Dienstenafnemers moeten in online catalogi kunnen opvragen welke diensten, met welke kenmerken, door dienstenaanbieder worden aangeboden. \\nOnder andere ontwikkelaars hebben baat bij informatie over beschikbare diensten en de vereisten voor het gebruik van de dienst (bijv. specificatie van een dienst conform de OAS-standaard).", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "documentation", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "documentationLong": { - "description": "De documentation-language van dit Element", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "documentationLong", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "properties": { - "description": "De properties van dit Element", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/property-definition", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "properties", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaType": { - "description": "Het gemma type van dit Element", - "type": "string", - "facetable": false, - "order": 10, - "title": "gemmaType", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaThema": { - "description": "Het gemma thema van dit Element", - "type": "string", - "facetable": false, - "order": 10, - "title": "gemmaThema", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaUrl": { - "description": "De gemma url van dit Element", - "type": "string", - "facetable": false, - "order": 10, - "title": "gemmaUrl", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "objectId": { - "description": "Object ID van dit Element", - "type": "string", - "facetable": false, - "title": "objectId", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "objectIdSync": { - "description": "Object ID sync van dit Element", - "type": "string", - "facetable": false, - "title": "objectIdSync", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaStatus": { - "description": "GEMMA status van dit Element", - "type": "string", - "facetable": true, - "title": "gemmaStatus", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaSubtype": { - "description": "GEMMA subtype van dit Element", - "type": "string", - "facetable": true, - "title": "gemmaSubtype", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaSorting": { - "description": "GEMMA sortering van dit Element", - "type": "string", - "facetable": false, - "title": "gemmaSorting", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaNotes": { - "description": "GEMMA toelichting van dit Element", - "type": "string", - "facetable": false, - "title": "gemmaNotes", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaGgmStatus": { - "description": "GEMMA-GGM status van dit Element", - "type": "string", - "facetable": false, - "title": "gemmaGgmStatus", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "abbreviation": { - "description": "Afkorting van dit Element", - "type": "string", - "facetable": false, - "title": "abbreviation", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "alternateName": { - "description": "Alternate name van dit Element", - "type": "string", - "facetable": false, - "title": "alternateName", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "architectureLayer": { - "description": "Architectuurlaag van dit Element", - "type": "string", - "facetable": true, - "title": "architectureLayer", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "architectureTool": { - "description": "Architectuurtool van dit Element", - "type": "string", - "facetable": false, - "title": "architectureTool", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "bbn": { - "description": "BBN van dit Element", - "type": "string", - "facetable": false, - "title": "bbn", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "maintainer": { - "description": "Beheerder van dit Element", - "type": "string", - "facetable": true, - "title": "maintainer", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "policyDomain": { - "description": "Beleidsdomein van dit Element", - "type": "string", - "facetable": true, - "title": "policyDomain", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "availability": { - "description": "Beschikbaarheid van dit Element", - "type": "string", - "facetable": false, - "title": "availability", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "availabilityPrimaryReason": { - "description": "Beschikbaarheid belangrijkste reden van dit Element", - "type": "string", - "facetable": false, - "title": "availabilityPrimaryReason", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "bivScoreBbn": { - "description": "BIV score BBN van dit Element", - "type": "string", - "facetable": false, - "title": "bivScoreBbn", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "bron": { - "description": "Bron van dit Element", - "type": "string", - "facetable": false, - "title": "bron", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "compliancy": { - "description": "Compliancy van dit Element", - "type": "string", - "facetable": false, - "title": "compliancy", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "compliancyUrl": { - "description": "Compliancy URL van dit Element", - "type": "string", - "facetable": false, - "title": "compliancyUrl", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "contextview": { - "description": "Contextview van dit Element", - "type": "string", - "facetable": false, - "title": "contextview", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "detailLevel": { - "description": "Detailniveau van dit Element", - "type": "string", - "facetable": true, - "title": "detailLevel", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "domein": { - "description": "Domein van dit Element", - "type": "string", - "facetable": true, - "title": "domain", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "owner": { - "description": "Eigenaar van dit Element", - "type": "string", - "facetable": true, - "title": "owner", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "excludefromview": { - "description": "Exclude from view van dit Element", - "type": "string", - "facetable": false, - "title": "excludefromview", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "grouping": { - "description": "Groepering van dit Element", - "type": "string", - "facetable": true, - "title": "grouping", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "hasSource": { - "description": "Heeft bron van dit Element", - "type": "string", - "facetable": false, - "title": "hasSource", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "id": { - "description": "ID van dit Element", - "type": "string", - "facetable": false, - "title": "id", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "implications": { - "description": "Implicaties van dit Element", - "type": "string", - "facetable": false, - "title": "implications", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "integrity": { - "description": "Integriteit van dit Element", - "type": "string", - "facetable": false, - "title": "integrity", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "integrityPrimaryReason": { - "description": "Integriteit belangrijkste reden van dit Element", - "type": "string", - "facetable": false, - "title": "integrityPrimaryReason", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "latestSyncDate": { - "description": "Latest Sync Date van dit Element", - "type": "string", - "facetable": false, - "title": "latestSyncDate", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "letOn": { - "description": "Let op van dit Element", - "type": "string", - "facetable": false, - "title": "letOn", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "model": { - "description": "Model van dit Element", - "type": "string", - "facetable": false, - "title": "model", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "noraCoreValue": { - "description": "NORA kernwaarde van dit Element", - "type": "string", - "facetable": true, - "title": "noraCoreValue", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "noraQualityGoal": { - "description": "NORA kwaliteitsdoel van dit Element", - "type": "string", - "facetable": true, - "title": "noraQualityGoal", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "noraPrincipe": { - "description": "NORA principe van dit Element", - "type": "string", - "facetable": true, - "title": "noraPrincipe", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "projectstatus": { - "description": "Projectstatus van dit Element", - "type": "string", - "facetable": true, - "title": "projectstatus", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "publish": { - "description": "Publiceren van dit Element", - "type": "string", - "facetable": false, - "title": "publish", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "release": { - "description": "Release van dit Element", - "type": "string", - "facetable": false, - "title": "release", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "roundtrip": { - "description": "Roundtrip van dit Element", - "type": "string", - "facetable": false, - "title": "roundtrip", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "scope": { - "description": "Scope van dit Element", - "type": "string", - "facetable": true, - "title": "scope", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "status": { - "description": "Status van dit Element", - "type": "string", - "facetable": false, - "title": "status", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "synoniemen": { - "description": "Synoniemen van dit Element", - "type": "string", - "facetable": false, - "title": "synoniemen", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "notes": { - "description": "Toelichting van dit Element", - "type": "string", - "facetable": false, - "title": "notes", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "typeBeforeConversion": { - "description": "Type before conversion van dit Element", - "type": "string", - "facetable": false, - "title": "typeBeforeConversion", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "typeModel": { - "description": "Type model van dit Element", - "type": "string", - "facetable": false, - "title": "typeModel", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "typeUri": { - "description": "Type URI van dit Element", - "type": "string", - "facetable": false, - "title": "typeUri", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "typeProvision": { - "description": "Type voorziening van dit Element", - "type": "string", - "facetable": true, - "title": "typeProvision", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "uri": { - "description": "URI van dit Element", - "type": "string", - "facetable": false, - "title": "uri", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "url": { - "description": "URL van dit Element", - "type": "string", - "facetable": false, - "title": "url", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "verbindingsrol": { - "description": "Verbindingsrol van dit Element", - "type": "string", - "facetable": false, - "title": "verbindingsrol", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "versionDesignation": { - "description": "Versieaanduiding van dit Element", - "type": "string", - "facetable": false, - "title": "versionDesignation", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "confidentiality": { - "description": "Vertrouwelijkheid van dit Element", - "type": "string", - "facetable": false, - "title": "confidentiality", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "confidentialityPrimaryReason": { - "description": "Vertrouwelijkheid belangrijkste reden van dit Element", - "type": "string", - "facetable": false, - "title": "confidentialityPrimaryReason", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggmSource": { - "description": "GGM bron van dit Element", - "type": "string", - "facetable": false, - "title": "ggmSource", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggmDateTimeExport": { - "description": "GGM datum tijd export van dit Element", - "type": "string", - "facetable": false, - "title": "ggmDateTimeExport", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggmDefinition": { - "description": "GGM definitie van dit Element", - "type": "string", - "facetable": false, - "title": "ggmDefinition", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggm-guid": { - "description": "GGM guid van dit Element", - "type": "string", - "facetable": false, - "title": "ggm-guid", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggmName": { - "description": "GGM naam van dit Element", - "type": "string", - "facetable": false, - "title": "ggmName", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggmSpecialisations": { - "description": "GGM specialisaties van dit Element", - "type": "string", - "facetable": false, - "title": "ggmSpecialisations", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggmNotes": { - "description": "GGM toelichting van dit Element", - "type": "string", - "facetable": false, - "title": "ggmNotes", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggm-type": { - "description": "GGM type van dit Element", - "type": "string", - "facetable": false, - "title": "ggm-type", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "ggm-uml-type": { - "description": "GGM uml type van dit Element", - "type": "string", - "facetable": false, - "title": "ggm-uml-type", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "apiPortal": { - "description": "API portaal van dit Element", - "type": "string", - "facetable": false, - "title": "apiPortal", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "apiPortalUrl": { - "description": "API portaal URL van dit Element", - "type": "string", - "facetable": false, - "title": "apiPortalUrl", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "omschrijving": { - "description": "Summary van dit Element", - "type": "string", - "facetable": false, - "title": "summary", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "recommendedStandards": { - "description": "Array van aanbevolen standaarden voor dit referentiecomponent", - "type": "array", - "facetable": false, - "title": "Aanbevolen Standaarden", - "items": { - "type": "object", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - } - }, - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "mandatoryStandards": { - "description": "Array van verplichte standaarden voor dit referentiecomponent", - "type": "array", - "facetable": false, - "title": "Verplichte Standaarden", - "items": { - "type": "object", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - } - }, - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "standards": { - "description": "Array van alle standaarden (aanbevolen + verplicht) - combined inversedBy lookup", - "type": "array", - "facetable": false, - "title": "Standaarden", - "items": { - "type": "object", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - } - }, - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "recommendedForReferenceComponent": { - "description": "UUID van het Referentiecomponent waarvoor deze standaard aanbevolen is", - "type": "object", - "facetable": false, - "visible": false, - "title": "Aanbevolen Voor Referentiecomponent", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - }, - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "mandatoryForReferenceComponent": { - "description": "UUID van het Referentiecomponent waarvoor deze standaard verplicht is", - "type": "object", - "facetable": false, - "visible": false, - "title": "Verplicht Voor Referentiecomponent", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - }, - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "standardVersions": { - "description": "Array van versies voor dit standaard element (inversedBy lookup)", - "type": "array", - "facetable": false, - "title": "Standaardversies", - "items": { - "type": "object", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - }, - "inversedBy": "standard" - }, - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "standard": { - "description": "UUID van de Standaard waartoe deze standaardversie behoort", - "type": "object", - "facetable": false, - "visible": false, - "title": "Standaard", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - }, - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "linkedStandardVersions": { - "description": "Array van standaardversies gekoppeld aan dit referentiecomponent (via standaarden)", - "type": "array", - "facetable": false, - "title": "Gekoppelde Standaardversies", - "items": { - "type": "object", - "$ref": "#/components/schemas/element", - "objectConfiguration": { - "handling": "related-object" - } - } - }, - "xml": { - "type": "object", - "title": "XML Data", - "description": "Original ArchiMate XML structure for round-trip export fidelity" - } - }, - "archive": [], - "source": "internal", - "hardValidation": true, - "immutable": false, - "searchable": true, - "maxDepth": 4, - "owner": "softwarecatalog", - "application": "softwarecatalog", - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "summary", - "objectDescriptionField": "documentation", - "x-openregister-shareable": true - } - }, - "model": { - "uri": null, - "slug": "model", - "title": "Model", - "description": "AMEF Model - Volledig ArchiMate model met alle elementen, relaties en views", - "version": "0.0.43", - "omschrijving": "", - "icon": "Database", - "required": [ - "xmlns", - "xsi", - "schemaLocation", - "identifier", - "name", - "nameLong", - "version", - "documentation", - "documentationLong", - "properties", - "elements", - "relationships", - "organizations", - "propertyDefinitions", - "views" - ], - "properties": { - "xmlns": { - "description": "De xmlns van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "http://www.opengroup.org/xsd/archimate/3.0/", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "xmlns" - }, - "xsi": { - "description": "De xsi van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "http://www.w3.org/2001/XMLSchema-instance", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "xsi" - }, - "schemaLocation": { - "description": "De schemaLocation van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "http://www.opengroup.org/xsd/archimate/3.0/ http://www.opengroup.org/xsd/archimate/3.1/archimate3_Diagram.xsd", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "schemaLocation" - }, - "identifier": { - "description": "De identifier van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-b58b6b03-a59d-472b-bd87-88ba77ded4e6", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "identifier" - }, - "name": { - "description": "De name van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "GEMMA release (test)", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "name" - }, - "nameLong": { - "description": "De name-language van dit GEMMA Model", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "nameLong" - }, - "version": { - "description": "De version van dit GEMMA Model", - "type": "string", - "minLength": 3, - "maxLength": null, - "example": "3.0", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "version" - }, - "documentation": { - "description": "De documentation van dit GEMMA Model", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "De GEMeentelijk Model Architectuur (GEMMA) bevat een blauwdruk van de gemeente en haar informatievoorziening. De GEMMA kan worden gebruikt als basis voor de projectmodellen", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "documentation" - }, - "documentationLong": { - "description": "De documentation-language van dit GEMMA Model", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "documentationLong" - }, - "properties": { - "description": "De properties van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/property-definition", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "properties" - }, - "elements": { - "description": "De elements van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/element", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "elements" - }, - "relationships": { - "description": "De relationships van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/relation", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "relationships" - }, - "organizations": { - "description": "De organizations van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/organization", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "organizations" - }, - "propertyDefinitions": { - "description": "De propertyDefinitions van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/property-definition", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "propertyDefinitions" - }, - "views": { - "description": "De views van dit GEMMA Model", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": 1, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/view", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "views" - }, - "xml": { - "type": "object", - "title": "XML Data", - "description": "Original ArchiMate XML structure for round-trip export fidelity" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 4, - "owner": null, - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "autoPublish": false, - "objectNameField": "name" - } - }, - "module": { - "uri": null, - "slug": "module", - "x-schema-org": "schema:SoftwareApplication", - "x-openregister-notifications": { - "dpia-review-overdue": { - "trigger": { - "type": "scheduled", - "intervalSec": 86400, - "filter": { - "dpiaStatus": { - "operator": "equals", - "value": "executed" - }, - "dpiaNextAssessment": { - "operator": "withinNext", - "value": "P0D" - } - } - }, - "enabled": true, - "channels": [ - "nc-notification", - "email" - ], - "recipients": [ - { - "kind": "groups", - "groups": [ - "software-catalog-admins" - ] - }, - { - "kind": "object-acl", - "permission": "manage" - } - ], - "subject": { - "nl": "DPIA-beoordeling verlopen: {{naam}} (uiterlijk {{dpiaVolgendeBeoordeling}})", - "en": "DPIA review overdue: {{naam}} (due {{dpiaVolgendeBeoordeling}})" - } - } - }, - "title": "Application", - "description": "Een applicatie is een softwarecomponent (applicatie of systeemsoftware)", - "version": "0.3.3", - "omschrijving": "", - "icon": "Package", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Naam van uw applicatie", - "title": "Name", - "order": 1, - "facetable": false, - "required": true, - "maxLength": 200, - "table": { - "default": true - }, - "example": "Bijvoorbeeld: VNG Applicatie Suite" - }, - "shortDescription": { - "type": "string", - "description": "Een korte beschrijving van de applicatie voor o.a. in de zoekresultaten.", - "title": "Short description", - "order": 2, - "facetable": false, - "required": false, - "maxLength": 255, - "table": { - "default": true - }, - "example": "Bijvoorbeeld: Een korte samenvatting van de applicatie" - }, - "longDescription": { - "type": "string", - "description": "Een uitgebreide omschrijving van uw applicatie. Dit kan met mark down opgemaakt worden.", - "title": "Extended description", - "order": 3, - "facetable": false, - "format": "markdown", - "maxLength": 5000, - "example": "Bijvoorbeeld: Een uitgebreide beschrijving van de applicatie met alle functionaliteiten en kenmerken" - }, - "website": { - "description": "Een URL naar uw applicatie. Default website van de organisatie", - "type": "string", - "format": "url", - "required": false, - "visible": true, - "order": 4, - "maxLength": 500, - "table": { - "default": true - }, - "facetable": false, - "title": "Website", - "example": "https://voorbeeld.nl/applicatie" - }, - "contactPerson": { - "description": "Selecteer de contactpersoon voor deze applicatie", - "type": "object", - "visible": true, - "order": 5, - "facetable": false, - "title": "Contact person", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/contactPerson", - "x-relation-filter": { - "organization": "@object.provider" - } - }, - "cloudDienstverleningsmodel": { - "description": "Kies één of meerdere hosting typen waarmee de applicatie wordt aangeboden.", - "type": "array", - "items": { - "type": "string", - "enum": [ - "On-premises (self-managed)", - "IaaS", - "PaaS", - "SaaS" - ] - }, - "order": 6, - "objectConfiguration": [], - "fileConfiguration": [], - "oneOf": [], - "facetable": true, - "title": "Hosting", - "example": [ - "SaaS" - ] - }, - "hostingJurisdiction": { - "description": "Kies de wetgeving die geldt voor de opgeslagen gegevens.", - "type": "string", - "visible": true, - "order": 7, - "enum": [ - "NL", - "EU", - "US", - "Elsewhere" - ], - "facetable": true, - "title": "Jurisdiction", - "example": "Bijvoorbeeld: NL" - }, - "hostingLocation": { - "description": "Kies het land of continent waar de applicatie wordt gehost.", - "type": "string", - "visible": true, - "order": 8, - "enum": [ - "NL", - "EU", - "US", - "Elsewhere" - ], - "facetable": true, - "title": "Hosting location", - "example": "Bijvoorbeeld: NL" - }, - "provider": { - "referenceSemanticType": "https://openregister.app/ns#Vendor", - "description": "De aanbieder van de applicatie", - "type": "object", - "visible": true, - "order": 9, - "facetable": true, - "table": { - "default": true - }, - "title": "Supplier", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organization" - }, - "licentietype": { - "description": "Biedt u de applicatie aan onder een closed source licentie of open source licentie?", - "type": "string", - "visible": true, - "order": 9, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "table": { - "default": true - }, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "default": "Closed source", - "enum": [ - "Closed source", - "Open source" - ], - "objectConfiguration": [], - "fileConfiguration": [], - "oneOf": [], - "facetable": true, - "title": "License type" - }, - "licence": { - "description": "Selecteer één van de veel gebruikte open source licenties.", - "type": "string", - "visible": true, - "order": 10, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": [], - "fileConfiguration": [], - "oneOf": [], - "facetable": false, - "enum": [ - "MIT License", - "GNU General Public License (GPL)", - "Apache License 2.0", - "BSD License (Berkeley Software Distribution)", - "European Union Public Licence (EUPL), version 1.2" - ], - "licence": "License", - "title": "License" - }, - "referenceComponents": { - "description": "GEMMA referentiecomponenten die de applicatie implementeert", - "type": "array", - "visible": true, - "order": 9, - "facetable": true, - "title": "Reference components", - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object", - "queryParams": "gemmaType=referentiecomponent&_extend=aanbevolenStandaarden,verplichteStandaarden" - }, - "$ref": "#/components/schemas/element" - }, - "hideOnForm": true - }, - "type": { - "description": "Het type applicatie zoals geregistreerd in de catalogus", - "type": "string", - "visible": false, - "order": 11, - "facetable": false, - "title": "Type", - "default": "Application", - "enum": [ - "Application", - "System software" - ] - }, - "logo": { - "description": "Het logo van de applicatie of de organisatie.", - "type": "file", - "format": "base64", - "visible": true, - "order": 18, - "facetable": false, - "title": "Logo", - "table": { - "default": true - }, - "fileConfiguration": { - "allowedMimeTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/svg+xml", - "image/webp" - ], - "maxSize": 5242880 - } - }, - "diensten": { - "description": "De diensten waarvan deze applicatie onderdeel is", - "type": "array", - "visible": true, - "order": 21, - "facetable": false, - "title": "Services", - "hideOnForm": true, - "table": { - "default": true - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/catalogService", - "inversedBy": "modules" - } - }, - "koppelingen": { - "description": "De koppelingen waarbij deze applicatie betrokken is", - "type": "array", - "visible": true, - "order": 22, - "facetable": false, - "title": "Connections", - "hideOnForm": true, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/connection", - "inversedBy": [ - "moduleA", - "moduleB" - ] - } - }, - "compliancy": { - "description": "De standaarden waar deze applicatie aan voldoet (compliance registraties)", - "type": "array", - "visible": true, - "order": 23, - "facetable": false, - "title": "Compliance", - "hideOnForm": true, - "$ref": "#/components/schemas/compliancy", - "x-relation-filter": { - "module": "@objectId" - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/compliancy", - "inversedBy": "module" - } - }, - "standards": { - "description": "Een array van amef id's van standaarden die deze applicatie implementeert", - "type": "array", - "visible": false, - "order": 24, - "facetable": false, - "title": "AMEF standards", - "hideOnForm": true, - "items": { - "type": "string" - } - }, - "standardsGemma": { - "description": "Een array van gemma id's van standaarden die deze applicatie implementeert", - "type": "array", - "visible": false, - "order": 25, - "facetable": false, - "title": "GEMMA standards", - "hideOnForm": true, - "items": { - "type": "string" - } - }, - "standardVersions": { - "description": "De standaardversies die deze applicatie implementeert", - "type": "array", - "visible": true, - "order": 26, - "facetable": true, - "title": "Standard versions", - "hideOnForm": true, - "table": { - "default": true - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object", - "queryParams": "gemmaType=standaardversie" - }, - "$ref": "#/components/schemas/element" - } - }, - "moduleVersions": { - "description": "De versies van deze applicatie", - "type": "array", - "visible": true, - "order": 26, - "facetable": false, - "title": "Application versions", - "hideOnForm": true, - "$ref": "#/components/schemas/moduleVersion", - "x-relation-filter": { - "module": "@objectId" - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/moduleVersion", - "inversedBy": "module" - } - }, - "usages": { - "description": "Het gebruik van deze applicatie", - "type": "array", - "visible": false, - "order": 27, - "facetable": false, - "title": "Usage", - "hideOnForm": true, - "$ref": "#/components/schemas/usage", - "x-relation-filter": { - "module": "@objectId" - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/usage" - } - }, - "registeredBy": { - "description": "Technische property die wordt gebruikt voor RBAC-doeleinden (toegangsbeheer op basis van partijtype)", - "type": "string", - "order": 30, - "facetable": false, - "title": "Registered by", - "visible": false, - "hideOnForm": true, - "enum": [ - "Municipality", - "Application", - "Collaboration", - "Supplier" - ] - }, - "publicationDate": { - "description": "Publication date as open data. Anonymous (public) readers can see this module once publicatiedatum is set and not in the future. Set by the open-data publish action; cleared by depublish.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 50, - "facetable": false, - "title": "Publication date" - }, - "depublicationDate": { - "description": "Depublication date. When set (and not in the future) the module is withdrawn from the open-data surface. Set by the depublish action.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 51, - "facetable": false, - "title": "Unpublication date" - }, - "eolProductSlug": { - "description": "De product-identifier op endoflife.date die bij deze applicatie hoort (bijv. \"postgresql\"). Optioneel; alleen gezet wanneer een beheerder deze applicatie koppelt aan een endoflife.date-product voor automatische einde-ondersteuning-matching. Zonder deze waarde slaat de EOL-matcher deze applicatie volledig over (geen lees- of schrijfactie).", - "type": "string", - "order": 52, - "facetable": false, - "title": "Endoflife.date product-slug", - "example": "postgresql" - }, - "bbnLevel": { - "description": "Het BBN-niveau (Baseline Informatiebeveiliging Overheid) waarop deze applicatie is geclassificeerd.", - "type": "string", - "visible": true, - "order": 53, - "enum": [ - "BBN1", - "BBN2", - "BBN3" - ], - "facetable": true, - "title": "BBN level", - "table": { - "default": true - }, - "example": "Bijvoorbeeld: BBN2" - }, - "dpiaStatus": { - "description": "De status van de Data Protection Impact Assessment (DPIA) voor deze applicatie.", - "type": "string", - "visible": true, - "order": 54, - "enum": [ - "not required", - "required", - "executed" - ], - "facetable": true, - "title": "DPIA status", - "table": { - "default": true - }, - "example": "Bijvoorbeeld: executed" - }, - "dpiaDate": { - "description": "De datum waarop de DPIA is uitgevoerd. Alleen betekenisvol wanneer dpiaStatus 'executed' is; dit wordt niet afgedwongen bij het opslaan.", - "type": "string", - "format": "date", - "visible": true, - "order": 55, - "facetable": false, - "title": "DPIA date", - "example": "Bijvoorbeeld: 2026-03-01" - }, - "dpiaNextAssessment": { - "description": "De datum waarop de DPIA opnieuw beoordeeld moet worden.", - "type": "string", - "format": "date", - "visible": true, - "order": 56, - "facetable": false, - "title": "Next DPIA review date", - "example": "Bijvoorbeeld: 2027-03-01" - }, - "dpiaDocumentRef": { - "description": "Nextcloud Files-verwijzing naar het DPIA-document (link, niet opslaan), naar analogie van compliancy.bewijsReferentie.", - "type": "string", - "visible": true, - "order": 57, - "facetable": false, - "title": "DPIA document (Nextcloud Files)" - }, - "verwerkingsregisterRef": { - "description": "Verwijzing (URL of identifier) naar de vermelding van deze applicatie in het register van verwerkingen van de organisatie. Dit veld slaat alleen de verwijzing op; het register zelf wordt niet gemodelleerd.", - "type": "string", - "visible": true, - "order": 58, - "facetable": false, - "title": "Processing register reference" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "gebruik-beheerder", - { - "group": "public", - "match": { - "publicationDate": { - "$lte": "$now" - } - } - }, - { - "group": "aanbod-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "public", - "match": { - "registeredBy": "Supplier" - } - } - ] - }, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "shortDescription", - "objectDescriptionField": "longDescription", - "objectImageField": "logo", - "allowFiles": true, - "allowedTags": [ - "Documentatie", - "Handleiding", - "Technische specificatie" - ], - "autoPublish": true, - "jsonld": { - "type": "https://schema.org/SoftwareApplication" - }, - "x-openregister-dedup": { - "blockingKeys": [], - "matchRules": [ - { - "field": "name", - "method": "normalized", - "weight": 0.4 - }, - { - "field": "name", - "method": "levenshtein", - "weight": 0.2 - }, - { - "field": "provider", - "method": "exact", - "weight": 0.3 - }, - { - "field": "website", - "method": "normalized", - "weight": 0.1 - } - ], - "threshold": 0.7 - }, - "x-openregister-quality": { - "field": "qualityScore", - "statusField": "qualityStatus", - "rules": [ - { - "type": "required", - "field": "name", - "weight": 2 - }, - { - "type": "required", - "field": "provider", - "weight": 2 - }, - { - "type": "required", - "field": "licence", - "weight": 1 - }, - { - "type": "required", - "field": "longDescription", - "weight": 1 - } - ], - "thresholds": { - "good": 0.8, - "fair": 0.5 - } - } - } - }, - "moduleVersion": { - "uri": null, - "slug": "moduleVersion", - "x-openregister-notifications": { - "module-version-published": { - "trigger": { - "type": "created" - }, - "enabled": true, - "channels": [ - "nc-notification" - ], - "recipients": [ - { - "kind": "object-acl", - "permission": "manage" - }, - { - "kind": "groups", - "groups": [ - "software-catalog-admins" - ] - } - ], - "subject": { - "nl": "Nieuwe moduleversie: {{versie}}", - "en": "New module version: {{versie}}" - } - }, - "eol-approaching": { - "trigger": { - "type": "scheduled", - "intervalSec": 86400, - "filter": { - "dateEndSupport": { - "operator": "withinNext", - "value": "P180D" - } - } - }, - "enabled": true, - "channels": [ - "nc-notification", - "email" - ], - "recipients": [ - { - "kind": "groups", - "groups": [ - "software-catalog-admins" - ] - }, - { - "kind": "object-acl", - "permission": "manage" - } - ], - "subject": { - "nl": "Einde ondersteuning nadert: {{versie}} (einddatum {{datumEindeOndersteuning}})", - "en": "End of support approaching: {{versie}} (end date {{datumEindeOndersteuning}})" - } - } - }, - "title": "Application version", - "description": "Schema voor applicatieversies", - "version": "0.1.4", - "omschrijving": "", - "icon": "ViewModule", - "required": [], - "properties": { - "module": { - "description": "De applicatie waarvan dit een versie is", - "type": "object", - "order": 1, - "title": "Application", - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "inversedBy": "moduleVersion" - }, - "version": { - "description": "Voer de versie van uw applicatie in; dit kan een nummer, tekst of combinatie daarvan zijn.", - "type": "string", - "order": 2, - "title": "Version", - "default": "1.0.0" - }, - "package_version_description": { - "description": "Beschrijving van de pakketversie", - "type": "string", - "order": 2, - "title": "Package version description" - }, - "shortDescription": { - "description": "Een korte omschrijving van de versie (256 karakters)", - "type": "string", - "order": 3, - "title": "Short description", - "maxLength": 255 - }, - "longDescription": { - "description": "Uitgebreide beschrijving van de applicatieversie", - "type": "string", - "order": 4, - "title": "Description", - "format": "markdown" - }, - "status": { - "description": "Geef aan wat de status van de versie is.", - "type": "string", - "order": 13, - "title": "Status", - "enum": [ - "in development", - "in use", - "end of support", - "withdrawn" - ], - "default": "in use" - }, - "dateInDevelopment": { - "description": "Startdatum van de ontwikkelingsfase", - "type": "string", - "format": "date", - "order": 14, - "title": "Date in development" - }, - "dateInUse": { - "description": "Startdatum van gebruik", - "type": "string", - "format": "date", - "order": 15, - "title": "Date in use" - }, - "dateEndSupport": { - "description": "Startdatum einde ondersteuning", - "type": "string", - "format": "date", - "order": 16, - "title": "End of support date" - }, - "dateWithdrawn": { - "description": "Datum waarop de applicatie teruggetrokken is", - "type": "string", - "format": "date", - "order": 17, - "title": "Date withdrawn" - }, - "usages": { - "description": "Het gebruik van deze applicatieversie", - "type": "array", - "visible": true, - "order": 18, - "facetable": false, - "title": "Usage", - "$ref": "#/components/schemas/usage", - "x-relation-filter": { - "moduleVersion": "@objectId" - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/usage" - } - }, - "registeredBy": { - "description": "Technische property die wordt gebruikt voor RBAC-doeleinden (toegangsbeheer op basis van partijtype)", - "type": "string", - "order": 19, - "facetable": true, - "title": "Registered by", - "visible": false, - "hideOnForm": true, - "enum": [ - "Municipality", - "Application", - "Collaboration", - "Supplier" - ] - }, - "eolSource": { - "description": "Herkomst van de gestempelde einde-ondersteuning-datum (bijv. \"endoflife.date\"). Alleen gezet door de EOL-matcher; afwezig bij een handmatig ingevoerde datumEindeOndersteuning.", - "type": "string", - "order": 20, - "facetable": false, - "title": "EOL source", - "example": "endoflife.date" - }, - "eolUpdatedOn": { - "description": "Tijdstip waarop de EOL-matcher deze versie voor het laatst heeft bijgewerkt vanuit de bron. Alleen gezet door de EOL-matcher; afwezig bij een handmatig ingevoerde datumEindeOndersteuning.", - "type": "string", - "format": "date-time", - "order": 21, - "facetable": false, - "title": "EOL updated on" - }, - "sbomLastImportedAt": { - "description": "Tijdstip waarop de laatste SBOM (Software Bill of Materials) voor deze versie is geïmporteerd", - "type": "string", - "format": "date-time", - "order": 22, - "title": "SBOM last imported on", - "visible": true, - "facetable": false, - "hideOnForm": true - }, - "sbomFormat": { - "description": "Formaat van de laatst geïmporteerde SBOM", - "type": "string", - "order": 23, - "title": "SBOM format", - "visible": true, - "facetable": false, - "hideOnForm": true, - "enum": [ - "cyclonedx-json", - "spdx-json" - ] - }, - "sbomFileName": { - "description": "Bestandsnaam van de laatst geïmporteerde SBOM", - "type": "string", - "order": 24, - "title": "SBOM file name", - "visible": true, - "facetable": false, - "hideOnForm": true - }, - "sbomComponents": { - "description": "De componenten die voor deze versie zijn geïmporteerd uit een SBOM", - "type": "array", - "visible": true, - "order": 25, - "facetable": false, - "title": "SBOM components", - "hideOnForm": true, - "$ref": "#/components/schemas/sbomComponent", - "x-relation-filter": { - "moduleVersion": "@objectId" - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/sbomComponent", - "inversedBy": "moduleVersion" - } - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "objectNameField": "version", - "objectSummaryField": "shortDescription", - "objectDescriptionField": "longDescription", - "autoPublish": true, - "x-openregister-lifecycle": { - "field": "status", - "initial": "in ontwikkeling", - "final": [ - "teruggetrokken" - ], - "transitions": { - "release": { - "from": [ - "in ontwikkeling" - ], - "to": "in gebruik", - "description": "Release the module version." - }, - "sunset": { - "from": [ - "in gebruik" - ], - "to": "einde ondersteuning", - "description": "Mark the module version as end-of-support." - }, - "withdraw": { - "from": [ - "in gebruik", - "einde ondersteuning" - ], - "to": "teruggetrokken", - "description": "Withdraw the module version." - } - } - } - } - }, - "organization": { - "uri": null, - "slug": "organization", - "x-schema-org": "schema:Organization", - "title": "Organization", - "description": "An organisation that offers provisions. Absorbs the former ArchiMate `organization` schema: its identity and statutory identifiers (name, summary, description, oin, tooi, rsin, pki, image) and its ArchiMate round-trip `xml` are declared here, so there is one organisation schema rather than two that shared no property.", - "version": "0.5.1", - "omschrijving": "", - "icon": "OfficeBuildingOutline", - "required": [ - "contactsUid", - "type" - ], - "properties": { - "name": { - "description": "The name of the organisation. Mirrors the identity held in Nextcloud Contacts via contactsUid, and is what the ArchiMate export round-trips. Deliberately NOT required: the existing records delegate their identity to Contacts and would otherwise all become invalid.", - "type": "string", - "minLength": 1, - "visible": true, - "order": 53, - "facetable": true, - "title": "Name", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "summary": { - "description": "Brief description of the organisation.", - "type": "string", - "minLength": 1, - "visible": true, - "order": 54, - "facetable": false, - "title": "Summary", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "description": { - "description": "Detailed description of the organisation.", - "type": "string", - "visible": true, - "order": 55, - "facetable": false, - "title": "Description", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "oin": { - "description": "Organisation Identification Number (OIN).", - "type": "string", - "pattern": "^0000000\\d{10}000$", - "visible": true, - "order": 56, - "facetable": true, - "title": "OIN", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "tooi": { - "description": "TOOI identifier for the organisation.", - "type": "string", - "pattern": "^\\w{2,}\\d{4}$", - "visible": true, - "order": 57, - "facetable": true, - "title": "TOOI", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "rsin": { - "description": "RSIN number for tax identification.", - "type": "string", - "pattern": "^\\d{9}$", - "visible": true, - "order": 58, - "facetable": true, - "title": "RSIN", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "pki": { - "description": "PKI certificate information.", - "type": "string", - "visible": true, - "order": 59, - "facetable": false, - "title": "PKI", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "image": { - "description": "Logo or image of the organisation.", - "type": "file", - "visible": true, - "order": 60, - "facetable": false, - "title": "Image" - }, - "xml": { - "description": "Original ArchiMate XML structure, kept for round-trip export fidelity.", - "type": "object", - "visible": false, - "order": 61, - "facetable": false, - "title": "ArchiMate XML" - }, - "contactsUid": { - "description": "Verwijzing (UID) naar de Nextcloud-contactpersoon van het type organisatie in het adresboek (OCP\\Contacts\\IManager). Identiteit (naam, e-mail, website, logo, CBS/KvK-code) leeft in Nextcloud Contacts; dit record bewaart alleen de catalogus-specifieke relatie/rol.", - "type": "string", - "required": true, - "visible": true, - "order": 0, - "maxLength": 255, - "facetable": false, - "title": "Contact-UID", - "example": "Bijvoorbeeld: 3f2b0c5e-1234-4a9b-8c1d-9f0e1a2b3c4d" - }, - "contactpersonen": { - "description": "De contactpersoon van de organisatie", - "type": "array", - "visible": true, - "order": 4, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "#/components/schemas/contactPerson", - "x-relation-filter": { - "organization": "@objectId" - }, - "items": { - "cascadeDelete": false, - "$ref": "#/components/schemas/contactPerson", - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "inversedBy": "organization" - }, - "objectConfiguration": { - "handling": null, - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Contact persons", - "authorization": { - "read": [ - "authenticated" - ] - } - }, - "deelnames": { - "description": "Deelnames van deze organisatie in andere organisaties", - "type": "array", - "visible": true, - "order": 5, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "#/components/schemas/organization", - "items": { - "cascadeDelete": false, - "$ref": "#/components/schemas/organization", - "type": "object", - "objectConfiguration": { - "handling": "related-object" - } - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Participations" - }, - "participants": { - "description": "Deelnemers in deze organisatie", - "type": "array", - "visible": true, - "order": 6, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "#/components/schemas/organization", - "items": { - "cascadeDelete": false, - "$ref": "#/components/schemas/organization", - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "inversedBy": "deelnames" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Participants", - "writeBack": true, - "removeAfterWriteBack": false - }, - "type": { - "description": "Type van de organisatie (Gemeente, Leverancier, Samenwerking) ", - "title": "Organization type", - "type": "string", - "required": true, - "visible": true, - "facetable": { - "aggregated": false, - "title": "Organization type", - "description": "Type van de organisatie", - "order": null - }, - "order": 3, - "minLength": null, - "maxLength": null, - "immutable": true, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Municipality", - "Supplier", - "Collaboration", - "Community" - ], - "hideOnCollection": true - }, - "status": { - "description": "Geeft aan of de VNG de organisatie positief beoordeeld heeft voor toegang tot de Softwarecatalogus", - "title": "Status", - "type": "string", - "default": "Draft", - "visible": false, - "hideOnCollection": true, - "facetable": false, - "order": 17, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Draft", - "Active", - "Inactive", - "merged" - ] - }, - "mergedInto": { - "description": "Organisation-merge tombstone: the target organisation UUID this organisation was merged into. Set only when status equals 'samengevoegd'; absent/null on every organisation that has never been a merge source.", - "title": "Merged with", - "type": "string", - "visible": false, - "hideOnCollection": true, - "hideOnForm": true, - "facetable": false, - "order": 52, - "maxLength": 255 - }, - "registrationStatus": { - "description": "Moderatiestatus van een (anoniem) zelf-geregistreerde organisatie. 'pending' tot een beheerder de registratie goedkeurt; pas daarna 'active'. Anoniem geregistreerde organisaties zijn 'pending' en niet zichtbaar in de open-data/federatie-laag tot goedkeuring.", - "title": "Registration status", - "type": "string", - "visible": false, - "hideOnCollection": true, - "facetable": true, - "order": 18, - "enum": [ - "pending", - "active", - "rejected" - ] - }, - "publicationDate": { - "description": "Publication date as open data. Anonymous (public) readers can see this organisation profile once publicatiedatum is set and not in the future. Set by the open-data publish action; cleared by depublish.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 50, - "facetable": false, - "title": "Publication date" - }, - "depublicationDate": { - "description": "Depublication date. When set (and not in the future) the organisation is withdrawn from the open-data surface. Set by the depublish action.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 51, - "facetable": false, - "title": "Unpublication date" - }, - "samenwerkingtype": { - "description": "Type samenwerking van de organisatie", - "title": "Collaboration type", - "type": "string", - "visible": false, - "facetable": true, - "order": 14, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Implementing organisation", - "Social Domain collaboration", - "Shared Service Center", - "samenwerkingtype", - "Environmental agency", - "ICT (for example Shared Service Center)", - "Municipal reorganisation (planned)", - "Joint Arrangement (collaboration across multiple domains)", - "Joint Arrangement", - "DVO", - "Central municipality arrangement", - "Tax collaboration", - "Operations organisation", - "Archive service (regional)", - "Administrative merger" - ] - }, - "registeredBy": { - "description": "Technische property die wordt gebruikt voor RBAC-doeleinden (toegangsbeheer op basis van partijtype)", - "type": "string", - "order": 18, - "facetable": true, - "title": "Registered by", - "visible": false, - "hideOnForm": true, - "enum": [ - "Municipality", - "Application", - "Collaboration", - "Supplier" - ] - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "public", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - { - "group": "gebruik-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "public", - "match": { - "publicationDate": { - "$lte": "$now" - } - } - }, - { - "group": "aanbod-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "public", - "match": { - "registeredBy": "Supplier", - "status": "Active" - } - }, - { - "group": "public", - "match": { - "type": "Municipality", - "status": "Active" - } - }, - { - "group": "public", - "match": { - "type": "Collaboration", - "status": "Active" - } - } - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "contactsUid", - "allowFiles": true, - "allowedTags": [ - "Verklaring betaling sociale premies", - "Verklaring betaling belastingen", - "Bestuurdersverklaring", - "Uittreksel KvK", - "BTW-nummer bevestiging", - "Compliance verklaring", - "Jaarrekening", - "ISO-certificaten", - "Privacy verklaring", - "AVG compliance document", - "ESPD (Europees aanbestedingsdocument)", - "Integriteitsverklaring", - "Financiële capaciteitsverklaring", - "Technische capaciteitsverklaring", - "Kwaliteitscertificaten", - "Milieucertificaten", - "Verzekeringsbewijs", - "Beroepsaansprakelijkheidsverzekering", - "Referentieprojecten", - "VCA-certificaat", - "BRL-certificaten", - "CE-markering documenten", - "Aanbestedingsdocumentatie" - ], - "autoPublish": true, - "jsonld": { - "type": "https://schema.org/Organization" - }, - "implements": [ - "https://schema.org/Organization", - "https://openregister.app/ns#Vendor" - ], - "x-openregister-dedup": { - "blockingKeys": [], - "matchRules": [ - { - "field": "contactsUid", - "method": "exact", - "weight": 1 - } - ], - "threshold": 0.7 - }, - "x-openregister-quality": { - "field": "qualityScore", - "statusField": "qualityStatus", - "rules": [ - { - "type": "required", - "field": "contactsUid", - "weight": 3 - }, - { - "type": "required", - "field": "type", - "weight": 1 - }, - { - "type": "required", - "field": "status", - "weight": 1 - } - ], - "thresholds": { - "good": 0.8, - "fair": 0.5 - } - }, - "$comment-lifecycle": "🔴 THE STATE NAMES HERE ARE THE ENUM'S VALUES, NOT LABELS. #520 translated the status enum (Concept/Actief/Deactief -> Draft/Active/Inactive) and migrated the stored rows, but left this block in Dutch, so `initial`, `final` and every `from`/`to` named a value no row can hold: the initial state wrote an out-of-enum value and no transition could ever match. Nothing errors — a transition whose `from` matches nothing is simply never offered — so it reads as a lifecycle nobody uses.", - "x-openregister-lifecycle": { - "field": "status", - "initial": "Draft", - "final": [ - "Inactive" - ], - "transitions": { - "activate": { - "from": [ - "Draft" - ], - "to": "Active", - "description": "Activate the organisation." - }, - "deactivate": { - "from": [ - "Active" - ], - "to": "Inactive", - "description": "Deactivate the organisation." - }, - "reactivate": { - "from": [ - "Inactive" - ], - "to": "Active", - "description": "Re-activate a deactivated organisation." - } - } - } - } - }, - "property-definition": { - "uri": null, - "slug": "property-definition", - "title": "Property Definition", - "description": "AMEF Property Definition - Definitie van eigenschappen voor ArchiMate elementen", - "version": "0.0.8", - "omschrijving": "", - "icon": "CogOutline", - "required": [ - "identifier", - "type" - ], - "properties": { - "identifier": { - "description": "De identifier van deze Property Definition", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "propid-43", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "identifier", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "type": { - "description": "De type van deze Property Definition", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "string", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "type", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "name": { - "description": "De name van deze Property Definition", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "API-portaal", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "name", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "nameLong": { - "description": "De name-language van deze Property Definition", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "nameLong", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "xml": { - "type": "object", - "title": "XML Data", - "description": "Original ArchiMate XML structure for round-trip export fidelity" - } - }, - "archive": [], - "source": "internal", - "hardValidation": true, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "softwarecatalog", - "application": "softwarecatalog", - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "objectNameField": "name | identifier", - "objectDescriptionField": "type" - } - }, - "relation": { - "uri": null, - "slug": "relation", - "title": "Relation", - "description": "AMEF Relation - Relaties tussen architectuur elementen uit het ArchiMate model", - "version": "0.0.8", - "omschrijving": "", - "icon": "ArrowRight", - "required": [ - "identifier", - "source", - "target", - "type", - "properties" - ], - "properties": { - "identifier": { - "description": "De identifier van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-1b46181d68e5477a9c0b5a95a0677924", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "identifier", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "source": { - "description": "De source van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-d143a1fc-02dc-11e6-11ba-005056a85f9c", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "source", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "target": { - "description": "De target van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-a85f22d89af14222a914fcb9ecfe6815", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "target", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "type": { - "description": "De type van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Access", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "type", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "accessType": { - "description": "De accessType van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Read", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "accessType", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "isDirected": { - "description": "De isDirected van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "true", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "isDirected", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "name": { - "description": "De name van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Verplicht", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "name", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "nameLong": { - "description": "De name-language van deze Relation", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "nameLong", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "documentation": { - "description": "De documentation van deze Relation", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Op basis van het zaaktype routeert de servicebuscomponent de aanvraag naar een Zaakafhandelcomponent (generiek of specifiek).", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "documentation", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "documentationLong": { - "description": "De documentation-lang van deze Relation", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "documentationLong", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "properties": { - "description": "De properties van deze Relation", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/property-definition", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "properties", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "objectId": { - "description": "Object ID van deze Relation", - "type": "string", - "facetable": false, - "title": "objectId", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaType": { - "description": "GEMMA type van deze Relation", - "type": "string", - "facetable": true, - "title": "gemmaType", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaThema": { - "description": "GEMMA thema van deze Relation", - "type": "string", - "facetable": true, - "title": "gemmaThema", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaUrl": { - "description": "GEMMA URL van deze Relation", - "type": "string", - "facetable": false, - "title": "gemmaUrl", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "gemmaStatus": { - "description": "GEMMA status van deze Relation", - "type": "string", - "facetable": true, - "title": "gemmaStatus", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "status": { - "description": "Status van deze Relation", - "type": "string", - "facetable": false, - "title": "status", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "verbindingsrol": { - "description": "Verbindingsrol van deze Relation", - "type": "string", - "facetable": false, - "title": "verbindingsrol", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "notes": { - "description": "Toelichting van deze Relation", - "type": "string", - "facetable": false, - "title": "notes", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "omschrijving": { - "description": "Summary van deze Relation", - "type": "string", - "facetable": false, - "title": "summary", - "table": { - "enabled": true, - "indexed": false, - "searchable": false - } - }, - "xml": { - "type": "object", - "title": "XML Data", - "description": "Original ArchiMate XML structure for round-trip export fidelity" - } - }, - "archive": [], - "source": "internal", - "hardValidation": true, - "immutable": false, - "searchable": true, - "maxDepth": 4, - "owner": "softwarecatalog", - "application": "softwarecatalog", - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "objectNameField": "name | type | identifier", - "objectSummaryField": "summary", - "objectDescriptionField": "documentation" - } - }, - "sbomComponent": { - "uri": null, - "slug": "sbomComponent", - "title": "SBOM component", - "description": "Schema voor componenten die zijn geïmporteerd uit een SBOM (Software Bill of Materials, CycloneDX of SPDX) voor een applicatieversie.", - "version": "0.0.1", - "omschrijving": "", - "icon": "PackageVariantClosed", - "required": [ - "moduleVersion", - "name" - ], - "properties": { - "moduleVersion": { - "description": "De applicatieversie waarvan dit component onderdeel is", - "type": "object", - "order": 1, - "title": "Application version", - "visible": true, - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/moduleVersion", - "inversedBy": "sbomComponents" - }, - "name": { - "description": "Naam van het component zoals gerapporteerd in de SBOM", - "type": "string", - "order": 2, - "title": "Name", - "visible": true, - "facetable": false, - "maxLength": 255 - }, - "version": { - "description": "Versie van het component zoals gerapporteerd in de SBOM", - "type": "string", - "order": 3, - "title": "Version", - "visible": true, - "facetable": false, - "maxLength": 100 - }, - "purl": { - "description": "Package URL (pkg:...) van het component", - "type": "string", - "order": 4, - "title": "Package URL", - "visible": true, - "facetable": false, - "maxLength": 500 - }, - "licenses": { - "description": "Licentie-identificaties of -expressies zoals gerapporteerd in de SBOM", - "type": "array", - "order": 5, - "title": "Licenses", - "visible": true, - "facetable": false, - "items": { - "type": "string" - } - }, - "type": { - "description": "CycloneDX componenttype (library, application, framework, container, ...)", - "type": "string", - "order": 6, - "title": "Type", - "visible": true, - "facetable": true - }, - "hashes": { - "description": "Bestandshashes van het component (alleen informatief, niet gebruikt voor matching)", - "type": "array", - "order": 7, - "title": "Hashes", - "visible": false, - "facetable": false, - "items": { - "type": "object", - "properties": { - "alg": { - "type": "string", - "title": "Algorithm", - "description": "Naam van het hash-algoritme zoals CycloneDX het aanlevert, bijvoorbeeld SHA-256 of SHA-512." - }, - "value": { - "type": "string", - "title": "Value", - "description": "De hexadecimale hashwaarde die met het genoemde algoritme over het componentbestand is berekend." - } - } - } - }, - "bomRef": { - "description": "CycloneDX bom-ref van het component; alleen gebruikt voor traceerbaarheid binnen één import", - "type": "string", - "order": 8, - "title": "BOM reference", - "visible": false, - "facetable": false, - "maxLength": 255 - }, - "vexCveIds": { - "description": "CVE-identificaties die de SBOM's VEX-blok (vulnerabilities[]) aan dit component koppelt via bom-ref. Ruwe feiten uit het brondocument — GEEN opgeslagen match: het al-dan-niet overeenkomen met een kwetsbaarheid wordt altijd on-the-fly berekend tegen het kwetsbaarheid-register, nooit hier vastgelegd.", - "type": "array", - "order": 9, - "title": "VEX CVE-ids", - "visible": false, - "facetable": false, - "items": { - "type": "string" - } - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "version", - "objectDescriptionField": "purl", - "autoPublish": true - } - }, - "sector": { - "uri": null, - "slug": "sector", - "title": "Sector", - "description": "Schema voor sectoren binnen de softwarecatalogus", - "version": "0.0.10", - "omschrijving": "", - "icon": "Domain", - "required": [ - "name" - ], - "properties": { - "name": { - "description": "Naam van de sector", - "type": "string", - "required": true, - "visible": true, - "order": 1, - "facetable": false, - "title": "Name", - "maxLength": 200, - "example": "Bijvoorbeeld: Overheid" - }, - "description": { - "description": "Beschrijving van de sector", - "type": "string", - "visible": true, - "order": 2, - "facetable": false, - "title": "Description", - "maxLength": 1000, - "example": "Bijvoorbeeld: Publieke sector en overheidsdiensten" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - "public", - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisatie-beheerder", - "organisaties-beheerder", - "gebruik-raadpleger" - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "name", - "objectDescriptionField": "description", - "autoPublish": false - } - }, - "catalogService": { - "uri": null, - "slug": "catalogService", - "x-schema-org": "schema:Service", - "title": "Service", - "description": "Een specifiek aanbod van een dienst op een of meerdere applicaties door een leverancier", - "version": "0.2.1", - "omschrijving": "", - "icon": "Handshake", - "required": [ - "name", - "provider", - "type" - ], - "properties": { - "name": { - "description": "De naam van uw dienst", - "type": "string", - "required": true, - "visible": true, - "order": 1, - "minLength": null, - "maxLength": 200, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "table": { - "default": true - }, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Name", - "example": "Bijvoorbeeld: Implementatie en ondersteuning" - }, - "shortDescription": { - "type": "string", - "description": "Een korte beschrijving van de dienst voor o.a. in de zoekresultaten.", - "title": "Short description", - "facetable": false, - "maxLength": 255, - "table": { - "default": true - }, - "order": 5, - "example": "Bijvoorbeeld: Korte beschrijving van de dienst" - }, - "longDescription": { - "description": "Een uitgebreide omschrijving van uw dienst. Dit kan met mark down opgemaakt worden.", - "type": "string", - "format": "markdown", - "visible": true, - "order": 6, - "facetable": false, - "title": "Extended description", - "maxLength": 5000, - "example": "Bijvoorbeeld: Uitgebreide beschrijving van de dienst met alle details" - }, - "website": { - "type": "string", - "format": "url", - "description": "Een URL naar uw dienst, applicatie of organisatie", - "facetable": false, - "title": "Website", - "order": 4, - "visible": true, - "maxLength": 500, - "example": "https://dienst.voorbeeld.nl" - }, - "contactPerson": { - "description": "Selecteer de contactpersoon voor deze dienst", - "type": "object", - "visible": true, - "order": 1, - "facetable": false, - "title": "Contact person", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/contactPerson", - "x-relation-filter": { - "organization": "@object.provider" - } - }, - "modules": { - "description": "Zoek de applicaties op waar deze dienst van toepassing is.", - "type": "array", - "visible": true, - "order": 2, - "facetable": false, - "title": "Application", - "$ref": "#/components/schemas/module", - "x-relation-filter": { - "provider": "@object.provider" - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module" - } - }, - "provider": { - "referenceSemanticType": "https://openregister.app/ns#Vendor", - "description": "De leverende partij die deze dienst beschikbaar stelt", - "type": "object", - "required": true, - "visible": true, - "order": 3, - "facetable": false, - "table": { - "default": true - }, - "title": "Provider", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organization" - }, - "type": { - "description": "kies één of meer typen die op deze dienst van toepassing zijn.", - "type": "array", - "items": { - "type": "string", - "enum": [ - "Functional management", - "Application management", - "Technical management", - "Implementation support", - "Training", - "Licence reseller" - ] - }, - "required": true, - "visible": true, - "order": 4, - "table": { - "default": true - }, - "facetable": { - "title": "Service type", - "aggregated": false - }, - "title": "Service type", - "example": "Bijvoorbeeld: Implementatieondersteuning" - }, - "logo": { - "description": "Het logo van de dienst of de organisatie", - "type": "file", - "format": "base64", - "visible": true, - "order": 5, - "facetable": false, - "title": "Logo", - "fileConfiguration": { - "allowedMimeTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/svg+xml", - "image/webp" - ], - "maxSize": 5242880 - } - }, - "koppelingen": { - "description": "Koppelingen die gebruikt worden door deze dienst", - "type": "array", - "visible": false, - "order": 8, - "facetable": false, - "title": "Connections", - "$ref": "#/components/schemas/connection", - "x-relation-filter": { - "service": "@objectId" - }, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/connection", - "inversedBy": "service" - } - }, - "publicationDate": { - "description": "Publication date as open data. Anonymous (public) readers can see this dienst only once publicatiedatum is set and not in the future. Set by the open-data publish action; cleared by depublish.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 50, - "facetable": false, - "title": "Publication date" - }, - "depublicationDate": { - "description": "Depublication date. When set (and not in the future) the dienst is withdrawn from the open-data surface. Set by the depublish action.", - "type": "string", - "format": "date-time", - "visible": true, - "order": 51, - "facetable": false, - "title": "Unpublication date" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - { - "group": "public", - "match": { - "publicationDate": { - "$lte": "$now" - } - } - }, - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisatie-beheerder", - "organisaties-beheerder", - "gebruik-raadpleger" - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "shortDescription", - "objectDescriptionField": "longDescription", - "objectImageField": "logo", - "allowFiles": true, - "allowedTags": [ - "ISO-9001", - "ISO-27001", - "ISO-16075", - "Verklaring van toepasselijkheid" - ], - "autoPublish": true - } - }, - "suite": { - "uri": null, - "slug": "suite", - "x-schema-org": "schema:SoftwareApplication", - "title": "Suite", - "description": "Een suite is een verzameling van applicaties die samen een product vormen", - "version": "0.1.4", - "omschrijving": "", - "icon": "PackageVariant", - "required": [ - "name", - "shortDescription" - ], - "properties": { - "name": { - "description": "Naam van de suite", - "type": "string", - "required": true, - "visible": true, - "order": 1, - "maxLength": 200, - "facetable": false, - "title": "Name", - "table": { - "default": true - }, - "example": "Bijvoorbeeld: VNG Suite" - }, - "shortDescription": { - "type": "string", - "title": "Short description", - "description": "Korte beschrijving van de suite", - "facetable": false, - "maxLength": 255, - "order": 2, - "table": { - "default": true - }, - "example": "Bijvoorbeeld: Een korte samenvatting van de suite" - }, - "longDescription": { - "description": "Uitgebreide beschrijving van de suite", - "type": "string", - "format": "markdown", - "visible": true, - "order": 3, - "maxLength": 5000, - "facetable": false, - "title": "Extended description", - "example": "Bijvoorbeeld: Een uitgebreide beschrijving van de suite met alle functionaliteiten" - }, - "logo": { - "description": "Logo van de suite", - "type": "file", - "format": "base64", - "visible": true, - "order": 4, - "facetable": false, - "title": "Logo", - "table": { - "default": true - }, - "fileConfiguration": { - "allowedMimeTypes": [ - "image/jpeg", - "image/png", - "image/gif", - "image/svg+xml", - "image/webp" - ], - "maxSize": 5242880 - } - }, - "website": { - "description": "Website van de suite", - "type": "string", - "format": "url", - "visible": true, - "order": 5, - "maxLength": 500, - "facetable": false, - "title": "Website", - "example": "https://voorbeeld.nl/suite" - }, - "contactPerson": { - "description": "Contactpersoon voor de suite", - "type": "object", - "visible": true, - "order": 6, - "facetable": false, - "title": "Contact", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/contactPerson" - }, - "applications": { - "description": "De applicaties die onderdeel zijn van deze suite", - "type": "array", - "visible": true, - "order": 7, - "facetable": false, - "title": "Applications", - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module" - } - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "system", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - "public", - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisatie-beheerder", - "organisaties-beheerder", - "gebruik-raadpleger" - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "shortDescription", - "objectDescriptionField": "longDescription", - "objectImageField": "logo", - "allowFiles": true, - "allowedTags": [ - "DPIA", - "Handleiding" - ], - "autoPublish": true, - "jsonld": { - "type": "https://schema.org/SoftwareApplication" - } - } - }, - "usage": { - "uri": null, - "slug": "usage", - "title": "Usage", - "description": "Het gebruik van applicaties, diensten en koppelingen door afnemers", - "version": "1.5.0", - "omschrijving": "", - "icon": "Gauge", - "x-openregister-notifications": { - "phaseout-approaching": { - "trigger": { - "type": "scheduled", - "intervalSec": 86400, - "filter": { - "startDateOutPhasing": { - "operator": "withinNext", - "value": "P180D" - } - } - }, - "enabled": true, - "channels": [ - "nc-notification", - "email" - ], - "recipients": [ - { - "kind": "groups", - "groups": [ - "software-catalog-admins" - ] - }, - { - "kind": "object-acl", - "permission": "manage" - } - ], - "subject": { - "nl": "Uitfasering nadert: {{contractNummer}} (uit te faseren {{startDatumUitTeFaseren}})", - "en": "Phase-out approaching (phase-out date {{startDatumUitTeFaseren}})" - } - } - }, - "required": [ - "consumer" - ], - "properties": { - "consumer": { - "type": "object", - "title": "Consumer", - "description": "De organisatie die afnemer is van de applicatie", - "facetable": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organization", - "required": true, - "order": 11 - }, - "provider": { - "type": "object", - "title": "Provider", - "description": "De organisatie die aanbieder is van de applicatie", - "facetable": false, - "visible": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organization", - "required": false, - "order": 12 - }, - "contactPerson": { - "type": "object", - "description": "De contactpersoon voor dit gebruik", - "facetable": false, - "visible": false, - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/contactPerson", - "title": "Contact person", - "order": 3 - }, - "participants": { - "description": "De organisaties die deelnemen aan dit gebruik (voor samenwerkingen)", - "type": "array", - "visible": true, - "facetable": false, - "title": "Participants", - "order": 6, - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/organization" - } - }, - "startDateAcquisition": { - "description": "De start datum voor het \"Verwerving\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 14, - "facetable": false, - "title": "Acquisition start date", - "example": "Bijvoorbeeld: 2025-01-01" - }, - "startDatePlanned": { - "description": "De start datum voor het \"Gepland\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 16, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Planned start date", - "example": "Bijvoorbeeld: 2025-02-01" - }, - "startDateInProduction": { - "description": "De start datum voor het \"actief\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 14, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Start date in production", - "example": "Bijvoorbeeld: 2025-03-01" - }, - "startDateOutPhasing": { - "description": "De start datum voor het \"Beëindigd\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 15, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Phase-out start date", - "example": "Bijvoorbeeld: 2025-12-31" - }, - "startDateOutPhased": { - "description": "De start datum voor het \"Uit gefaseerd\" status", - "type": "string", - "format": "date", - "visible": true, - "order": 15, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Phase-out completed date", - "example": "Bijvoorbeeld: 2025-12-31" - }, - "status": { - "description": "Selecteer de status van de versie in uw landschap (default status \"in productie\")", - "type": "string", - "default": "In production", - "required": true, - "visible": true, - "order": 17, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "enum": [ - "Acquisition", - "Planned", - "In production", - "To be phased out", - "Phased out" - ], - "facetable": false, - "title": "Status", - "example": "Bijvoorbeeld: Gepland" - }, - "interneAnnotation": { - "description": "Voeg Interne notitie toe, bijvoorbeeld over kosten of eigenaarschap.", - "type": "string", - "visible": true, - "hideOnCollection": true, - "order": 10, - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "inversedBy": "", - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "Internal note", - "example": "Bijvoorbeeld: Interne notitie over het gebruik", - "authorization": { - "read": [ - { - "group": "public", - "match": { - "_organisation": "$organisation" - } - } - ], - "update": [ - { - "group": "public", - "match": { - "_organisation": "$organisation" - } - } - ] - } - }, - "module": { - "description": "Selecteer de applicatie uit uw aanbod", - "type": "object", - "visible": true, - "order": 20, - "facetable": false, - "title": "Application", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "inversedBy": "usage", - "table": { - "default": true - } - }, - "moduleVersion": { - "description": "Selecteer de versie (on-premisse - default nieuwste versie, SaaS - default versie 'Cloud')", - "type": "object", - "visible": true, - "order": 21, - "facetable": false, - "title": "Version", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/moduleVersion", - "inversedBy": "usage", - "x-relation-filter": { - "module": "@object.module" - }, - "table": { - "default": true - } - }, - "usedForReferenceComponents": { - "description": "GEMMA referentiecomponenten waarvoor dit product wordt gebruikt", - "type": "array", - "visible": true, - "order": 22, - "facetable": true, - "title": "Reference components", - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object", - "queryParams": "gemmaType=referentiecomponent&_extend=aanbevolenStandaarden,verplichteStandaarden" - }, - "$ref": "#/components/schemas/element" - }, - "table": { - "default": true - } - }, - "amefElements": { - "description": "Ids van AMEF elementen waarvoor dit product wordt gebruikt", - "type": "array", - "visible": false, - "order": 22, - "facetable": false, - "title": "AMEF elements", - "items": { - "type": "string" - } - }, - "elementRef": { - "description": "Id of the ArchiMate element this usage is shown on.", - "type": "string", - "visible": false, - "order": 23, - "facetable": false, - "title": "ArchiMate element" - }, - "koppelingen": { - "description": "De koppelingen die gebruikt worden binnen dit productgebruik", - "type": "array", - "visible": true, - "order": 24, - "facetable": false, - "title": "Connections", - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/connection" - } - }, - "diensten": { - "description": "De diensten die onderdeel zijn van dit gebruik", - "type": "array", - "visible": true, - "order": 25, - "facetable": false, - "title": "Services", - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/catalogService" - } - }, - "cloudDienstverleningsmodel": { - "type": "array", - "format": "", - "title": "Hosting", - "description": "Kies het type hosting waarmee de applicatie wordt gebruikt. (SaaS of On-premise)", - "facetable": true, - "items": { - "type": "string", - "enum": [ - "On-premises (self-managed)", - "IaaS", - "PaaS", - "SaaS" - ] - }, - "example": "SaaS", - "table": { - "default": true - } - }, - "plannedReplacement": { - "description": "De module die dit gebruik gepland gaat vervangen (opvolger). Wordt per gebruik vastgelegd, niet op de module zelf.", - "type": "object", - "visible": true, - "order": 30, - "facetable": false, - "title": "Planned replacement", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module" - }, - "plannedReplacementDate": { - "description": "De geplande datum waarop de vervanging plaatsvindt.", - "type": "string", - "format": "date", - "visible": true, - "order": 31, - "facetable": false, - "title": "Planned replacement date", - "example": "Bijvoorbeeld: 2027-01-01" - }, - "timeClassification": { - "description": "Gartner TIME-classificatie van dit gebruik: Tolerate (gedogen), Invest (investeren), Migrate (migreren) of Eliminate (uitfaseren). Wordt per gebruik vastgelegd, niet op de module zelf.", - "type": "string", - "visible": true, - "order": 32, - "facetable": true, - "title": "TIME classification", - "enum": [ - "Tolerate", - "Invest", - "Migrate", - "Eliminate" - ], - "example": "Bijvoorbeeld: Migrate" - }, - "timeRationale": { - "description": "Onderbouwing van de TIME-classificatie voor dit gebruik.", - "type": "string", - "visible": true, - "order": 33, - "facetable": false, - "title": "TIME rationale", - "example": "Bijvoorbeeld: Verouderd platform, opvolger reeds gepland" - }, - "timeReviewDate": { - "description": "Datum waarop de TIME-classificatie van dit gebruik opnieuw beoordeeld moet worden.", - "type": "string", - "format": "date", - "visible": true, - "order": 34, - "facetable": false, - "title": "TIME review date", - "example": "Bijvoorbeeld: 2027-01-01" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - { - "group": "gebruik-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "gebruik-beheerder", - "match": { - "consumer": "$organisation" - } - }, - { - "group": "aanbod-beheerder", - "match": { - "_organisation": "$organisation" - } - }, - { - "group": "aanbod-beheerder", - "match": { - "provider": "$organisation" - } - } - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "consumer", - "objectDescriptionField": "module", - "allowFiles": true, - "allowedTags": [ - "DPIA", - "Contract", - "Verwerkingsovereenkomst" - ], - "autoPublish": false, - "x-openregister-lifecycle": { - "field": "status", - "initial": "Verwerving", - "final": [ - "Uitgefaseerd" - ], - "transitions": { - "plan": { - "from": [ - "Verwerving" - ], - "to": "Gepland", - "description": "Plan the usage." - }, - "goLive": { - "from": [ - "Gepland" - ], - "to": "In productie", - "description": "Go live with the usage." - }, - "phaseOut": { - "from": [ - "In productie" - ], - "to": "Uit te faseren", - "description": "Mark the usage to be phased out." - }, - "retire": { - "from": [ - "Uit te faseren" - ], - "to": "Uitgefaseerd", - "description": "Retire the usage." - } - } - } - } - }, - "view": { - "uri": null, - "slug": "view", - "title": "View", - "description": "AMEF View - Architectuur views en diagrammen uit het ArchiMate model", - "version": "0.0.7", - "omschrijving": "", - "icon": "EyeOutline", - "required": [ - "identifier", - "type", - "name", - "properties", - "nodes", - "connections" - ], - "properties": { - "identifier": { - "description": "De identifier van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "id-a6ee6077d3094afa91fc6ea92a9a2a40", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "identifier" - }, - "type": { - "description": "De type van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Diagram", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "type" - }, - "viewpoint": { - "description": "De viewpoint van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Application Structure", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "viewpoint" - }, - "name": { - "description": "De name van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "LV01 BGT basisregistratie en SVB view", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "facetable": false, - "title": "name" - }, - "nameLong": { - "description": "De name-language van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "nameLong" - }, - "documentation": { - "description": "De documentation van deze View", - "type": "string", - "minLength": null, - "maxLength": null, - "example": "Toont de referentiecomponenten ter ondersteuning van applicatieservices voor publieksdiensten", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "documentation" - }, - "documentationLong": { - "description": "De documentation-language van deze View", - "type": "string", - "minLength": 2, - "maxLength": 2, - "example": "nl", - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "documentationLong" - }, - "properties": { - "description": "De properties van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "$ref": "#/components/schemas/property-definition", - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "properties" - }, - "nodes": { - "description": "De nodes van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "nodes" - }, - "connections": { - "description": "De connections van deze View", - "type": "array", - "minLength": null, - "maxLength": null, - "minimum": null, - "maximum": null, - "multipleOf": null, - "minItems": null, - "maxItems": null, - "$ref": "", - "items": { - "cascadeDelete": true, - "type": "object" - }, - "objectConfiguration": { - "handling": "nested-object", - "schema": "" - }, - "fileConfiguration": { - "handling": "ignore", - "allowedMimeTypes": [], - "location": "", - "maxSize": 0 - }, - "oneOf": [], - "title": "connections" - }, - "objectId": { - "description": "Object ID van deze View", - "type": "string", - "facetable": false, - "title": "objectId" - }, - "viewtype": { - "description": "Viewtype van deze View", - "type": "string", - "facetable": true, - "title": "viewtype" - }, - "titleViewSwc": { - "description": "Titel view SWC van deze View", - "type": "string", - "facetable": false, - "title": "titleViewSwc" - }, - "gemmaType": { - "description": "GEMMA type van deze View", - "type": "string", - "facetable": true, - "title": "gemmaType" - }, - "gemmaThema": { - "description": "GEMMA thema van deze View", - "type": "string", - "facetable": true, - "title": "gemmaThema" - }, - "gemmaUrl": { - "description": "GEMMA URL van deze View", - "type": "string", - "facetable": false, - "title": "gemmaUrl" - }, - "gemmaStatus": { - "description": "GEMMA status van deze View", - "type": "string", - "facetable": true, - "title": "gemmaStatus" - }, - "contextview": { - "description": "Contextview van deze View", - "type": "string", - "facetable": false, - "title": "contextview" - }, - "layoutDirectionBo": { - "description": "Layout direction BO van deze View", - "type": "string", - "facetable": false, - "title": "layoutDirectionBo" - }, - "layoutDirectionDo": { - "description": "Layout direction DO van deze View", - "type": "string", - "facetable": false, - "title": "layoutDirectionDo" - }, - "detailLevel": { - "description": "Detailniveau van deze View", - "type": "string", - "facetable": true, - "title": "detailLevel" - }, - "scope": { - "description": "Scope van deze View", - "type": "string", - "facetable": true, - "title": "scope" - }, - "publish": { - "description": "Publiceren van deze View", - "type": "string", - "facetable": false, - "title": "publish" - }, - "omschrijving": { - "description": "Summary van deze View", - "type": "string", - "facetable": false, - "title": "summary" - }, - "xml": { - "type": "object", - "title": "XML Data", - "description": "Original ArchiMate XML structure for round-trip export fidelity" - } - }, - "archive": [], - "source": "internal", - "hardValidation": false, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": null, - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "read": [ - "public" - ] - }, - "configuration": { - "autoPublish": false, - "objectNameField": "name", - "objectDescriptionField": "omschrijving" - } - }, - "vulnerability": { - "uri": null, - "slug": "vulnerability", - "x-openregister-notifications": { - "vulnerability-reported": { - "trigger": { - "type": "created" - }, - "enabled": true, - "channels": [ - "nc-notification", - "email" - ], - "recipients": [ - { - "kind": "groups", - "groups": [ - "software-catalog-admins" - ] - }, - { - "kind": "object-acl", - "permission": "manage" - } - ], - "subject": { - "nl": "Kwetsbaarheid gemeld: {{naam}} ({{cveCode}}, CVSS {{cvssScore}})", - "en": "Vulnerability reported: {{naam}} ({{cveCode}}, CVSS {{cvssScore}})" - } - } - }, - "title": "Vulnerability", - "description": "Schema voor kwetsbaarheden. Dit schema is onderdeel van het vastgestelde datamodel maar wordt niet daadwerkelijk in de applicatie gebruikt.", - "version": "1.0.20", - "omschrijving": "", - "icon": "ShieldAlertOutline", - "required": [ - "name", - "shortDescription", - "modules" - ], - "properties": { - "name": { - "description": "Naam van de kwetsbaarheid", - "type": "string", - "visible": true, - "order": 1, - "facetable": false, - "title": "Name", - "maxLength": 200, - "example": "Bijvoorbeeld: SQL Injection" - }, - "shortDescription": { - "description": "Korte beschrijving van de kwetsbaarheid", - "type": "string", - "maxLength": 255, - "visible": true, - "order": 2, - "facetable": false, - "title": "Summary", - "example": "Bijvoorbeeld: Korte beschrijving van de kwetsbaarheid" - }, - "longDescription": { - "description": "Uitgebreide beschrijving van de kwetsbaarheid", - "type": "string", - "format": "markdown", - "visible": true, - "order": 3, - "facetable": false, - "title": "Description", - "maxLength": 5000, - "example": "Bijvoorbeeld: Uitgebreide beschrijving van de kwetsbaarheid" - }, - "cveCode": { - "description": "CVE (Common Vulnerabilities and Exposures) identificatiecode", - "type": "string", - "pattern": "^CVE-\\d{4}-\\d{4,}$", - "visible": true, - "order": 4, - "facetable": false, - "title": "CVE Code", - "maxLength": 20, - "example": "Bijvoorbeeld: CVE-2021-44228" - }, - "cvssScore": { - "description": "CVSS (Common Vulnerability Scoring System) score van 0.0 tot 10.0", - "type": "number", - "minimum": 0, - "maximum": 10, - "visible": true, - "order": 5, - "facetable": false, - "title": "CVSS Score", - "example": "Bijvoorbeeld: 9.8" - }, - "modules": { - "description": "De applicaties die door deze kwetsbaarheid getroffen worden", - "type": "array", - "visible": true, - "order": 6, - "facetable": false, - "title": "Affected applications", - "items": { - "type": "object", - "objectConfiguration": { - "handling": "related-object" - }, - "$ref": "#/components/schemas/module", - "inversedBy": "vulnerability" - } - } - }, - "archive": [], - "source": "internal", - "hardValidation": true, - "immutable": false, - "searchable": true, - "maxDepth": 0, - "owner": "1", - "application": null, - "organisation": null, - "groups": null, - "authorization": { - "create": [ - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar", - "aanbod-beheerder" - ], - "read": [ - "public", - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisatie-beheerder", - "organisaties-beheerder", - "gebruik-raadpleger" - ], - "update": [ - "aanbod-beheerder", - "ambtenaar", - "functioneel-beheerder", - "gebruik-beheerder", - "gebruik-raadpleger", - "organisatie-beheerder", - "organisaties-beheerder", - "software-catalog-admins", - "software-catalog-users", - "vng-raadpleger" - ], - "delete": [ - "aanbod-beheerder", - "vng-raadpleger", - "software-catalog-users", - "software-catalog-admins", - "organisaties-beheerder", - "organisatie-beheerder", - "gebruik-raadpleger", - "gebruik-beheerder", - "functioneel-beheerder", - "ambtenaar" - ] - }, - "configuration": { - "objectNameField": "name", - "objectSummaryField": "shortDescription", - "objectDescriptionField": "longDescription", - "autoPublish": false - } - } - }, "objects": [ { "@self": { @@ -7665,7 +384,10 @@ "opmerkingen": "Voorbeeld Opmerkingen 2", "decisions": [ "00000000-0000-4000-8000-000000000001" - ] + ], + "licenceMetric": "Per named user", + "licencesBought": 400, + "licencesInUse": 460 }, { "@self": { @@ -7698,6 +420,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 +1391,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 +1438,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 +1454,8 @@ "consumer": {}, "provider": {}, "contactPerson": {}, + "businessOwner": {}, + "technicalOwner": {}, "participants": [ {} ], @@ -8719,7 +1487,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 +2977,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 +3024,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 +3040,8 @@ "consumer": {}, "provider": {}, "contactPerson": {}, + "businessOwner": {}, + "technicalOwner": {}, "participants": [ {} ], @@ -10288,7 +3073,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 +3235,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..7ee628b87 100644 --- a/openapi.json +++ b/openapi.json @@ -2,10 +2,868 @@ "openapi": "3.0.3", "info": { "title": "stackiq", - "version": "0.2.2-unstable.20260910105110", + "version": "0.2.3-unstable.20260912202721", "description": "Stackiq", "license": { "name": "agpl" } + }, + "paths": { + "/index.php/apps/stackiq/api/cmdb-import": { + "post": { + "operationId": "cmdbImport-import", + "summary": "Import a TOPdesk CMDB export (xlsx) for one municipality", + "description": "Nextcloud admins only (not the groups delegated the stackiq admin settings), CSRF-protected (requesttoken header or OCS-APIRequest: true). Creates or updates modules, supplier organisations, usages and owner contact persons, matched on topdesk::. See openspec/changes/archive/2026-10-05-cmdb-export-import/contract.md.", + "tags": [ + "cmdb-import" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "cmdbFile" + ], + "properties": { + "cmdbFile": { + "type": "string", + "format": "binary", + "description": "The TOPdesk export, .xlsx, at most 10 MB" + }, + "municipalityUuid": { + "type": "string", + "format": "uuid", + "description": "An existing organization of type Municipality; wins over municipalityName" + }, + "municipalityName": { + "type": "string", + "description": "Name of a Municipality to reuse (same normalised name) or create" + }, + "updateExisting": { + "type": "string", + "enum": [ + "true", + "false" + ], + "default": "true", + "description": "false reports matched rows as skipped (exists). 1 and 0 are accepted too, trimmed and in any case; any other value is refused with 400 FIELD_INVALID" + }, + "publish": { + "type": "string", + "enum": [ + "true", + "false" + ], + "default": "true", + "description": "true gives every module the import creates a publicationDate (the import's start), so it is public, also to anonymous visitors; false creates them without one, for publication by hand. An update never changes publicationDate either way. 1 and 0 are accepted too, trimmed and in any case; any other value is refused with 400 FIELD_INVALID" + }, + "missingRecords": { + "type": "string", + "enum": [ + "keep" + ], + "default": "keep", + "description": "Only keep is accepted; mark and remove are reserved" + }, + "operationId": { + "type": "string", + "pattern": "^cmdb-[A-Za-z0-9-]{8,64}$", + "description": "Progress operation id, readable through GET /api/progress/{operationId}. A value that does not match the pattern, or the id of a CMDB import that is still running, is replaced by a generated id; the response's operationId is the id the import used" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The import report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportReport" + } + } + } + }, + "400": { + "description": "NO_FILE_UPLOADED, NOT_XLSX, or FIELD_INVALID (updateExisting or publish not true/false/1/0, or a text field sent as an array; details.field names it)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Not a Nextcloud admin (delegated stackiq admin settings are not enough)" + }, + "409": { + "description": "IMPORT_IN_PROGRESS: another CMDB import holds the register's lock; nothing is read or written", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "412": { + "description": "Missing or invalid CSRF token" + }, + "413": { + "description": "FILE_TOO_LARGE; details.maxBytes is the limit that fired: the profile's maximum, or PHP's upload_max_filesize or post_max_size when lower. WORKBOOK_TOO_LARGE; over a profile limit checked before parsing: the unpacked size of all parts (details.maxUncompressedBytes), of one part the import may parse (details.maxPartBytes, details.part, details.size and, for a sheet, details.sheet; a sheet the import does not read may be larger), the number of shared strings (details.maxSharedStrings), or the shared-string text the cells reference together (details.maxReferencedStringBytes)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "422": { + "description": "MISSING_RECORDS_UNSUPPORTED, MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID, MUNICIPALITY_AMBIGUOUS (more than one live Municipality has the typed name; details.matches lists their uuids), NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "500": { + "description": "UPLOAD_FAILED (PHP could not store the upload) or IMPORT_FAILED (unexpected; the detail is in the log)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "503": { + "description": "MAPPING_UNAVAILABLE, READER_UNAVAILABLE, NOT_CONFIGURED, or SCHEMA_OUTDATED (a stackiq schema lacks a property the import matches on, or the module schema predates 0.3.8; details.schema names it and details.missing lists the properties, and externalKey.authorization.update for a missing admin-only write rule)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + } + } + } + }, + "/index.php/apps/stackiq/api/cmdb-import/{operationId}/cancel": { + "post": { + "operationId": "cmdbImport-cancel", + "summary": "Ask a running CMDB import to stop between rows", + "description": "Nextcloud admins only (not the groups delegated the stackiq admin settings), CSRF-protected (requesttoken header or OCS-APIRequest: true). No body.", + "tags": [ + "cmdb-import" + ], + "parameters": [ + { + "name": "operationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^cmdb-[A-Za-z0-9-]{8,64}$" + }, + "description": "The operationId of the import; any other value answers 404 OPERATION_NOT_FOUND" + } + ], + "responses": { + "200": { + "description": "Cancel requested", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "success", + "cancelRequested" + ], + "properties": { + "success": { + "type": "boolean" + }, + "cancelRequested": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Not a Nextcloud admin (delegated stackiq admin settings are not enough)" + }, + "404": { + "description": "OPERATION_NOT_FOUND: no CMDB import with this id is running", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "412": { + "description": "Missing or invalid CSRF token" + }, + "500": { + "description": "IMPORT_FAILED (unexpected; the exception class and its sanitised first line are in the log)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + } + } + } + }, + "/index.php/apps/stackiq/api/itsm/status": { + "get": { + "operationId": "itsmExchange-status", + "summary": "Whether the service desk exchange runs, and with which desk", + "description": "Any signed-in user, CSRF-protected (requesttoken header or OCS-APIRequest: true). The CMDB page reads it. See openspec/changes/sharing-itsm-exchange.", + "tags": [ + "itsm-exchange" + ], + "responses": { + "200": { + "description": "The exchange summary", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmStatus" + } + } + } + }, + "401": { + "description": "Not signed in" + }, + "412": { + "description": "Missing or invalid CSRF token" + } + } + } + }, + "/index.php/apps/stackiq/api/itsm/config": { + "get": { + "operationId": "itsmExchange-config", + "summary": "The full service desk exchange set-up, for the admin section", + "description": "Nextcloud admins and users delegated the stackiq admin settings, CSRF-protected (requesttoken header or OCS-APIRequest: true).", + "tags": [ + "itsm-exchange" + ], + "responses": { + "200": { + "description": "The set-up", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmConfig" + } + } + } + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Not an admin of the stackiq settings" + }, + "412": { + "description": "Missing or invalid CSRF token" + } + } + } + }, + "/index.php/apps/stackiq/api/itsm/setup": { + "post": { + "operationId": "itsmExchange-setUp", + "summary": "Set up, or set up again, the service desk exchange flows", + "description": "Nextcloud admins and users delegated the stackiq admin settings, CSRF-protected (requesttoken header or OCS-APIRequest: true). Every flow is checked by OpenRegister's preflight first; nothing is saved unless all pass. The scheduled imports run as the signed-in user. The source and its credential live in integriq.", + "tags": [ + "itsm-exchange" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "desk", + "organisation" + ], + "properties": { + "desk": { + "type": "string", + "enum": [ + "topdesk", + "servicenow" + ], + "description": "The service desk" + }, + "organisation": { + "type": "string", + "description": "Id, uuid or slug of the stackiq organization whose applications are exchanged; stored as its uuid" + }, + "templateId": { + "type": "string", + "default": "", + "description": "The TOPdesk asset template a new record is created from; empty for ServiceNow" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Every flow was created or updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmSetUpResult" + } + } + } + }, + "401": { + "description": "Not signed in", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmSetUpRefusal" + } + } + } + }, + "403": { + "description": "Not an admin of the stackiq settings" + }, + "412": { + "description": "Missing or invalid CSRF token" + }, + "422": { + "description": "Nothing was set up: the flow engine, the desk, the organisation or integriq's source is missing, a flow failed preflight, a template could not be read, or saving the flows failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmSetUpRefusal" + } + } + } + } + } + } + }, + "/index.php/apps/stackiq/api/itsm/import": { + "post": { + "operationId": "itsmExchange-import", + "summary": "Import a CSV or XLSX file through the file import flow", + "description": "Nextcloud admins and users delegated the stackiq admin settings, CSRF-protected (requesttoken header or OCS-APIRequest: true). Needs a set-up exchange: the rows are handed, as one run, to the file flow the set-up created.", + "tags": [ + "itsm-exchange" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "A .csv (comma or semicolon) or .xlsx file, at most 10 MB, with a header row and at most 5000 rows; every row needs a recordId. Columns: recordId, name, supplierName, installedVersion, status" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The run started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmImportStarted" + } + } + } + }, + "400": { + "description": "No file was uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmImportRefusal" + } + } + } + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Not an admin of the stackiq settings" + }, + "412": { + "description": "Missing or invalid CSRF token" + }, + "422": { + "description": "Nothing started: no exchange is set up, the file is too large, not CSV or XLSX, unreadable, empty, over 5000 rows or has a row without recordId, or the flow could not be started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItsmImportRefusal" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CmdbImportReport": { + "type": "object", + "required": [ + "success", + "operationId", + "cancelled", + "municipality", + "summary", + "importWarnings", + "rows" + ], + "properties": { + "success": { + "type": "boolean" + }, + "operationId": { + "type": "string" + }, + "cancelled": { + "type": "boolean" + }, + "municipality": { + "type": "object", + "required": [ + "uuid", + "name", + "created" + ], + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created": { + "type": "boolean" + } + } + }, + "summary": { + "type": "object", + "required": [ + "rowsRead", + "processed", + "created", + "updated", + "unchanged", + "skipped", + "failed", + "warnings", + "unpublished" + ], + "properties": { + "rowsRead": { + "type": "integer" + }, + "processed": { + "type": "integer" + }, + "created": { + "type": "integer" + }, + "updated": { + "type": "integer" + }, + "unchanged": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "warnings": { + "type": "integer" + }, + "unpublished": { + "type": "integer", + "description": "Modules this import created without a publicationDate (publish=false); 0 when publish is true" + } + } + }, + "importWarnings": { + "type": "array", + "items": { + "type": "object", + "required": [ + "sheet", + "message" + ], + "properties": { + "sheet": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": [ + "sheet", + "row", + "appId", + "name", + "outcome", + "reasons", + "warnings", + "moduleUuid", + "usageUuid" + ], + "properties": { + "sheet": { + "type": "string" + }, + "row": { + "type": "integer" + }, + "appId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "created", + "updated", + "unchanged", + "skipped", + "failed" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "moduleUuid": { + "type": "string", + "nullable": true + }, + "usageUuid": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "CmdbImportError": { + "type": "object", + "required": [ + "success", + "error", + "message", + "details" + ], + "properties": { + "success": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": true + } + } + }, + "ItsmStatus": { + "type": "object", + "required": [ + "available", + "enabled", + "desk", + "setUpAt", + "desks" + ], + "properties": { + "available": { + "type": "boolean", + "description": "Whether OpenRegister's flow engine is there" + }, + "enabled": { + "type": "boolean", + "description": "Whether the exchange is switched on" + }, + "desk": { + "type": "string", + "nullable": true, + "description": "The desk key, or null before a set-up" + }, + "setUpAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "desks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + } + } + }, + "ItsmConfig": { + "type": "object", + "required": [ + "available", + "enabled", + "desk", + "organisation", + "flows", + "setUpAt", + "desks" + ], + "properties": { + "available": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "desk": { + "type": "string", + "nullable": true + }, + "organisation": { + "type": "string", + "nullable": true, + "description": "The organisation uuid" + }, + "flows": { + "type": "object", + "description": "Flow uuids by flow key (applications, relations, licences, contracts, outbound, file)", + "additionalProperties": { + "type": "string" + } + }, + "setUpAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "desks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + } + } + }, + "ItsmSetUpResult": { + "type": "object", + "required": [ + "created", + "desk", + "flows" + ], + "properties": { + "created": { + "type": "boolean", + "enum": [ + true + ] + }, + "desk": { + "type": "string" + }, + "flows": { + "type": "object", + "description": "Flow uuids by flow key", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ItsmSetUpRefusal": { + "type": "object", + "required": [ + "created", + "message" + ], + "properties": { + "created": { + "type": "boolean", + "enum": [ + false + ] + }, + "message": { + "type": "string", + "description": "What stopped the set-up, to show as it is" + }, + "blocking": { + "description": "OpenRegister's blocking preflight findings by flow key; an empty list when the refusal is not a preflight finding", + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "additionalProperties": true + } + } + }, + { + "type": "array", + "maxItems": 0, + "items": {} + } + ] + } + } + }, + "ItsmImportStarted": { + "type": "object", + "required": [ + "started", + "run", + "rows" + ], + "properties": { + "started": { + "type": "boolean", + "enum": [ + true + ] + }, + "run": { + "type": "string", + "description": "The uuid of the flow run" + }, + "rows": { + "type": "integer", + "description": "The rows handed to the run" + } + } + }, + "ItsmImportRefusal": { + "type": "object", + "required": [ + "started", + "message" + ], + "properties": { + "started": { + "type": "boolean", + "enum": [ + false + ] + }, + "message": { + "type": "string", + "description": "Why nothing started, to show as it is" + } + } + } + } } -} \ No newline at end of file +} 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-shared-usage-on-views/design.md b/openspec/changes/architecture-shared-usage-on-views/design.md new file mode 100644 index 000000000..cf9afa45e --- /dev/null +++ b/openspec/changes/architecture-shared-usage-on-views/design.md @@ -0,0 +1,49 @@ +# Design: architecture-shared-usage-on-views + +Read at stackiq development 8f74f890. + +No board covers this change: stackiq is one of the apps without a canvas board (decision 75). The page is the read-only view page that `architecture-views-editor` specifies; this change adds a control and a legend to it. + +## Where it fits + +| Part | File and line | What changes | +|---|---|---| +| Page | `src/views/architecture/ArchitectureViewEditor.vue` (from `architecture-views-editor`) | read-only mode gets the "Show applications" control, the overlay nodes and the legend | +| Mapping | new `src/utils/usageOverlay.js` | turns enriched view nodes into Vue Flow nodes for own and shared usage | +| Store | `src/store/modules/view.js:15` `useViewStore` | its fetch of one view takes `includeGebruik` and `includeDeelnamesGebruik` and passes them as query parameters | +| Export | `lib/Service/ArchiMateExportService.php` `copyAndEnrichViews()` (`:2731`) and `processNodesForInjection()` (`:2956`) | each nested application carries its kind (own or shared) and partner; shared ones get their own style | +| Export | `lib/Service/ArchiMateExportService.php` `generateApplicationElements()` (`:2643`) | shared elements get the property `Gedeeld door` | +| Strings | `l10n/en.json`, `l10n/nl.json` | "Show applications", "Our applications", "Shared with partners", "Shared", "Shared by %s", legend lines | + +No new route or controller. The page reads the existing view API, which already separates the two lists. + +## Decisions + +### D1. Draw in two places, because there are two readers + +An information manager looks at the view in stackiq. An enterprise architect takes the export into Archi. The spec in `module-overlay-rendering` asked for the difference on screen; the export is where stackiq draws views today, and it currently erases the difference. Both get it. + +### D2. Shared is marked by shape and text, not by colour alone + +WCAG 2.2 success criterion 1.4.1 forbids colour as the only carrier. On screen a shared node has a dashed border (`border-style: dashed`, `--color-border-maxcontrast`), the text "Shared" before its name, and an accessible name ", shared by ". An own node has a solid `--color-primary-element` border. Colours come from Nextcloud variables, so NL Design themes apply (ADR-003). + +### D3. Nest overlay nodes inside their reference component + +`ViewService` attaches usage to the enriched node of the reference component. `usageOverlay.js` creates one Vue Flow node per usage with `parentNode` set to that component's node id and `extent: 'parent'`, and stacks them from the bottom of the parent, as `processNodesForInjection()` stacks them in the export (`:2985-2990`). A component with more usages than fit shows the first ones and a "+N more" node that opens the list. + +Rejected: a side panel listing usages per component. It answers "what fills this box" but not "where are our shared applications on the map". + +### D4. In the export, keep one decision for owned versus shared + +`copyAndEnrichViews()` builds `$refCompApps` from the merged element list (`:2758`). It gains a `kind` (`own` or `shared`) and a `partner` per entry, taken from the element lists that `:2451-2471` already keep apart. When both lists hold the same application for the same reference component, the owned entry wins, matching `ViewService.php:467-469`. `processNodesForInjection()` picks the style by `kind`: + +| Kind | fillColor | lineColor | +|---|---|---| +| own | 200, 255, 200 (unchanged) | 0, 150, 0 | +| shared | 210, 225, 255 | 40, 80, 170 | + +`generateApplicationElements()` adds the property `Gedeeld door` with the partner's name to shared elements, through the same property definition mechanism the export uses for its source property (`addSourceProperty()`, `:2885`). + +### D5. The switches start off and remember nothing + +The read-only view opens as plain GEMMA, like today's import. The switches are page state, not a user setting, so a shared link shows the same view to everyone. diff --git a/openspec/changes/architecture-shared-usage-on-views/proposal.md b/openspec/changes/architecture-shared-usage-on-views/proposal.md new file mode 100644 index 000000000..32895fe6d --- /dev/null +++ b/openspec/changes/architecture-shared-usage-on-views/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: + - architecture-views-editor +--- + +# Shared applications are drawn apart from your own on a GEMMA view + +## Summary + +An information manager opens a GEMMA view and sees which applications fill each reference component. Applications the organisation uses through a partner (deelnames) look different from the ones it runs itself, and each one names the partner. This works in two places: on the GEMMA view page inside stackiq, and in the organisation's ArchiMate export that opens in Archi. + +## Why + +This change covers one matrix row. + +- `stackiq:arch-shared-overlay`, "On a GEMMA view, see the applications you share with partners, drawn apart from your own." Stackiq rates partial, state specified. The delivered change `2026-06-14-module-overlay-rendering` (archived) wrote the requirement in `openspec/specs/module-overlay-rendering/spec.md:55-79`: a deelnames node must be styled differently from an owned one and must carry a visual sign of shared ownership. It assumed a JointJS renderer in the external VNG frontend, which stackiq does not ship. The VNG Softwarecatalogus rates partial: a cooperation's packages show "alleen op de lijst en kaart van de samenwerking" (https://www.softwarecatalogus.nl/node/30355), but drawing shared apart from own is not described. + +The backend half is built. The drawing half is missing in both places stackiq can draw a view. + +## What stackiq has today + +Read at development 8f74f890. + +- `lib/Service/ViewService.php:463` reads shared usage when `include_deelnames_gebruik` is set, `:467-469` drops a shared usage when the organisation also owns one for the same element, and `:932-955` tags each one `_type: deelnames` with `_sourceOrganizationId` and `_sourceOrganization`. `:494` and `:501` put the owned list under `usage` and the shared list under `deelnamesGebruik` on each enriched view node. +- `lib/Controller/ViewController.php:308-313` exposes `include_gebruik` and `include_deelnames_gebruik` on `GET /api/views/{viewId}`. +- `lib/Service/ArchiMateExportService.php:2451-2471` builds the shared applications for the organisation export when the Deelnames box is ticked (`src/views/settings/sections/ArchiMateImportExport.vue:614`), then merges them with the owned ones before `copyAndEnrichViews()` (`:2474`, `:2731`). `processNodesForInjection()` (`:2956`) gives every nested application the same green style (`:3003-3004`). The only difference left is a separate folder, `Deelnames (Stackiq)` (`:3083`), which Archi shows in the model tree, not on the view. +- `src/store/modules/view.js:15` defines `useViewStore`, imported by nothing. No page in stackiq draws a GEMMA view today. The open change `architecture-views-editor` adds one: imported GEMMA views open read-only on `CnGraphCanvas` in `src/views/architecture/ArchitectureViewEditor.vue`. + +## What this change builds + +- On the read-only GEMMA view page from `architecture-views-editor`: a "Show applications" control with two switches, "Our applications" and "Shared with partners". Switched on, the page asks `GET /api/views/{viewId}` with `include_gebruik` and `include_deelnames_gebruik`, and draws each application inside its reference component. +- A shared application has a dashed border, the text "Shared" on the node, and "Shared by " in its tooltip and accessible name. An own application has a solid border. A legend under the canvas explains both. +- In the organisation ArchiMate export: shared applications get their own fill and line colour, and a property `Gedeeld door` naming the partner, so Archi draws them apart on every view copy. When an application is both owned and shared for the same reference component, it is drawn once, as owned, the rule `ViewService` already applies. + +## Out of scope + +- The external VNG frontend. It is a VNG client repository (`Softwarecatalogus/`) that this team does not commit to. Whether it draws deelnames apart is its own question. +- Drawing applications on views the user drew themselves. Drawn views hold elements placed by hand; the overlay is for imported GEMMA views. +- Editing usage from the view. A click opens the usage detail page; changes happen there. +- Which partners count as deelnames. That stays the deelnemers relation `ViewService` reads today. + +## Risks + +- `CnGraphCanvas` renders every node through `CnFlowNode`. If it does not pass a node's `class` through to Vue Flow's wrapper, the dashed border needs a small library change. Task 2 checks this first; if it fails, the builder files it against nextcloud-vue and the text badge still carries the difference. +- A large GEMMA view with many usages can hold several hundred extra nodes. The switches start off, so the plain view stays as fast as `architecture-views-editor` makes it. +- Colours in an ArchiMate exchange file are fixed RGB values, not theme variables. The export picks a pair with at least 3:1 contrast between the two fills, and the property carries the meaning for readers who cannot tell them apart. diff --git a/openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md b/openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md new file mode 100644 index 000000000..fa498374b --- /dev/null +++ b/openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md @@ -0,0 +1,79 @@ +# shared-usage-on-views specification + +**Status**: proposed +**Scope**: stackiq +**OpenSpec changes**: +- architecture-shared-usage-on-views + +## Purpose + +An information manager sees, on a GEMMA view, which applications fill each reference component, and can tell at a glance which ones the organisation runs itself and which it uses through a partner. The same difference shows when the organisation's export is opened in Archi. + +## ADDED Requirements + +### Requirement: REQ-SUV-001 The GEMMA view page MUST let a user show own and shared applications on the view + +The read-only GEMMA view page SHALL offer a "Show applications" control with two switches, "Our applications" and "Shared with partners", both off when the page opens. With a switch on, the page SHALL request `GET /api/views/{viewId}` with `include_gebruik` or `include_deelnames_gebruik` set, and SHALL draw each usage as a node inside the reference component it belongs to. + +#### Scenario: An information manager shows the shared applications +@e2e tests/e2e/spec-coverage/shared-usage-on-views.spec.ts + +- **GIVEN** a GEMMA view with reference component "Zaakregistratiecomponent", and the organisation uses application "Zaaksysteem X" there through partner "Gemeente Utrecht" +- **WHEN** the information manager opens the view and switches on "Shared with partners" +- **THEN** a node "Zaaksysteem X" SHALL appear inside "Zaakregistratiecomponent" +- **AND** the node SHALL show the text "Shared" + +#### Scenario: The view opens without overlays +@e2e tests/e2e/spec-coverage/shared-usage-on-views.spec.ts + +- **GIVEN** a GEMMA view with own and shared usage +- **WHEN** a user opens the view +- **THEN** both switches SHALL be off +- **AND** no application node SHALL be drawn + +### Requirement: REQ-SUV-002 A shared application MUST be drawn apart from an own application without relying on colour + +A shared node SHALL have a dashed border, the visible text "Shared", and an accessible name ", shared by ". An own node SHALL have a solid border and no "Shared" text. A legend under the canvas SHALL explain both. Colours SHALL come from Nextcloud CSS variables. + +#### Scenario: Own and shared look different on the same component +@e2e tests/e2e/spec-coverage/shared-usage-on-views.spec.ts + +- **GIVEN** a reference component that holds own application "Suite A" and shared application "Suite B" from partner "Gemeente Utrecht" +- **WHEN** both switches are on +- **THEN** "Suite A" SHALL have a solid border and no "Shared" text +- **AND** "Suite B" SHALL have a dashed border and the text "Shared" +- **AND** the legend SHALL list "Our application" and "Shared with a partner" + +#### Scenario: A screen reader user hears who shares the application +@e2e tests/e2e/spec-coverage/shared-usage-on-views.spec.ts + +- **GIVEN** shared application "Suite B" from partner "Gemeente Utrecht" is drawn +- **WHEN** keyboard focus reaches its node +- **THEN** its accessible name SHALL be "Suite B, shared by Gemeente Utrecht" + +#### Scenario: An application both owned and shared is drawn once as own +@e2e exclude The API already drops the shared copy (lib/Service/ViewService.php:467-469); tests/vitest/usageOverlay.spec.js asserts the mapping draws one own node. + +- **GIVEN** the organisation runs "Suite A" itself and also uses it through a partner on the same component +- **WHEN** both switches are on +- **THEN** exactly one "Suite A" node SHALL be drawn on that component, as own + +### Requirement: REQ-SUV-003 The organisation ArchiMate export MUST draw shared applications apart from own ones + +When the organisation export includes deelnames, every shared application nested in a view copy SHALL get a fill and line colour different from own applications, and its element SHALL carry the property `Gedeeld door` with the partner's name. An application both owned and shared for the same reference component SHALL be nested once, with the own style. + +#### Scenario: Archi shows shared applications in their own colour +@e2e exclude The export is an XML download; tests/Unit/Service/ArchiMateExportSharedUsageStyleTest.php checks the generated nodes and properties. + +- **GIVEN** an organisation with own application "Suite A" and shared application "Suite B" on the same reference component +- **WHEN** an admin runs the organisation export with Deelnames ticked +- **THEN** the nested node for "Suite A" SHALL have fillColor 200,255,200 +- **AND** the nested node for "Suite B" SHALL have fillColor 210,225,255 +- **AND** the element "Suite B" SHALL have the property `Gedeeld door` with value "Gemeente Utrecht" + +#### Scenario: The export without deelnames is unchanged +@e2e exclude Covered by tests/Unit/Service/ArchiMateExportSharedUsageStyleTest.php. + +- **GIVEN** the same organisation +- **WHEN** the export runs with Deelnames not ticked +- **THEN** only own applications SHALL be nested, with the own style, as before this change diff --git a/openspec/changes/architecture-shared-usage-on-views/tasks.md b/openspec/changes/architecture-shared-usage-on-views/tasks.md new file mode 100644 index 000000000..e077317fd --- /dev/null +++ b/openspec/changes/architecture-shared-usage-on-views/tasks.md @@ -0,0 +1,68 @@ +# Tasks: architecture-shared-usage-on-views + +Starts after `architecture-views-editor` has landed the read-only GEMMA view page. + +## Implementation tasks + +### Task 1: Fetch enriched views from the store +- **spec_ref**: openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md#requirement-req-suv-001-the-gemma-view-page-must-let-a-user-show-own-and-shared-applications-on-the-view +- **files**: `src/store/modules/view.js`, `tests/vitest/viewStoreEnrichment.spec.js` +- **acceptance_criteria**: + - GIVEN `includeGebruik: true` WHEN the store fetches one view THEN the request carries `include_gebruik=true` + - GIVEN `includeDeelnamesGebruik: true` WHEN the store fetches one view THEN the request carries `include_deelnames_gebruik=true` + - GIVEN neither flag WHEN the store fetches THEN neither parameter is sent +- [ ] Implement +- [ ] Test (vitest tests/vitest/viewStoreEnrichment.spec.js) + +### Task 2: Map usage to overlay nodes +- **spec_ref**: openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md#requirement-req-suv-002-a-shared-application-must-be-drawn-apart-from-an-own-application-without-relying-on-colour +- **files**: `src/utils/usageOverlay.js`, `tests/vitest/usageOverlay.spec.js` +- **acceptance_criteria**: + - GIVEN an enriched node with one `usage` and one `deelnamesGebruik` entry WHEN mapped THEN two Vue Flow nodes come back with `parentNode` set to the component's node id and `extent: 'parent'` + - GIVEN a `deelnamesGebruik` entry WHEN mapped THEN its node has class `usage-node--shared`, `data.badge` "Shared" and `data.ariaLabel` ", shared by <_sourceOrganization>" + - GIVEN the same application in both lists for one component WHEN mapped THEN one own node comes back + - GIVEN more usages than fit the component's height WHEN mapped THEN the last node is "+N more" + - GIVEN `CnGraphCanvas` from `@conduction/nextcloud-vue` WHEN a node with a `class` renders THEN the class reaches the Vue Flow node wrapper (check first; if not, file it on nextcloud-vue and keep the badge) +- [ ] Implement +- [ ] Test (vitest tests/vitest/usageOverlay.spec.js) + +### Task 3: Add the control, the overlay and the legend to the read-only view page +- **spec_ref**: openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md#requirement-req-suv-001-the-gemma-view-page-must-let-a-user-show-own-and-shared-applications-on-the-view +- **files**: `src/views/architecture/ArchitectureViewEditor.vue`, `src/components/architecture/UsageOverlayLegend.vue`, `l10n/en.json`, `l10n/nl.json`, `tests/e2e/spec-coverage/shared-usage-on-views.spec.ts` +- **acceptance_criteria**: + - GIVEN an imported view in read-only mode WHEN the page opens THEN "Show applications" is visible with "Our applications" and "Shared with partners" off + - GIVEN a drawn view WHEN the page opens THEN the control is not shown + - GIVEN "Shared with partners" switched on WHEN the view reloads THEN shared nodes appear inside their components with a dashed border and the text "Shared" + - GIVEN any switch on WHEN the page renders THEN the legend lists "Our application" and "Shared with a partner" + - GIVEN a Dutch instance WHEN the page renders THEN every new label is Dutch + - GIVEN a click on an overlay node WHEN it is handled THEN the usage detail page (`GebruikDetail`) opens +- [ ] Implement +- [ ] Test (Playwright tests/e2e/spec-coverage/shared-usage-on-views.spec.ts) + +### Task 4: Style shared applications apart in the organisation export +- **spec_ref**: openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md#requirement-req-suv-003-the-organisation-archimate-export-must-draw-shared-applications-apart-from-own-ones +- **files**: `lib/Service/ArchiMateExportService.php`, `tests/Unit/Service/ArchiMateExportSharedUsageStyleTest.php` +- **acceptance_criteria**: + - GIVEN own and shared application elements WHEN `copyAndEnrichViews()` builds its lookup THEN every entry has `kind` own or shared and shared entries name the partner + - GIVEN the same application owned and shared on one component WHEN nodes are injected THEN one node is nested, with the own style + - GIVEN a shared entry WHEN `processNodesForInjection()` nests it THEN fillColor is 210,225,255 and lineColor 40,80,170 + - GIVEN a shared element WHEN it is generated THEN it carries the property `Gedeeld door` with the partner name + - GIVEN the export without deelnames WHEN it runs THEN the output equals today's for own applications +- [ ] Implement +- [ ] Test (PHPUnit tests/Unit/Service/ArchiMateExportSharedUsageStyleTest.php) + +### Task 5: Seed and document +- **spec_ref**: openspec/changes/architecture-shared-usage-on-views/specs/shared-usage-on-views/spec.md#requirement-req-suv-002-a-shared-application-must-be-drawn-apart-from-an-own-application-without-relying-on-colour +- **files**: `lib/Settings/stackiq_mock_register.json`, `docs/features/shared-usage-on-views.md` +- **acceptance_criteria**: + - GIVEN a fresh demo import WHEN a GEMMA view opens with both switches on THEN at least one own and one shared application show on the same component + - GIVEN the docs WHEN a reader opens the page THEN it explains both switches, the legend and the export colours, with a screenshot (ADR-010) +- [ ] Implement + +## Verification + +- `openspec validate architecture-shared-usage-on-views --type change --strict` +- vitest: tests/vitest/viewStoreEnrichment.spec.js, tests/vitest/usageOverlay.spec.js +- PHPUnit: tests/Unit/Service/ArchiMateExportSharedUsageStyleTest.php +- Playwright: tests/e2e/spec-coverage/shared-usage-on-views.spec.ts +- Open the organisation export in Archi once and screenshot a view with both kinds, for the PR body 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/conceptOrganisatiesWidget.js b/src/conceptOrganisatiesWidget.js index 33312c7c0..03258d3af 100644 --- a/src/conceptOrganisatiesWidget.js +++ b/src/conceptOrganisatiesWidget.js @@ -10,7 +10,8 @@ import pinia from './pinia.js' import '@conduction/nextcloud-vue/css/index.css' OCA.Dashboard.register( - 'stackiq_concept_organisaties_widget', + // FROZEN: must equal ConceptOrganisatiesWidget::getId(), which kept its pre-rename value. + 'softwarecatalog_concept_organisaties_widget', async (el, { widget }) => { const app = createApp({ render: () => h(ConceptOrganisatiesWidget, { title: widget.title }), diff --git a/src/customComponents.js b/src/customComponents.js index dac0c48d3..d3c8c0f77 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,22 @@ 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. It navigates in JavaScript because the library renders + // every header action as a button; once a header action can carry an + // `href` (ConductionNL/nextcloud-vue#1314) this becomes a link in the + // manifest and the handler goes. + ...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 +87,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..0641870b9 100644 --- a/src/main.js +++ b/src/main.js @@ -30,6 +30,8 @@ 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 CmdbOverview from './views/cmdb/CmdbOverview.vue' import customComponents from './customComponents.js' import appIcons from './icons.js' import bundledManifest from './manifest.json' @@ -72,6 +74,25 @@ 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, +}) +// The CMDB page's body (sharing-itsm-exchange): what stackiq records and does +// not, the service desk exchange status and the admin file import. +registerDashboardWidget('cmdb-overview', { + renderer: CmdbOverview, + defaultContent: {}, + displayName: 'CMDB', + icon: 'Sitemap', + 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/cmdb.json b/src/manifest.d/cmdb.json new file mode 100644 index 000000000..a1a7db59b --- /dev/null +++ b/src/manifest.d/cmdb.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "_note": "sharing-itsm-exchange: the CMDB page says what stackiq records (applications in use, applications and their components, connections, licences and contracts) and what it does not (hardware, network discovery, tickets), shows the service desk exchange and takes a file import. A child of Applications (ADR-097: no new top-level entry). A type:dashboard page: four stat tiles counted by OpenRegister, and the cmdb-overview widget registered in main.js.", + "menu": [ + { + "id": "Modules", + "children": [ + { + "id": "Cmdb", + "label": "CMDB", + "icon": "Sitemap", + "route": "Cmdb", + "order": 1 + } + ] + } + ], + "pages": [ + { + "id": "Cmdb", + "route": "/cmdb", + "type": "dashboard", + "title": "CMDB", + "config": { + "widgets": [ + { + "id": "cmdb-usages", + "type": "stat", + "title": "Applications in use", + "content": { + "label": "Applications in use", + "icon": "OfficeBuilding", + "route": { + "name": "Gebruik" + }, + "format": { + "style": "decimal", + "decimals": 0 + }, + "source": { + "register": "@resolve:voorzieningen_register", + "schema": "usage", + "metric": "count" + } + } + }, + { + "id": "cmdb-modules", + "type": "stat", + "title": "Applications", + "content": { + "label": "Applications", + "icon": "Package", + "route": { + "name": "Modules" + }, + "format": { + "style": "decimal", + "decimals": 0 + }, + "source": { + "register": "@resolve:voorzieningen_register", + "schema": "module", + "metric": "count" + } + } + }, + { + "id": "cmdb-connections", + "type": "stat", + "title": "Connections", + "content": { + "label": "Connections", + "icon": "LinkVariant", + "route": { + "name": "Koppelingen" + }, + "format": { + "style": "decimal", + "decimals": 0 + }, + "source": { + "register": "@resolve:voorzieningen_register", + "schema": "connection", + "metric": "count" + } + } + }, + { + "id": "cmdb-contracts", + "type": "stat", + "title": "Licences and contracts", + "content": { + "label": "Licences and contracts", + "icon": "FileSign", + "route": { + "name": "Contracten" + }, + "format": { + "style": "decimal", + "decimals": 0 + }, + "source": { + "register": "@resolve:voorzieningen_register", + "schema": "catalogContract", + "metric": "count" + } + } + }, + { + "id": "cmdb-overview", + "type": "cmdb-overview", + "title": "CMDB", + "_note": "sharing-itsm-exchange: what stackiq records and does not, the service desk exchange status (GET /api/itsm/status) and the admin file import (POST /api/itsm/import). A widget TYPE registered in main.js, like catalog-panels, because it combines prose, a status and an upload form, which no built-in widget does." + } + ], + "layout": [ + { + "id": "1", + "widgetId": "cmdb-usages", + "gridX": 0, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 2, + "showTitle": false + }, + { + "id": "2", + "widgetId": "cmdb-modules", + "gridX": 3, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 2, + "showTitle": false + }, + { + "id": "3", + "widgetId": "cmdb-connections", + "gridX": 6, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 2, + "showTitle": false + }, + { + "id": "4", + "widgetId": "cmdb-contracts", + "gridX": 9, + "gridY": 0, + "gridWidth": 3, + "gridHeight": 2, + "showTitle": false + }, + { + "id": "5", + "widgetId": "cmdb-overview", + "gridX": 0, + "gridY": 2, + "gridWidth": 12, + "gridHeight": 10, + "showTitle": false + } + ] + }, + "_note": "The CMDB entry point: four stat tiles for the lists that hold the CMDB, and the cmdb-overview widget with what stackiq records and does not, the exchange status and the file import." + } + ] +} 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..1ff71f97e --- /dev/null +++ b/src/manifest.d/usages.json @@ -0,0 +1,74 @@ +{ + "$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", "serviceDeskRecordId"], + "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", "installedVersion", "publicationDate" ] } }, + { "id": "gb-service-desk", "type": "data", "title": "Service desk", "icon": "Sitemap", "content": { "columns": 2, "include": [ "serviceDeskSystem", "serviceDeskRecordId", "serviceDeskUrl", "serviceDeskSyncedAt" ] } }, + { "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 }, + { "id": "5", "widgetId": "gb-service-desk", "gridX": 8, "gridY": 8, "gridWidth": 4, "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..13080eed0 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -16,17 +16,10 @@ "display": "cards", "optionsSource": "datasets", "configKey": "demo_dataset", + "loadAction": "load-demo-data", "title": "Load example data?", "required": false, - "body": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install." - }, - { - "id": "load-demo-data", - "type": "run-action", - "action": "load-demo-data", - "title": "Load the example data", - "required": false, - "body": "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards." + "body": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install." }, { "id": "done", @@ -200,7 +193,7 @@ "label": "Reports", "icon": "ChartBoxOutline", "route": "Reports", - "section": "footer", + "section": "settings", "order": 95 }, { @@ -329,6 +322,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 +367,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 +422,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 +432,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 +506,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 +597,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 +620,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 +703,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 +945,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..6374d9c6e --- /dev/null +++ b/src/utils/archiMateImportProgress.js @@ -0,0 +1,146 @@ +/** + * 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. A tick that comes while the + * previous request is still waiting sends nothing, so a slow server never + * collects concurrent progress requests. + * + * @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, + }) + let inFlight = false + const handle = setIntervalFn(async () => { + if (inFlight) { + return + } + inFlight = true + 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. + } finally { + inFlight = false + } + }, 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..4413f6c95 --- /dev/null +++ b/src/utils/archiMateImportProgress.spec.js @@ -0,0 +1,150 @@ +/** + * 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([]) + }) + + it('sends no new request while the previous one is still waiting', async () => { + let tick = null + let answer = null + const get = jest.fn( + () => + new Promise((resolve) => { + answer = resolve + }), + ) + startProgressPolling({ + operationId: 'archimate_import_abc12345', + http: { get }, + onProgress: () => {}, + setIntervalFn: (fn) => { + tick = fn + return 1 + }, + clearIntervalFn: () => {}, + }) + + const first = tick() + await tick() + expect(get).toHaveBeenCalledTimes(1) + + answer({ data: { progress: { percentage: 10 } } }) + await first + tick() + expect(get).toHaveBeenCalledTimes(2) + }) +}) + +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/cmdbImport.js b/src/utils/cmdbImport.js new file mode 100644 index 000000000..ab7fa7080 --- /dev/null +++ b/src/utils/cmdbImport.js @@ -0,0 +1,986 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. + +/** + * Client side of the CMDB import: request building, the checks the page can + * make before uploading, and the words for every error code and outcome. + * + * The routes, field names, report shape and error codes are fixed by + * openspec/changes/cmdb-export-import/contract.md. The server stays the + * authority: every check here is repeated there. The texts are rendered by + * CmdbImport.vue as text, so translations with placeholders use AS_TEXT. + * + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 + */ + +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' + +/** + * The limits and sheet names the import profile ships with + * (lib/Settings/cmdb-import/topdesk-profile.json: `maxFileBytes`, + * `maxRowsPerSheet` and `sheets`). + * + * These are defaults, used only where the page has no answer from the server + * yet (the help text). The server reads the profile itself, and when it + * refuses a file its error `details` (`maxBytes`, `limit`, `expected`) carry + * the values in force, which the error texts show instead. The page makes no + * size check of its own, so a changed limit needs no change here. + */ +export const PROFILE_DEFAULTS = Object.freeze({ + maxFileBytes: 10 * 1024 * 1024, + maxRowsPerSheet: 10000, + sheets: Object.freeze(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']), +}) + +/** + * The l10n options for a translation with placeholders that is rendered as + * text, through `{{ }}` or a text prop. By default translate() escapes the + * placeholder values for HTML and sanitises the result, and Vue escapes the + * text again, so a name like "Berkel & Rodenrijs" showed as + * "Berkel & Rodenrijs". Never use it for a string that goes into v-html. + */ +export const AS_TEXT = Object.freeze({ escape: false, sanitize: false }) + +/** Every row outcome the report can carry, in display order. */ +export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed'] + +/** + * The words for one row outcome. + * + * @param {string} outcome The outcome key from the report + * @return {string} The label + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011 + */ +export function outcomeLabel(outcome) { + switch (outcome) { + case 'created': + return t('stackiq', 'Created') + case 'updated': + return t('stackiq', 'Updated') + case 'unchanged': + return t('stackiq', 'Unchanged') + case 'skipped': + return t('stackiq', 'Skipped') + case 'failed': + return t('stackiq', 'Failed') + default: + return String(outcome ?? '') + } +} + +/** + * Make a fresh operation id, in the form the contract's example uses + * (`cmdb-` plus a random version 4 uuid). + * + * `crypto.randomUUID()` exists only in a secure context, and an instance + * served over plain http is not one, so the uuid is built from + * `getRandomValues()`, which is available everywhere. + * + * @return {string} The id + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013 + */ +export function makeCmdbOperationId() { + const bytes = new Uint8Array(16) + globalThis.crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + return ( + 'cmdb-' + + hex.slice(0, 8) + + '-' + + hex.slice(8, 12) + + '-' + + hex.slice(12, 16) + + '-' + + hex.slice(16, 20) + + '-' + + hex.slice(20) + ) +} + +/** + * A size in bytes as megabytes, for the texts ("10 MB", "12.5 MB"). + * + * @param {number} bytes The size + * @return {string} The size with its unit + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin-req-cmdb-001 + */ +export function formatMegabytes(bytes) { + const megabytes = Math.round((Number(bytes) / (1024 * 1024)) * 10) / 10 + return t('stackiq', '{size} MB', { size: String(megabytes) }, AS_TEXT) +} + +/** + * The check the page makes on a chosen file before it uploads it. + * + * Only the name is checked here. The size limit is the server's (it can be + * changed in the import profile), and so is the content check (ZIP + * signature, `xl/workbook.xml`). + * + * @param {File|null} file The chosen file + * @return {{error: string, details: object}|null} An error in the server's shape, or null when the file may be sent + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin-req-cmdb-001 + */ +export function checkFile(file) { + if (!file) { + return { error: 'NO_FILE_UPLOADED', details: {} } + } + if (!/\.xlsx$/i.test(file.name || '')) { + return { error: 'NOT_XLSX', details: {} } + } + return null +} + +/** + * The multipart body for `POST /api/cmdb-import`. + * + * @param {object} options The options + * @param {File} options.file The export + * @param {{uuid: string|null, name: string}} options.municipality The chosen municipality: an existing one has a uuid, a new one only a name + * @param {boolean} options.updateExisting Whether matched rows are updated + * @param {boolean} [options.publish] Whether the modules the import creates are published; true when left out + * @param {string} options.operationId The progress operation id + * @return {FormData} The body + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin-req-cmdb-004 + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-newly-created-module-shall-get-a-publicationdate-when-the-admin-publishes-and-an-existing-one-shall-keep-its-own-req-cmdb-007 + */ +export function buildImportForm({ + file, + municipality, + updateExisting, + publish = true, + operationId, +}) { + const form = new FormData() + form.append('cmdbFile', file) + if (municipality?.uuid) { + form.append('municipalityUuid', municipality.uuid) + } else if (municipality?.name) { + form.append('municipalityName', municipality.name) + } + form.append('updateExisting', updateExisting ? 'true' : 'false') + form.append('publish', publish ? 'true' : 'false') + form.append('missingRecords', 'keep') + form.append('operationId', operationId) + return form +} + +/** + * The statuses of a municipality the import never matches a typed name to, as on the server. + */ +export const UNMATCHED_MUNICIPALITY_STATUSES = ['merged', 'Inactive'] + +/** + * The chooser's options from the organisations OpenRegister returned. + * + * Only live organisations of type Municipality are offered: an organisation + * without a type, a merged one or an inactive one is left out, because the + * server refuses it or never matches a name to it. Municipalities that share + * a name get the start of their uuid in the label, so the admin can tell them + * apart; `name` keeps the plain name. + * + * @param {Array} objects The organisations + * @return {Array<{id: string, label: string, name: string, isNew: boolean}>} The options, sorted by label + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin-req-cmdb-004 + */ +export function municipalityOptions(objects) { + const options = (Array.isArray(objects) ? objects : []) + .filter( + (org) => + org?.type === 'Municipality' + && !UNMATCHED_MUNICIPALITY_STATUSES.includes(org?.status), + ) + .map((org) => { + const name = String(org.name || org['@self']?.name || '') + return { + id: String(org.id || org['@self']?.id || ''), + label: name, + name, + isNew: false, + } + }) + .filter((option) => option.id !== '' && option.name !== '') + const counts = {} + for (const option of options) { + const key = normaliseMunicipalityName(option.name) + counts[key] = (counts[key] || 0) + 1 + } + return options + .map((option) => + counts[normaliseMunicipalityName(option.name)] > 1 + ? { ...option, label: `${option.name} (${option.id.slice(0, 8)})` } + : option, + ) + .sort((a, b) => a.label.localeCompare(b.label)) +} + +/** + * The chooser's option for a name the admin typed. + * + * A typed name is always sent as `municipalityName`, also when it equals the + * name of a listed municipality: the server then reuses the one live + * municipality with that name, creates one when there is none, and refuses + * the name with MUNICIPALITY_AMBIGUOUS when several share it. Only an option + * picked from the list sends its uuid. + * + * @param {string|object} typed What the admin typed + * @return {{id: null, label: string, name: string, isNew: boolean}} The option + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin-req-cmdb-004 + */ +export function typedMunicipalityOption(typed) { + const name = String( + typeof typed === 'object' && typed !== null ? typed.label : typed, + ) + .trim() + .replace(/\s+/g, ' ') + return { id: null, label: name, name, isNew: true } +} + +/** + * A municipality name as the server compares it: trimmed, single spaces, lower case. + * + * @param {string} name The name + * @return {string} The normalised name + */ +function normaliseMunicipalityName(name) { + return String(name).trim().replace(/\s+/g, ' ').toLowerCase() +} + +/** + * The URL of the import endpoint. + * + * @return {string} The URL + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin-req-cmdb-001 + */ +export function importUrl() { + return generateUrl('/apps/stackiq/api/cmdb-import') +} + +/** + * Ask the server to stop a running import between two rows. + * + * @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/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013 + */ +export async function cancelCmdbImport({ operationId, http }) { + const response = await http.post( + generateUrl('/apps/stackiq/api/cmdb-import/{operationId}/cancel', { + operationId, + }), + ) + return response.data +} + +/** + * The link to a module's detail page in the app. + * + * The settings page is outside the app's router, so this is a plain URL. + * + * @param {string} uuid The module uuid + * @return {string} The URL + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011 + */ +export function moduleUrl(uuid) { + return generateUrl('/apps/stackiq/modules/{id}', { id: uuid }) +} + +/** + * HTTP statuses that mean the request was cut off before the import + * answered: no answer at all, or a proxy or gateway that gave up waiting. + * The import itself may still be running on the server. + */ +const INTERRUPTED_STATUSES = new Set([0, 502, 503, 504]) + +/** + * Turn a failed request into the server's error shape. + * + * Errors raised by Nextcloud itself (not signed in, not an admin, CSRF) come + * without a CMDB error code, so they get one here from the HTTP status. + * `interrupted` is true when the request was cut off without an answer from + * the import (see INTERRUPTED_STATUSES); an answer with a CMDB error code, + * such as a 503 MAPPING_UNAVAILABLE, is the import's own and never counts. + * + * @param {object} error The axios error + * @return {{error: string, message: string, details: object, status: number, interrupted: boolean}} The error + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin-req-cmdb-001 + */ +export function normaliseError(error) { + const status = error?.response?.status ?? 0 + const body = error?.response?.data + const fromBody = + body && typeof body === 'object' && typeof body.error === 'string' + ? body.error + : '' + let code = fromBody + if (code === '') { + if (status === 401) { + code = 'NOT_SIGNED_IN' + } else if (status === 403) { + code = 'NOT_ADMIN' + } else if (status === 412) { + code = 'CSRF_FAILED' + } else if (status === 413) { + code = 'FILE_TOO_LARGE' + } else if (status === 0) { + code = 'NETWORK_ERROR' + } else { + code = 'IMPORT_FAILED' + } + } + return { + error: code, + message: + body && typeof body === 'object' && typeof body.message === 'string' + ? body.message + : '', + details: + body + && typeof body === 'object' + && body.details + && typeof body.details === 'object' + ? body.details + : {}, + status, + interrupted: fromBody === '' && INTERRUPTED_STATUSES.has(status), + } +} + +/** + * Find out what became of an import whose request was cut off. + * + * The server keeps the operation's progress, and the finished report in its + * `statistics.report`, for an hour. This reads the operation until it is no + * longer running, waiting `intervalMs` between reads. + * + * @param {object} options The options + * @param {string} options.operationId The operation of the import + * @param {object} options.http An axios-like client with get + * @param {(progress: object) => void} [options.onProgress] Called with each snapshot + * @param {() => boolean} [options.shouldStop] Returns true when the page no longer waits + * @param {(ms: number) => Promise} [options.wait] Waits the given milliseconds, for tests + * @param {number} [options.intervalMs] Time between two reads + * @param {number} [options.timeoutMs] How long to wait for a running import + * @return {Promise<{state: string, report?: object, status?: number}>} `finished` with the report, `failed`, `unknown` (not found, or still running when the page stopped waiting) or `unreachable` (the progress could not be read either) + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013 + */ +export async function followInterruptedImport({ + operationId, + http, + onProgress = () => {}, + shouldStop = () => false, + wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + intervalMs = 2000, + timeoutMs = 30 * 60 * 1000, +}) { + const url = generateUrl('/apps/stackiq/api/progress/{operationId}', { + operationId, + }) + let waited = 0 + for (;;) { + let progress = null + try { + const response = await http.get(url) + progress = response?.data?.progress ?? null + } catch (error) { + if (!error?.response) { + return { state: 'unreachable' } + } + } + if (!progress || typeof progress !== 'object') { + return { state: 'unknown' } + } + onProgress(progress) + const report = progress.statistics?.report + if ( + (progress.status === 'completed' || progress.status === 'cancelled') + && report + && typeof report === 'object' + ) { + return { state: 'finished', report } + } + if (progress.status === 'failed') { + return { state: 'failed' } + } + if (progress.status !== 'running' || waited >= timeoutMs || shouldStop()) { + return { state: 'unknown' } + } + await wait(intervalMs) + waited += intervalMs + } +} + +/** + * The error the page shows for an interrupted import, once + * followInterruptedImport() has an outcome; null when the report was found. + * + * @param {{state: string}} outcome What followInterruptedImport() found + * @param {{error: string, status: number}} error The normalised error of the cut-off request + * @return {object|null} The error in the server's shape, or null + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013 + */ +export function interruptedImportError(outcome, error) { + const base = { + message: '', + details: {}, + status: error?.status ?? 0, + interrupted: true, + } + switch (outcome?.state) { + case 'finished': + return null + case 'failed': + return { ...base, error: 'IMPORT_FAILED' } + case 'unreachable': + if ((error?.status ?? 0) === 0) { + return { ...base, error: 'NETWORK_ERROR' } + } + return { ...base, error: 'IMPORT_INTERRUPTED' } + default: + return { ...base, error: 'IMPORT_INTERRUPTED' } + } +} + +/** Every error code the page has its own words for. */ +const KNOWN_ERRORS = new Set([ + 'NO_FILE_UPLOADED', + 'NOT_XLSX', + 'FILE_TOO_LARGE', + 'MISSING_RECORDS_UNSUPPORTED', + 'FIELD_INVALID', + 'UPLOAD_FAILED', + 'MUNICIPALITY_REQUIRED', + 'MUNICIPALITY_INVALID', + 'NO_SOURCE_SHEET', + 'MISSING_COLUMN', + 'TOO_MANY_ROWS', + 'MAPPING_UNAVAILABLE', + 'READER_UNAVAILABLE', + 'NOT_CONFIGURED', + 'WORKBOOK_TOO_LARGE', + 'SCHEMA_OUTDATED', + 'IMPORT_IN_PROGRESS', + 'MUNICIPALITY_AMBIGUOUS', + 'OPERATION_NOT_FOUND', + 'IMPORT_INTERRUPTED', + 'NOT_SIGNED_IN', + 'NOT_ADMIN', + 'CSRF_FAILED', + 'NETWORK_ERROR', +]) + +/** + * Whether the page has its own words for an error code. For any other code + * (including `IMPORT_FAILED`) the page also shows the server's message. + * + * @param {string} code The error code + * @return {boolean} True for a code with its own text + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin-req-cmdb-001 + */ +export function isKnownError(code) { + return KNOWN_ERRORS.has(code) +} + +/** + * What the page says for WORKBOOK_TOO_LARGE: which limit the server applied, + * to which sheet or part, and what to do about it. + * + * @param {object} details The error details: `maxPartBytes`, `part`, `size` and, for a sheet, `sheet`; `maxSharedStrings`; `maxReferencedStringBytes`; or `maxUncompressedBytes` + * @return {{title: string, hint: string}} The words + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links-req-cmdb-002 + */ +function workbookTooLarge(details) { + const compressed = t( + 'stackiq', + 'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.', + ) + const split = + compressed + + ' ' + + t( + 'stackiq', + 'Split the rows over two copies of the export and import them one after the other.', + ) + if (Number(details.maxPartBytes) > 0) { + const sizes = { + size: formatMegabytes(details.size), + limit: formatMegabytes(details.maxPartBytes), + } + return { + title: details.sheet + ? t( + 'stackiq', + 'The sheet "{sheet}" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.', + { sheet: String(details.sheet), ...sizes }, + AS_TEXT, + ) + : t( + 'stackiq', + 'The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.', + { part: String(details.part ?? ''), ...sizes }, + AS_TEXT, + ), + hint: split, + } + } + const unread = + compressed + + ' ' + + t( + 'stackiq', + 'Delete the sheets the import does not read from a copy of the export (it reads only "{first}" and "{second}"), and import that copy.', + { + first: PROFILE_DEFAULTS.sheets[0], + second: PROFILE_DEFAULTS.sheets[1], + }, + AS_TEXT, + ) + if (Number(details.maxSharedStrings) > 0) { + return { + title: t( + 'stackiq', + 'The workbook holds more than {count} different texts, the most the import reads.', + { count: String(details.maxSharedStrings) }, + AS_TEXT, + ), + hint: unread, + } + } + if (Number(details.maxReferencedStringBytes) > 0) { + return { + title: t( + 'stackiq', + 'Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.', + { size: formatMegabytes(details.maxReferencedStringBytes) }, + AS_TEXT, + ), + hint: unread, + } + } + if (Number(details.maxUncompressedBytes) > 0) { + return { + title: t( + 'stackiq', + 'Unpacked, the workbook is larger than {size}, the most the import reads.', + { size: formatMegabytes(details.maxUncompressedBytes) }, + AS_TEXT, + ), + hint: unread, + } + } + return { + title: t('stackiq', 'The workbook is too large to read once unpacked.'), + hint: unread, + } +} + +/** + * What the page says for an error: a title and, where the code has one, a + * hint on what to do. The text is the page's own, so it is translated even + * when the server's message is not. + * + * @param {{error: string, message?: string, details?: object}} error The error in the server's shape + * @return {{title: string, hint: string}} The words + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422-req-cmdb-003 + */ +export function errorText(error) { + const details = error?.details || {} + switch (error?.error) { + case 'NO_FILE_UPLOADED': + return { + title: t('stackiq', 'No file was uploaded.'), + hint: t('stackiq', 'Choose the TOPdesk export and try again.'), + } + case 'NOT_XLSX': + return { + title: t('stackiq', 'This file is not an Excel workbook (.xlsx).'), + hint: t( + 'stackiq', + 'Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.', + ), + } + case 'FILE_TOO_LARGE': + return { + title: + Number(details.maxBytes) > 0 + ? t( + 'stackiq', + 'The file is larger than {size}, the most the import accepts.', + { size: formatMegabytes(details.maxBytes) }, + AS_TEXT, + ) + : t( + 'stackiq', + 'The file is larger than the server accepts.', + ), + hint: t( + 'stackiq', + 'Remove sheets the import does not read, or split the export, and try again.', + ), + } + case 'MISSING_RECORDS_UNSUPPORTED': + return { + title: t( + 'stackiq', + 'Records missing from the export can only be kept.', + ), + hint: '', + } + case 'FIELD_INVALID': { + const accepted = Array.isArray(details.accepted) + ? details.accepted.map((value) => String(value)) + : [] + return { + title: t( + 'stackiq', + 'The request field "{field}" has a value the import does not accept.', + { field: String(details.field ?? '') }, + AS_TEXT, + ), + hint: + accepted.length > 0 + ? t( + 'stackiq', + 'Accepted values: {accepted}. Reload the page and try again.', + { accepted: accepted.join(', ') }, + AS_TEXT, + ) + : t('stackiq', 'Reload the page and try again.'), + } + } + case 'UPLOAD_FAILED': + return { + title: t('stackiq', 'The server could not store the uploaded file.'), + hint: t( + 'stackiq', + 'Try again. If it keeps failing, the Nextcloud log has the details; check the free space and the upload settings of the server.', + ), + } + case 'MUNICIPALITY_REQUIRED': + return { + title: t('stackiq', 'Choose a municipality first.'), + hint: t( + 'stackiq', + 'Pick an existing municipality or type the name of a new one.', + ), + } + case 'MUNICIPALITY_INVALID': + return { + title: t( + 'stackiq', + 'The chosen organisation is not a municipality.', + ), + hint: t( + 'stackiq', + 'Pick an organisation of type Municipality, or type the name of a new one.', + ), + } + case 'NO_SOURCE_SHEET': { + const expected = + Array.isArray(details.expected) && details.expected.length > 0 + ? details.expected + : PROFILE_DEFAULTS.sheets + return { + title: t( + 'stackiq', + 'The workbook has none of the sheets the import reads.', + ), + hint: t( + 'stackiq', + 'Expected a sheet named "{first}" or "{second}". Sheet names must match exactly.', + { + first: String(expected[0] ?? PROFILE_DEFAULTS.sheets[0]), + second: String(expected[1] ?? PROFILE_DEFAULTS.sheets[1]), + }, + AS_TEXT, + ), + } + } + case 'MISSING_COLUMN': + return { + title: t( + 'stackiq', + 'The sheet "{sheet}" has no column "{column}".', + { + sheet: String(details.sheet ?? ''), + column: String(details.column ?? ''), + }, + AS_TEXT, + ), + hint: t( + 'stackiq', + 'The columns "APPID" and "Applicatie Naam" are required on every source sheet. Add the column to the export and try again. Nothing was imported.', + ), + } + case 'TOO_MANY_ROWS': + return { + title: details.sheet + ? t( + 'stackiq', + 'The sheet "{sheet}" has more rows than the import can process.', + { sheet: String(details.sheet) }, + AS_TEXT, + ) + : t( + 'stackiq', + 'A sheet has more rows than the import can process.', + ), + hint: + Number(details.limit) > 0 + ? t( + 'stackiq', + 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.', + { limit: Number(details.limit).toLocaleString() }, + AS_TEXT, + ) + : t( + 'stackiq', + 'Split the export and import the parts one after the other.', + ), + } + case 'MAPPING_UNAVAILABLE': + return { + title: t('stackiq', 'The import mapping cannot run.'), + hint: t( + 'stackiq', + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.", + ), + } + case 'READER_UNAVAILABLE': + return { + title: t('stackiq', 'The Excel reader is not available.'), + hint: t( + 'stackiq', + 'The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.', + ), + } + case 'NOT_CONFIGURED': + return { + title: t('stackiq', 'Stackiq is not configured for the import.'), + hint: t( + 'stackiq', + 'The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.', + ), + } + case 'WORKBOOK_TOO_LARGE': + return workbookTooLarge(details) + case 'SCHEMA_OUTDATED': + return { + title: details.schema + ? t( + 'stackiq', + 'The "{schema}" schema of the stackiq register is out of date.', + { schema: String(details.schema) }, + AS_TEXT, + ) + : t('stackiq', 'The stackiq register is out of date.'), + hint: t( + 'stackiq', + 'It lacks properties the import matches on. Press Force Update at the top of this page to import the register configuration again, then try again. Nothing was imported.', + ), + } + case 'IMPORT_IN_PROGRESS': + return { + title: t('stackiq', 'Another CMDB import is running.'), + hint: t( + 'stackiq', + 'Only one import runs at a time. Wait until it has finished and try again. Nothing was imported.', + ), + } + case 'MUNICIPALITY_AMBIGUOUS': + return { + title: + Array.isArray(details.matches) && details.matches.length > 1 + ? t( + 'stackiq', + '{count} municipalities have this name.', + { count: String(details.matches.length) }, + AS_TEXT, + ) + : t('stackiq', 'Several municipalities have this name.'), + hint: t( + 'stackiq', + 'Choose the municipality from the list instead of typing its name. Nothing was imported.', + ), + } + case 'OPERATION_NOT_FOUND': + return { + title: t('stackiq', 'This import is no longer running.'), + hint: '', + } + case 'NOT_SIGNED_IN': + return { + title: t('stackiq', 'You are not signed in.'), + hint: t('stackiq', 'Sign in again and retry the import.'), + } + case 'NOT_ADMIN': + return { + title: t( + 'stackiq', + 'Only Nextcloud administrators can import a CMDB export.', + ), + hint: '', + } + case 'CSRF_FAILED': + return { + title: t('stackiq', 'Your session has expired.'), + hint: t('stackiq', 'Reload the page and try again.'), + } + case 'NETWORK_ERROR': + return { + title: t('stackiq', 'The server could not be reached.'), + hint: t('stackiq', 'Check the connection and try again.'), + } + case 'IMPORT_INTERRUPTED': + return { + title: t('stackiq', 'The page got no answer from the import.'), + hint: t( + 'stackiq', + 'The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.', + ), + } + case 'IMPORT_FAILED': + default: + return { + title: t('stackiq', 'The import failed unexpectedly.'), + hint: t( + 'stackiq', + 'Nothing more is known on this page; the Nextcloud log has the details.', + ), + } + } +} + +/** + * What the page says when its request to cancel the import was refused. + * + * @param {{error: string, details?: object}} error The normalised error of the cancel request + * @return {string} The sentence + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013 + */ +export function cancelFailureText(error) { + if (error?.error === 'OPERATION_NOT_FOUND') { + return t( + 'stackiq', + 'The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.', + ) + } + return t( + 'stackiq', + 'The import could not be cancelled: {reason}', + { + reason: errorText(error).title, + }, + AS_TEXT, + ) +} + +/** + * What the page shows for a progress snapshot of the running import. + * + * The percentage comes from the processed and total row counts when the + * server has set them, because the tracker's own percentage is weighted by + * the phases of the ArchiMate import. + * + * @param {object|null} progress The snapshot from `GET /api/progress/{operationId}` + * @return {{percentage: number, detail: string}|null} The view, or null before any progress + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013 + */ +export function cmdbProgressView(progress) { + if (!progress) { + return null + } + const processed = Number(progress.processed_items) || 0 + const total = Number(progress.total_items) || 0 + const percentage = + total > 0 + ? Math.min(100, Math.round((processed / total) * 100)) + : Number(progress.percentage) || 0 + return { + percentage, + detail: + total > 0 + ? t( + 'stackiq', + '{processed} of {total} rows processed', + { + processed, + total, + }, + AS_TEXT, + ) + : '', + } +} + +/** + * How many report rows the table shows at first, and how many more each + * "Show more" adds. A report can hold up to two full sheets of rows. + */ +export const REPORT_PAGE_SIZE = 100 + +/** + * The report rows in the order of one column: numbers by value, text in the + * locale's order. Rows that tie keep their order in the report. + * + * The table emits the sort the admin asked for and leaves the sorting to the + * page, so the whole row set is sorted before it is cut into pages. + * + * @param {Array} rows The table rows + * @param {string|null} key The column key + * @param {string|null} order 'asc', 'desc', or null for the report's order + * @return {Array} The rows, sorted (a new array when sorted) + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 + */ +export function sortReportRows(rows, key, order) { + if (!key || (order !== 'asc' && order !== 'desc')) { + return rows + } + const direction = order === 'desc' ? -1 : 1 + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + }) + return rows + .map((row, index) => ({ row, index })) + .sort((a, b) => { + const compared = collator.compare( + String(a.row[key] ?? ''), + String(b.row[key] ?? ''), + ) + return compared !== 0 ? compared * direction : a.index - b.index + }) + .map((entry) => entry.row) +} + +/** + * The rows of the report as the table shows them. + * + * @param {Array} rows The report's `rows` + * @return {Array} The table rows + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011 + */ +export function reportRows(rows) { + if (!Array.isArray(rows)) { + return [] + } + return rows.map((row, index) => ({ + key: `${row.sheet ?? ''}:${row.row ?? index}:${index}`, + sheet: String(row.sheet ?? ''), + row: row.row ?? '', + appId: String(row.appId ?? ''), + name: String(row.name ?? ''), + outcome: String(row.outcome ?? ''), + notes: [ + ...(Array.isArray(row.reasons) ? row.reasons : []), + ...(Array.isArray(row.warnings) ? row.warnings : []), + ] + .map((note) => String(note)) + .join('; '), + moduleUuid: row.moduleUuid ? String(row.moduleUuid) : '', + })) +} diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js new file mode 100644 index 000000000..8f566d6cb --- /dev/null +++ b/src/utils/cmdbImport.spec.js @@ -0,0 +1,709 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. + +/** + * Unit tests for the client side of the CMDB import: request building, the + * error codes the server can send and the words for each, and following an + * import whose request was cut off. + * + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 + */ + +import { + buildImportForm, + cancelCmdbImport, + cancelFailureText, + checkFile, + cmdbProgressView, + errorText, + followInterruptedImport, + formatMegabytes, + importUrl, + interruptedImportError, + isKnownError, + makeCmdbOperationId, + municipalityOptions, + normaliseError, + outcomeLabel, + OUTCOMES, + PROFILE_DEFAULTS, + reportRows, + sortReportRows, + typedMunicipalityOption, +} from './cmdbImport.js' + +/** The operation ids CmdbImportController accepts. */ +const SERVER_OPERATION_ID = /^cmdb-[A-Za-z0-9-]{8,64}$/ + +/** + * Every code the import endpoint, the cancel endpoint and Nextcloud can + * answer with, plus the ones the page derives itself. + */ +const SERVER_CODES = [ + 'NO_FILE_UPLOADED', + 'NOT_XLSX', + 'FILE_TOO_LARGE', + 'MISSING_RECORDS_UNSUPPORTED', + 'FIELD_INVALID', + 'UPLOAD_FAILED', + 'MUNICIPALITY_REQUIRED', + 'MUNICIPALITY_INVALID', + 'NO_SOURCE_SHEET', + 'MISSING_COLUMN', + 'TOO_MANY_ROWS', + 'MAPPING_UNAVAILABLE', + 'READER_UNAVAILABLE', + 'NOT_CONFIGURED', + 'WORKBOOK_TOO_LARGE', + 'SCHEMA_OUTDATED', + 'IMPORT_IN_PROGRESS', + 'MUNICIPALITY_AMBIGUOUS', + 'OPERATION_NOT_FOUND', + 'IMPORT_INTERRUPTED', + 'NOT_SIGNED_IN', + 'NOT_ADMIN', + 'CSRF_FAILED', + 'NETWORK_ERROR', +] + +const GENERIC_TITLE = errorText({ error: 'SOMETHING_ELSE' }).title + +/** + * An axios error with a response. + * + * @param {number} status The HTTP status + * @param {*} data The response body + * @return {object} The error + */ +function httpError(status, data = '') { + return { response: { status, data } } +} + +describe('makeCmdbOperationId', () => { + it('makes an id the server accepts, different each time', () => { + const a = makeCmdbOperationId() + const b = makeCmdbOperationId() + expect(a).toMatch(SERVER_OPERATION_ID) + expect(b).toMatch(SERVER_OPERATION_ID) + expect(a).not.toBe(b) + }) +}) + +describe('checkFile', () => { + it('asks for a file when none is chosen', () => { + expect(checkFile(null)).toEqual({ error: 'NO_FILE_UPLOADED', details: {} }) + }) + + it('refuses a file that is not named .xlsx', () => { + expect(checkFile({ name: 'export.csv', size: 10 }).error).toBe('NOT_XLSX') + expect(checkFile({ name: 'export.xlsm', size: 10 }).error).toBe('NOT_XLSX') + }) + + it('leaves the size limit to the server, which may allow more than the default', () => { + expect( + checkFile({ + name: 'Export.XLSX', + size: PROFILE_DEFAULTS.maxFileBytes * 3, + }), + ).toBeNull() + }) +}) + +describe('the municipality chooser', () => { + const organisations = [ + { + id: 'aaaaaaaa-1111', + name: 'Gemeente Bergen', + type: 'Municipality', + status: 'Active', + }, + { + id: 'bbbbbbbb-2222', + name: 'gemeente bergen', + type: 'Municipality', + status: 'Active', + }, + { + id: 'cccccccc-3333', + name: 'Gemeente Oud', + type: 'Municipality', + status: 'merged', + }, + { + id: 'dddddddd-4444', + name: 'Gemeente Slaap', + type: 'Municipality', + status: 'Inactive', + }, + { id: 'eeeeeeee-5555', name: 'Zonder type' }, + { + id: 'ffffffff-6666', + name: 'Fabfrikant', + type: 'Supplier', + status: 'Active', + }, + { + id: '99999999-7777', + name: 'Gemeente Voorbeeldstad', + type: 'Municipality', + }, + ] + + it('offers only live municipalities, and tells same-named ones apart', () => { + const options = municipalityOptions(organisations) + + expect(options.map((option) => option.id).sort()).toEqual([ + '99999999-7777', + 'aaaaaaaa-1111', + 'bbbbbbbb-2222', + ]) + const byId = Object.fromEntries(options.map((option) => [option.id, option])) + expect(byId['aaaaaaaa-1111'].label).toBe('Gemeente Bergen (aaaaaaaa)') + expect(byId['bbbbbbbb-2222'].label).toBe('gemeente bergen (bbbbbbbb)') + expect(byId['99999999-7777'].label).toBe('Gemeente Voorbeeldstad') + expect(byId['aaaaaaaa-1111'].name).toBe('Gemeente Bergen') + }) + + it('sends a typed name as municipalityName, also when it equals a listed name', () => { + const typed = typedMunicipalityOption(' Gemeente Bergen ') + expect(typed).toEqual({ + id: null, + label: 'Gemeente Bergen', + name: 'Gemeente Bergen', + isNew: true, + }) + + const form = buildImportForm({ + file: new File(['x'], 'export.xlsx'), + municipality: { uuid: typed.id, name: typed.name }, + updateExisting: true, + operationId: 'cmdb-abcdefgh', + }) + expect(form.get('municipalityName')).toBe('Gemeente Bergen') + expect(form.has('municipalityUuid')).toBe(false) + }) +}) + +describe('buildImportForm', () => { + const file = new File(['x'], 'export.xlsx') + + it('sends an existing municipality by uuid', () => { + const form = buildImportForm({ + file, + municipality: { uuid: 'uuid-1', name: 'Tilburg' }, + updateExisting: true, + operationId: 'cmdb-abcdefgh', + }) + expect(form.get('cmdbFile')).toBeInstanceOf(File) + expect(form.get('municipalityUuid')).toBe('uuid-1') + expect(form.has('municipalityName')).toBe(false) + expect(form.get('updateExisting')).toBe('true') + expect(form.get('missingRecords')).toBe('keep') + expect(form.get('operationId')).toBe('cmdb-abcdefgh') + }) + + it('sends a typed new municipality by name, and updateExisting as an explicit false', () => { + const form = buildImportForm({ + file, + municipality: { uuid: null, name: 'Berkel & Rodenrijs' }, + updateExisting: false, + operationId: 'cmdb-abcdefgh', + }) + expect(form.has('municipalityUuid')).toBe(false) + expect(form.get('municipalityName')).toBe('Berkel & Rodenrijs') + expect(form.get('updateExisting')).toBe('false') + }) + + it('publishes what the import creates unless told not to', () => { + const options = { + file, + municipality: { uuid: 'uuid-1', name: 'Tilburg' }, + updateExisting: true, + operationId: 'cmdb-abcdefgh', + } + expect(buildImportForm(options).get('publish')).toBe('true') + expect(buildImportForm({ ...options, publish: true }).get('publish')).toBe( + 'true', + ) + expect(buildImportForm({ ...options, publish: false }).get('publish')).toBe( + 'false', + ) + }) +}) + +describe('the endpoints', () => { + it('posts the import to the CMDB import route', () => { + expect(importUrl()).toContain('/apps/stackiq/api/cmdb-import') + }) + + it('posts a cancel to the route of the operation', async () => { + const post = jest.fn().mockResolvedValue({ data: { cancelled: true } }) + await expect( + cancelCmdbImport({ operationId: 'cmdb-abcdefgh', http: { post } }), + ).resolves.toEqual({ cancelled: true }) + expect(post.mock.calls[0][0]).toContain( + '/apps/stackiq/api/cmdb-import/cmdb-abcdefgh/cancel', + ) + }) +}) + +describe('normaliseError', () => { + it('keeps the code, message and details the server sent', () => { + const error = normaliseError( + httpError(422, { + error: 'MISSING_COLUMN', + message: 'Sheet "X" has no column "APPID".', + details: { sheet: 'X', column: 'APPID' }, + }), + ) + expect(error).toEqual({ + error: 'MISSING_COLUMN', + message: 'Sheet "X" has no column "APPID".', + details: { sheet: 'X', column: 'APPID' }, + status: 422, + interrupted: false, + }) + }) + + it.each([ + [401, 'NOT_SIGNED_IN'], + [403, 'NOT_ADMIN'], + [412, 'CSRF_FAILED'], + [413, 'FILE_TOO_LARGE'], + [500, 'IMPORT_FAILED'], + ])('gives a %i without a CMDB code the code %s', (status, code) => { + const error = normaliseError(httpError(status, 'Error')) + expect(error.error).toBe(code) + expect(error.details).toEqual({}) + expect(error.interrupted).toBe(false) + }) + + it('marks a request that got no answer at all as interrupted', () => { + const error = normaliseError({ message: 'Network Error' }) + expect(error.error).toBe('NETWORK_ERROR') + expect(error.status).toBe(0) + expect(error.interrupted).toBe(true) + }) + + it.each([502, 503, 504])( + 'marks a %i from a gateway, without a CMDB code, as interrupted', + (status) => { + expect( + normaliseError(httpError(status, 'Gateway')) + .interrupted, + ).toBe(true) + }, + ) + + it("never counts the import's own 503 answer as interrupted", () => { + const error = normaliseError( + httpError(503, { error: 'MAPPING_UNAVAILABLE', message: '' }), + ) + expect(error.error).toBe('MAPPING_UNAVAILABLE') + expect(error.interrupted).toBe(false) + }) +}) + +describe('errorText', () => { + it.each(SERVER_CODES)('has its own words for %s', (code) => { + expect(isKnownError(code)).toBe(true) + const words = errorText({ error: code, details: {} }) + expect(words.title).not.toBe('') + expect(words.title).not.toBe(GENERIC_TITLE) + }) + + it('falls back to the generic text, and shows the server message, for an unknown code', () => { + expect(isKnownError('IMPORT_FAILED')).toBe(false) + expect(isKnownError('SOMETHING_NEW')).toBe(false) + expect(errorText({ error: 'IMPORT_FAILED' }).title).toBe(GENERIC_TITLE) + }) + + it('names the field and the values it accepts', () => { + const words = errorText({ + error: 'FIELD_INVALID', + details: { field: 'updateExisting', accepted: ['true', 'false'] }, + }) + expect(words.title).toContain('"updateExisting"') + expect(words.hint).toContain('Accepted values: true, false.') + expect( + errorText({ + error: 'FIELD_INVALID', + details: { field: 'municipalityUuid' }, + }).hint, + ).toBe('Reload the page and try again.') + }) + + it('names the upload limit the server applied', () => { + expect( + errorText({ + error: 'FILE_TOO_LARGE', + details: { maxBytes: 20 * 1024 * 1024 }, + }).title, + ).toContain('20 MB') + expect(errorText({ error: 'FILE_TOO_LARGE', details: {} }).title).toBe( + 'The file is larger than the server accepts.', + ) + }) + + it('names the row limit the server applied', () => { + const words = errorText({ + error: 'TOO_MANY_ROWS', + details: { sheet: 'Beheerde Applicaties CMDB', limit: 2500 }, + }) + expect(words.title).toContain('Beheerde Applicaties CMDB') + expect(words.hint).toMatch(/at most 2[,.]?500 rows/) + expect(errorText({ error: 'TOO_MANY_ROWS', details: {} }).hint).not.toMatch( + /\d/, + ) + }) + + it('names the sheets the server expected, and the defaults without them', () => { + expect( + errorText({ + error: 'NO_SOURCE_SHEET', + details: { expected: ['Blad A', 'Blad B'] }, + }).hint, + ).toContain('"Blad A" or "Blad B"') + expect(errorText({ error: 'NO_SOURCE_SHEET', details: {} }).hint).toContain( + PROFILE_DEFAULTS.sheets[1], + ) + }) + + it('names the unpacked size limit the server applied', () => { + expect( + errorText({ + error: 'WORKBOOK_TOO_LARGE', + details: { maxUncompressedBytes: 100 * 1024 * 1024 }, + }).title, + ).toContain('100 MB') + expect(errorText({ error: 'WORKBOOK_TOO_LARGE', details: {} }).title).toBe( + 'The workbook is too large to read once unpacked.', + ) + }) + + it('names the sheet, its unpacked size and the limit, and says what to do', () => { + const words = errorText({ + error: 'WORKBOOK_TOO_LARGE', + details: { + maxPartBytes: 10 * 1024 * 1024, + part: 'xl/worksheets/sheet4.xml', + size: 14.3 * 1024 * 1024, + sheet: 'Beheerde Applicaties CMDB', + }, + }) + expect(words.title).toBe( + 'The sheet "Beheerde Applicaties CMDB" is too large to import: unpacked it is 14.3 MB, and the import reads at most 10 MB of one sheet.', + ) + expect(words.hint).toBe( + 'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported. Split the rows over two copies of the export and import them one after the other.', + ) + }) + + it('tells to delete the unread sheets when the workbook or its texts are too large in all', () => { + for (const details of [ + { maxUncompressedBytes: 50 * 1024 * 1024 }, + { maxSharedStrings: 200000 }, + { maxReferencedStringBytes: 64 * 1024 * 1024 }, + ]) { + expect( + errorText({ error: 'WORKBOOK_TOO_LARGE', details }).hint, + ).toContain( + '(it reads only "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB")', + ) + } + }) + + it('names the part limit and the part, the shared-strings limit or the referenced-text limit the server applied', () => { + expect( + errorText({ + error: 'WORKBOOK_TOO_LARGE', + details: { + maxPartBytes: 10 * 1024 * 1024, + part: 'xl/sharedStrings.xml', + size: 12 * 1024 * 1024, + }, + }).title, + ).toBe( + 'The part xl/sharedStrings.xml of the workbook is too large to import: unpacked it is 12 MB, and the import reads at most 10 MB of one part.', + ) + expect( + errorText({ + error: 'WORKBOOK_TOO_LARGE', + details: { maxSharedStrings: 200000 }, + }).title, + ).toBe( + 'The workbook holds more than 200000 different texts, the most the import reads.', + ) + expect( + errorText({ + error: 'WORKBOOK_TOO_LARGE', + details: { maxReferencedStringBytes: 64 * 1024 * 1024 }, + }).title, + ).toBe( + 'Together, the cells of the workbook reference more than 64 MB of shared text, the most the import reads.', + ) + }) + + it('names the outdated schema and points to Force Update', () => { + const words = errorText({ + error: 'SCHEMA_OUTDATED', + details: { schema: 'module', missing: ['externalKey'] }, + }) + expect(words.title).toBe( + 'The "module" schema of the stackiq register is out of date.', + ) + expect(words.hint).toContain('Force Update') + expect(errorText({ error: 'SCHEMA_OUTDATED', details: {} }).title).toBe( + 'The stackiq register is out of date.', + ) + }) + + it('asks to wait for the import that is running', () => { + expect( + errorText({ error: 'IMPORT_IN_PROGRESS', details: {} }).hint, + ).toContain('Only one import runs at a time.') + }) + + it('counts the municipalities with the typed name and asks to pick one', () => { + const words = errorText({ + error: 'MUNICIPALITY_AMBIGUOUS', + details: { matches: ['uuid-1', 'uuid-2', 'uuid-3'] }, + }) + expect(words.title).toBe('3 municipalities have this name.') + expect(words.hint).toContain('from the list') + expect( + errorText({ error: 'MUNICIPALITY_AMBIGUOUS', details: {} }).title, + ).toBe('Several municipalities have this name.') + }) + + it('puts names from the details in as they are, for Vue to escape once', () => { + const words = errorText({ + error: 'MISSING_COLUMN', + details: { sheet: 'Apps & Co ', column: 'APPID' }, + }) + expect(words.title).toBe('The sheet "Apps & Co " has no column "APPID".') + }) +}) + +describe('formatMegabytes', () => { + it('shows whole and fractional megabytes', () => { + expect(formatMegabytes(10 * 1024 * 1024)).toBe('10 MB') + expect(formatMegabytes(12.5 * 1024 * 1024)).toBe('12.5 MB') + }) +}) + +describe('cancelFailureText', () => { + it('explains a cancel the server cannot match to a running import', () => { + const error = normaliseError( + httpError(404, { error: 'OPERATION_NOT_FOUND', message: '' }), + ) + expect(cancelFailureText(error)).toContain( + 'The import cannot be cancelled now', + ) + }) + + it('names the reason for any other refusal', () => { + expect(cancelFailureText(normaliseError(httpError(403, '')))).toBe( + 'The import could not be cancelled: Only Nextcloud administrators can import a CMDB export.', + ) + }) +}) + +describe('followInterruptedImport', () => { + const report = { summary: { rowsRead: 2 }, rows: [], cancelled: false } + + it('returns the report a completed operation kept', async () => { + const get = jest.fn().mockResolvedValue({ + data: { progress: { status: 'completed', statistics: { report } } }, + }) + const outcome = await followInterruptedImport({ + operationId: 'cmdb-abcdefgh', + http: { get }, + }) + expect(outcome).toEqual({ state: 'finished', report }) + expect(get.mock.calls[0][0]).toContain( + '/apps/stackiq/api/progress/cmdb-abcdefgh', + ) + }) + + it('waits while the operation runs, then returns its report', async () => { + const get = jest + .fn() + .mockResolvedValueOnce({ + data: { progress: { status: 'running', processed_items: 1 } }, + }) + .mockResolvedValueOnce({ + data: { progress: { status: 'cancelled', statistics: { report } } }, + }) + const wait = jest.fn().mockResolvedValue() + const seen = [] + const outcome = await followInterruptedImport({ + operationId: 'cmdb-abcdefgh', + http: { get }, + wait, + onProgress: (progress) => seen.push(progress.status), + }) + expect(outcome.state).toBe('finished') + expect(seen).toEqual(['running', 'cancelled']) + expect(wait).toHaveBeenCalledWith(2000) + }) + + it('reports a failed operation as failed', async () => { + const get = jest + .fn() + .mockResolvedValue({ data: { progress: { status: 'failed' } } }) + await expect( + followInterruptedImport({ operationId: 'cmdb-abcdefgh', http: { get } }), + ).resolves.toEqual({ state: 'failed' }) + }) + + it('does not know the outcome when the operation cannot be found', async () => { + const get = jest + .fn() + .mockRejectedValue(httpError(404, { error: 'OPERATION_NOT_FOUND' })) + await expect( + followInterruptedImport({ operationId: 'cmdb-abcdefgh', http: { get } }), + ).resolves.toEqual({ state: 'unknown' }) + }) + + it('says when the progress cannot be read either', async () => { + const get = jest.fn().mockRejectedValue({ message: 'Network Error' }) + await expect( + followInterruptedImport({ operationId: 'cmdb-abcdefgh', http: { get } }), + ).resolves.toEqual({ state: 'unreachable' }) + }) + + it('stops waiting for a running import after the timeout', async () => { + const get = jest + .fn() + .mockResolvedValue({ data: { progress: { status: 'running' } } }) + const outcome = await followInterruptedImport({ + operationId: 'cmdb-abcdefgh', + http: { get }, + wait: () => Promise.resolve(), + intervalMs: 10, + timeoutMs: 30, + }) + expect(outcome).toEqual({ state: 'unknown' }) + expect(get).toHaveBeenCalledTimes(4) + }) + + it('stops waiting when the page is left', async () => { + const get = jest + .fn() + .mockResolvedValue({ data: { progress: { status: 'running' } } }) + const outcome = await followInterruptedImport({ + operationId: 'cmdb-abcdefgh', + http: { get }, + shouldStop: () => true, + }) + expect(outcome).toEqual({ state: 'unknown' }) + expect(get).toHaveBeenCalledTimes(1) + }) +}) + +describe('interruptedImportError', () => { + const gateway = { error: 'IMPORT_FAILED', status: 504 } + const offline = { error: 'NETWORK_ERROR', status: 0 } + + it('shows the report, not an error, when it was found', () => { + expect(interruptedImportError({ state: 'finished' }, gateway)).toBeNull() + }) + + it('says the import may still be running when its outcome is unknown', () => { + expect(interruptedImportError({ state: 'unknown' }, gateway).error).toBe( + 'IMPORT_INTERRUPTED', + ) + expect(interruptedImportError({ state: 'unknown' }, offline).error).toBe( + 'IMPORT_INTERRUPTED', + ) + expect(interruptedImportError({ state: 'unreachable' }, gateway).error).toBe( + 'IMPORT_INTERRUPTED', + ) + }) + + it('keeps "server not reached" when nothing reached the server', () => { + expect(interruptedImportError({ state: 'unreachable' }, offline).error).toBe( + 'NETWORK_ERROR', + ) + }) + + it('reports a failed operation as a failed import', () => { + expect(interruptedImportError({ state: 'failed' }, gateway).error).toBe( + 'IMPORT_FAILED', + ) + }) +}) + +describe('cmdbProgressView', () => { + it('computes the percentage from the row counts', () => { + expect( + cmdbProgressView({ processed_items: 1, total_items: 4, percentage: 90 }), + ).toEqual({ percentage: 25, detail: '1 of 4 rows processed' }) + }) + + it('shows nothing before any progress', () => { + expect(cmdbProgressView(null)).toBeNull() + }) +}) + +describe('the report table', () => { + it('has words for every outcome', () => { + for (const outcome of OUTCOMES) { + expect(outcomeLabel(outcome)).not.toBe(outcome) + } + }) + + it('turns report rows into table rows with their reasons and warnings', () => { + expect( + reportRows([ + { + sheet: 'Beheerde Applicaties CMDB', + row: 2, + appId: 2, + name: 'Mailen', + outcome: 'skipped', + reasons: ['exists'], + warnings: ['Column "Vendor": empty'], + moduleUuid: 'm-1', + }, + ]), + ).toEqual([ + { + key: 'Beheerde Applicaties CMDB:2:0', + sheet: 'Beheerde Applicaties CMDB', + row: 2, + appId: '2', + name: 'Mailen', + outcome: 'skipped', + notes: 'exists; Column "Vendor": empty', + moduleUuid: 'm-1', + }, + ]) + expect(reportRows(null)).toEqual([]) + }) + + it('sorts numbers by value and keeps the report order for ties', () => { + const rows = [ + { key: 'a', row: 10, sheet: 'B' }, + { key: 'b', row: 9, sheet: 'A' }, + { key: 'c', row: 100, sheet: 'B' }, + ] + expect(sortReportRows(rows, 'row', 'asc').map((r) => r.key)).toEqual([ + 'b', + 'a', + 'c', + ]) + expect(sortReportRows(rows, 'row', 'desc').map((r) => r.key)).toEqual([ + 'c', + 'a', + 'b', + ]) + expect(sortReportRows(rows, 'sheet', 'asc').map((r) => r.key)).toEqual([ + 'b', + 'a', + 'c', + ]) + expect(sortReportRows(rows, null, null)).toBe(rows) + }) +}) 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/cmdb/CmdbOverview.vue b/src/views/cmdb/CmdbOverview.vue new file mode 100644 index 000000000..d705f76ce --- /dev/null +++ b/src/views/cmdb/CmdbOverview.vue @@ -0,0 +1,347 @@ + + + + + + + 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/StackiqSettings.vue b/src/views/settings/StackiqSettings.vue index 00b3c6d88..05d723232 100644 --- a/src/views/settings/StackiqSettings.vue +++ b/src/views/settings/StackiqSettings.vue @@ -85,6 +85,9 @@ + + + @@ -116,6 +119,9 @@ + + + @@ -131,10 +137,12 @@ import { defineComponent } from 'vue' import Web from 'vue-material-design-icons/Web.vue' import AlwaysVisibleSection from '../../components/AlwaysVisibleSection.vue' import ArchiMateImportExport from './sections/ArchiMateImportExport.vue' +import CmdbImport from './sections/CmdbImport.vue' import CronjobConfiguration from './sections/CronjobConfiguration.vue' import EmailConfiguration from './sections/EmailConfiguration.vue' import EolSyncSettings from './sections/EolSyncSettings.vue' import FederationSettings from './sections/FederationSettings.vue' +import ItsmExchange from './sections/ItsmExchange.vue' import ModerationQueue from './sections/ModerationQueue.vue' import OpenRegisterIntegration from './sections/OpenRegisterIntegration.vue' import OrganizationSynchronization from './sections/OrganizationSynchronization.vue' @@ -160,11 +168,13 @@ export default defineComponent({ UserGroupsConfiguration, OrganizationSynchronization, ArchiMateImportExport, + CmdbImport, EmailConfiguration, CronjobConfiguration, ModerationQueue, FederationSettings, EolSyncSettings, + ItsmExchange, AlwaysVisibleSection, Web, }, diff --git a/src/views/settings/sections/ArchiMateImportExport.vue b/src/views/settings/sections/ArchiMateImportExport.vue index 6ab9d584f..3b1f9b606 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') }} + +