From 59d6ad51c51d6b442861b682561aea06aaaed086 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 1 Oct 2026 07:26:25 +0200 Subject: [PATCH 01/16] fix(register): seed organisations save on a fresh install contactsUid was required on organization and contactPerson. OpenRegister creates a required property's column NOT NULL, and the app import writes seed objects without validation, so every organisation nested in a seed usage failed to insert and the organization table stayed empty. contactsUid is a link the contacts sync fills in after the record exists, so it is optional now (organization 0.5.2, contactPerson 0.0.28, register 2.5.6). The nested seed organisations also carry their type, the other required field, and the seed usages move to 0.0.2 so an existing install retries them. --- lib/Settings/softwarecatalogus_register.json | 39 +++---- .../Settings/OrganisationSeedSavesTest.php | 106 ++++++++++++++++++ 2 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 tests/Unit/Settings/OrganisationSeedSavesTest.php diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 85f71c90..966d2e0c 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.5", - "changelog": "2.5.5: the maintenanceWindow schema (0.1.0) joins the stackiq register, for maintenance a supplier plans on its products, with the owners of every usage notified (lifecycle-maintenance-and-supplier-roadmap). 2.5.4: usage (1.5.2) gains businessOwner and technicalOwner, contact persons of the consumer organisation; a usage is named after its application and organisation; status becomes facetable for the Applications in use filters (landscape-usage-registration). 2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." + "version": "2.5.6", + "changelog": "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", @@ -1791,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", @@ -2025,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": { @@ -2148,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, @@ -8656,7 +8651,7 @@ "register": "stackiq", "schema": "usage", "slug": "gebruik-topdesk-gem-leiden-deelnemers", - "version": "0.0.1" + "version": "0.0.2" }, "name": "Topdesk bij Servicecenter Rijnland (SSC)", "status": "In production", @@ -8664,19 +8659,22 @@ "consumer": { "name": "Servicecenter Rijnland", "oin": "00000001001234567890", - "slug": "servicecenter-rijnland" + "slug": "servicecenter-rijnland", + "type": "Collaboration" }, "module": "topdesk-itsm", "participants": [ { "name": "Gemeente Leiden", "oin": "00000001001234567891", - "slug": "gemeente-leiden" + "slug": "gemeente-leiden", + "type": "Municipality" }, { "name": "Gemeente Leiderdorp", "oin": "00000001001234567892", - "slug": "gemeente-leiderdorp" + "slug": "gemeente-leiderdorp", + "type": "Municipality" } ] }, @@ -8685,7 +8683,7 @@ "register": "stackiq", "schema": "usage", "slug": "gebruik-key2-gem-leiden-deelnemer", - "version": "0.0.1" + "version": "0.0.2" }, "name": "KEY2 Burgerzaken gedeeld via GBLT", "status": "Planned", @@ -8693,14 +8691,16 @@ "consumer": { "name": "Gemeentebelastingen Coevorden Hardenberg (GBLT)", "oin": "00000001001234567893", - "slug": "gblt" + "slug": "gblt", + "type": "Collaboration" }, "module": "key2-burgerzaken", "participants": [ { "name": "Gemeente Leiden", "oin": "00000001001234567891", - "slug": "gemeente-leiden" + "slug": "gemeente-leiden", + "type": "Municipality" } ] }, @@ -8709,7 +8709,7 @@ "register": "stackiq", "schema": "usage", "slug": "gebruik-suite4-gem-delft-eigenaar", - "version": "0.0.1" + "version": "0.0.2" }, "name": "Suite4 Schuldhulpverlening - eigenaar Gemeente Delft", "status": "In production", @@ -8717,7 +8717,8 @@ "consumer": { "name": "Gemeente Delft", "oin": "00000001001234567894", - "slug": "gemeente-delft" + "slug": "gemeente-delft", + "type": "Municipality" }, "module": "suite4-schuldhulpverlening", "participants": [] diff --git a/tests/Unit/Settings/OrganisationSeedSavesTest.php b/tests/Unit/Settings/OrganisationSeedSavesTest.php new file mode 100644 index 00000000..50b6a327 --- /dev/null +++ b/tests/Unit/Settings/OrganisationSeedSavesTest.php @@ -0,0 +1,106 @@ + + * @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/settings-service/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use PHPUnit\Framework\TestCase; + +/** + * Asserts that the contacts link is optional and that every organisation a + * seed object creates carries the organisation schema's required fields. + */ +class OrganisationSeedSavesTest extends TestCase { + + /** + * The register as shipped. + * + * @return array + */ + private function register(): array { + return json_decode((string) file_get_contents(__DIR__ . '/../../../lib/Settings/softwarecatalogus_register.json'), true); + }//end register() + + /** + * No schema requires the contacts link, in its required list or on the property. + * + * @return void + */ + public function testTheContactsLinkIsNeverRequired(): void { + $schemas = $this->register()['components']['schemas']; + $checked = 0; + foreach ($schemas as $key => $schema) { + if (isset($schema['properties']['contactsUid']) === false) { + continue; + } + + $checked++; + $this->assertNotContains('contactsUid', $schema['required'] ?? [], $key); + $this->assertNotTrue($schema['properties']['contactsUid']['required'] ?? false, $key); + } + + $this->assertSame(2, $checked, 'organization and contactPerson both carry contactsUid'); + }//end testTheContactsLinkIsNeverRequired() + + /** + * Every organisation nested in a seed object carries all required organisation fields. + * + * @return void + */ + public function testEverySeededOrganisationCarriesTheRequiredFields(): void { + $register = $this->register(); + $schemas = $register['components']['schemas']; + $required = $schemas['organization']['required']; + $typeEnum = $schemas['organization']['properties']['type']['enum']; + $this->assertNotEmpty($required); + + $organisations = []; + foreach ($register['components']['objects'] as $object) { + $schema = $schemas[$object['@self']['schema']]; + foreach ($schema['properties'] as $name => $property) { + $ref = $property['$ref'] ?? ($property['items']['$ref'] ?? null); + if ($ref !== '#/components/schemas/organization' || isset($object[$name]) === false) { + continue; + } + + $values = $object[$name]; + if (isset($values['slug']) === true) { + $values = [$values]; + } + + foreach ($values as $organisation) { + $organisations[] = $organisation; + } + } + } + + $this->assertGreaterThanOrEqual(5, count($organisations)); + foreach ($organisations as $organisation) { + foreach ($required as $field) { + $this->assertArrayHasKey($field, $organisation, $organisation['slug']); + } + + $this->assertContains($organisation['type'], $typeEnum, $organisation['slug']); + } + }//end testEverySeededOrganisationCarriesTheRequiredFields() +}//end class From ff16c88f3d98429963874fe6730b6e27eb1b1abd Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 1 Oct 2026 07:34:15 +0200 Subject: [PATCH 02/16] docs(openspec): widen sharing-itsm-exchange to a CMDB with two-way sync Import creates and updates applications, relations, licences and contracts; export sends what stackiq owns; per-field ownership in the integriq mapping decides conflicts; disjoint field sets plus a hash per direction keep writes from echoing; a CSV or XLSX file feeds the same flow. --- .../changes/sharing-itsm-exchange/design.md | 125 +++++++++++++++--- .../changes/sharing-itsm-exchange/proposal.md | 47 +++++-- .../specs/itsm-exchange/spec.md | 111 ++++++++++++++-- .../changes/sharing-itsm-exchange/tasks.md | 77 ++++++++--- 4 files changed, 295 insertions(+), 65 deletions(-) diff --git a/openspec/changes/sharing-itsm-exchange/design.md b/openspec/changes/sharing-itsm-exchange/design.md index fd7cfb6f..84eaf242 100644 --- a/openspec/changes/sharing-itsm-exchange/design.md +++ b/openspec/changes/sharing-itsm-exchange/design.md @@ -1,44 +1,133 @@ # Design: sharing-itsm-exchange -Read at development `9a5ece6a`, OpenRegister development `4fee776`, integriq development `413357e`. +Read at stackiq development `f262512c`, OpenRegister development `9a4e28e9a9`, integriq development `c67abca0c`. Amended 2026-10-01 for the Rotterdam programme (CMDB import, two-way sync, licences and contracts, file import). The first version of this design (one outbound flow, one inbound flow that only linked existing usages) is replaced by the one below. ## Context -Stackiq's outside connections are declared in `lib/Settings/connections.json` and shown by integriq's connection registry. Outside calls and their credentials belong to integriq (ADR-091, ADR-064); stackiq never holds a service desk token. Integriq synchronises through OpenRegister flows made of steps (fetch page, map, contract, save), and its `SourceCallNode` calls a configured source from a flow (integriq `lib/Flow/SourceCallNode.php`). Stackiq authors flows on its own Flows page (`src/manifest.json:1057`) and its store accepts `openregister.flows` configuration sets (`src/manifest.json`, `store.types`). +Outside calls and their credentials belong to integriq (ADR-091, ADR-064); stackiq never holds a service desk token. OpenRegister runs flows; integriq adds the nodes that talk to a source and keep track of what was synchronised. Stackiq ships flow templates and a set-up action, and owns the fields. -## D1. External references on the usage +The building blocks, by who ships them: -`lib/Settings/register.d/itsm-exchange.json` adds to `usage`: `externalReferences`, an array of objects `{ system: string, recordId: string, url: string (uri), syncedAt: date-time }`, visible on the page, `hideOnForm: true` (written by the inbound flow, not typed by hand), and a derived `serviceDeskUrl` for the list column. +| Block | Owner | Key | +|---|---|---| +| Object trigger, schedule trigger, manual trigger | OpenRegister | `openregister.trigger-object`, `openregister.trigger-schedule`, `openregister.trigger-manual` | +| Read objects, write with upsert on a `match` list (patch by default) | OpenRegister | `openregister.object-read`, `openregister.object-write` (`ObjectWriteNode.php:456`) | +| Split a list, compute fields, branch | OpenRegister | `openregister.explode`, `openregister.set-fields`, `openregister.switch` | +| Read pages from a source | integriq | `openconnector.source-paginate` (keyed on a synchronization) | +| Map a record with a stored mapping | integriq | `openconnector.apply-mapping` | +| Remember what was synchronised, decide create, update or skip | integriq | `openconnector.contract`, `openconnector.contract-commit` | +| Call the source | integriq | `openconnector.source-call` | +| Per-field ownership on a mapping, enforced on update | integriq (lane iq, `connectors-service-desk-templates`) | mapping `ownership`, `apply-mapping` `ownership` and `exists` | -The usage is the right object: a service desk's application record describes the application as this organisation runs it, with its own version and owners, not the supplier's product. +## D1. Fields -## D2. The itsm connection +`lib/Settings/register.d/sharing-itsm-exchange.json` (ADR-037): -A fourth entry in `lib/Settings/connections.json`: `key: itsm`, title "Service desk", `reportedOnly: true`, a `switch` on a new app setting `itsm_exchange_enabled`, and `sourceTemplate` naming integriq's service desk templates once integriq publishes them. The flows report their outcome through `ConnectionReportService` (`lib/Service/ConnectionReportService.php`, from `adopt-connection-registry`), so the Integrations page shows the last run. +- On `usage`, `connection` and `catalogContract`: `serviceDeskSystem` (topdesk, servicenow, file), `serviceDeskRecordId`, `serviceDeskUrl` (uri) and `serviceDeskSyncedAt` (date-time). Shown on the page, hidden on the form: the import writes them. +- On `usage`: `installedVersion` (the version the service desk records, a string; `moduleVersion` stays the catalogue reference) and `publicationDate` (stackiq-owned; lane oc publishes a usage only when it is set and in the past). +- On `catalogContract`: `vendorReference` (the supplier's contract or agreement number), `currency` (ISO 4217, default EUR) and `supplier` (the organisation that sold it). `licenceMetric`, `licencesBought` and `licencesInUse` already come from `contracts-licence-seats.json`; start, end, cost and cost period are on the base schema. -## D3. Two flow templates and a set-up action +The reference is flat, not a list of `{system, recordId, url, syncedAt}` as the first version had it. `object-write` matches on a property and `object-read` filters on one; neither reaches into a list of objects. An organisation runs one service desk, so one reference per record is enough. Integriq's contract keeps the full origin record and its hash. -Two flow templates ship in `lib/Settings/flows/` (`itsm-outbound.json`, `itsm-inbound.json`), with three mapping presets (TOPdesk assets, ServiceNow CMDB CIs, GLPI appliances): +The base register changes too, because a fragment can only append to a list (`SettingsService::deepMergeConfig`): `catalogContract.required` drops `service`, and the property loses `required: true`. A licence bought for an application has no catalogue service. Versions: `usage` 1.5.4, `connection` 0.3.4, `catalogContract` 0.1.4, register 2.5.7. `value-assessment.json` also declares the `usage` version and sorts after this fragment, so its version moves to 1.5.4 with it. Otherwise the last fragment wins and the new fields never deploy. -- **Outbound**: trigger `object.updated` and `object.created` on `usage` (OpenRegister `TriggerObjectNode`), a map step that builds the service desk payload (application name, supplier, version, lifecycle status, business owner, technical owner, BBN level), and integriq's source call. A `recordId` in the answer is written back to `externalReferences`. -- **Inbound**: trigger on a nightly schedule, integriq's fetch page over the service desk's application records, a match step on `recordId`, else on name and supplier, and a save step that updates `externalReferences` and `syncedAt`. Unmatched records are listed in the flow run. +## D2. Field ownership -A "Set up service desk exchange" action in the admin settings (a new section `section-itsm`, the anchor the connection entry links to) asks for the integriq source and the mapping preset, fills them into the templates, validates them with OpenRegister (`POST /apps/openregister/api/flow/validate`, openregister `appinfo/routes.php:803`) and creates the flows (`POST /api/flows`, :861), scoped to stackiq so they appear on its Flows page. Controller `lib/Controller/ItsmExchangeController.php`, service `lib/Service/ItsmExchangeService.php`, admin only (`#[AuthorizedAdminSetting]`). +Integriq's mapping presets carry `ownership: {: "source" | "stackiq"}`, one entry per mapped field (lane iq, `connectors-service-desk-templates`). `openconnector.apply-mapping` with `ownership: "inbound"` and `exists: ` keeps, on an update, only the fields the service desk owns; with `"outbound"` it keeps only the fields stackiq owns. On a create it keeps every field. -Rejected: a stackiq PHP client per service desk. It would hold credentials and outside calls in stackiq, which ADR-091 moves to integriq, and it would duplicate integriq's synchronisation. Rejected too: a Store configuration set, because stackiq's Store lists sets from publishers' sources, and this exchange needs the administrator's own source filled in before it can run. +The split for the application level: -## D4. Pages +| Field (stackiq side) | Owner | +|---|---| +| record id, record link, name, supplier, installed version, status, description | service desk | +| business owner, technical owner, BBN level, TIME class, publication date | stackiq | +| every licence and contract field (number, vendor reference, type, start, end, cost, cost period, currency, metric, licences bought) | stackiq | +| relation: both ends, name, type, direction | service desk | -The usage page shows the service desk link from `externalReferences` in its data widget, and Applications in use (`src/manifest.d/usages.json`) gets a Service desk column that opens the record in a new tab. +Licences and contracts are stackiq-owned but the service desk is where many organisations first recorded them. So the import creates a contract it does not yet know, with every field filled, and after that only refreshes the service desk reference. That is the same rule: create keeps every field, update keeps only what the service desk owns. + +## D3. Inbound flows: create and update, never duplicate + +One template per feed: `lib/Settings/flows/itsm-inbound-applications.json`, `itsm-inbound-relations.json`, `itsm-inbound-contracts.json` (licences and contracts share it, each with its own preset and synchronization). Applications run as: + +1. `trigger-schedule`, nightly (`0 2 * * *`), run as the administrator who set it up. +2. `source-paginate` on the feed's synchronization, `explode` the page into `source`. +3. `apply-mapping` with the inbound preset, output `record`: every field, used on create. +4. `apply-mapping` with the same preset, `ownership: inbound`, `exists: record.recordId`, output `owned`. `record.recordId` is always set, so this keeps only the service-desk-owned fields. +5. `contract` with `idPosition: owned.recordId` and `hashPosition: owned`. Unchanged service-desk fields give `skip`. +6. `switch` on the outcome. `skip` ends. +7. Supplier: `object-write` upsert on `organization`, match `name` and `type: Supplier`. +8. Application: `object-write` upsert on `module`, match `name` and `provider`. +9. Usage: on `create`, `object-write` upsert on `usage` matching `serviceDeskRecordId`, then `module` and `consumer`, with every field of `record`. On `update`, `object-write` update matching `@self.uuid` on the contract's target id, with only `owned`. +10. `contract-commit` with the written usage's uuid. + +That is the matching order Ruben asked for: the service desk reference first (the contract, and `serviceDeskRecordId` on the usage), then name and supplier (steps 7 and 8). Spelling variants that miss an exact match are created, and then surface as duplicate candidates through `x-openregister-dedup` on `module`, which `operations-record-reconciliation` links to and merges through OpenRegister's merge engine. Stackiq does not run its own fuzzy match. + +Relations read the desk's relation records, look up both ends by `serviceDeskRecordId` on `usage`, and upsert a `connection` (match `serviceDeskRecordId`) between the two modules. A relation whose end is not imported yet waits for the next run and is listed in the run. + +Licences and contracts look up the usage by `applicationRecordId`, upsert `catalogContract` (match `serviceDeskRecordId`) with every field on create and only the service desk reference on update, and link `usage` and `supplier`. + +## D4. Outbound flow: only what stackiq owns + +`lib/Settings/flows/itsm-outbound-applications.json`: + +1. `trigger-object` on `usage`, `object.created` and `object.updated` (two trigger nodes, one flow). +2. `object-read` the usage's module, supplier, owners and its first active contract. +3. `set-fields` builds `usage` in the shape the outbound preset reads (D2 field names, `catalogueUrl`). +4. `apply-mapping` with the outbound preset, `ownership: outbound`, `exists: usage.recordId`, output `send`. A usage the desk does not know yet sends every field; a known one sends only stackiq-owned fields. +5. `set-fields` builds `stackiqOwned`: only the stackiq-owned source fields of `usage` (owners, BBN level, TIME class, licence and contract fields). +6. `contract` on the outbound synchronization with `idPosition: usage.uuid` and `hashPosition: stackiqOwned`. Unchanged stackiq-owned fields give `skip`, and nothing is sent. +7. `switch` on `usage.recordId`: empty means `source-call` POST to the desk's create endpoint, then `object-write` on the usage with the returned record id and link. Set means `source-call` PATCH or PUT to the record. +8. `contract-commit`. + +## D5. Why there is no ping-pong + +The two directions write disjoint field sets, and each skips on a hash of only its own set. + +- **Inbound change, then outbound.** The import writes only service-desk-owned fields on an existing usage. That fires `object.updated`, and the outbound flow runs. Its hash covers only stackiq-owned fields, which did not change, so `contract` says `skip` and no call goes out. +- **Outbound change, then inbound.** The export changes only stackiq-owned fields on the desk record. The next import maps that record, keeps only service-desk-owned fields for its hash, and they did not change, so `contract` says `skip` and nothing is written. +- **The record id written back after a create.** It fires `object.updated`. The id is not in the stackiq-owned hash, so the outbound flow skips. + +OpenRegister has no loop guard on object triggers (`lifecycle-auto-transitions/design.md:43` in OpenRegister), so the guard has to come from the data, and it does. The live test asserts that one stackiq change makes exactly one call to the mock, and that the next import writes nothing. + +## D6. File import + +For organisations without a service desk API: an administrator uploads a CSV or XLSX on the CMDB page. `ItsmFileImportService` reads the rows with PhpSpreadsheet (shipped by OpenRegister) and starts the file import flow once, with the rows as the run's payload (`FlowService::run`, `context.payload`). That flow is the inbound applications flow with a manual trigger and `explode` over `rows` in place of `source-paginate`, the preset `itsm-file-application-inbound` (column names are the stackiq field names, every field owned by the file), and its own synchronization so a second upload of the same file updates rather than duplicates. The column `recordId` is the key; a row without one is refused with its row number. + +## D7. The itsm connection and the set-up action + +A fourth entry in `lib/Settings/connections.json`: `key: itsm`, title "Service desk", `reportedOnly: true`, a `switch` on app setting `itsm_exchange_enabled`, `settingsUrl: /settings/admin/stackiq#section-itsm`. No `sourceTemplate`: the administrator picks TOPdesk or ServiceNow, so one fixed template would be wrong for half of them. + +`lib/Service/ItsmExchangeService.php` and `lib/Controller/ItsmExchangeController.php`, admin only (`#[AuthorizedAdminSetting]`): + +- `GET /api/itsm/status`: the desk, the source, the flows with their last run, and the file import. +- `POST /api/itsm/setup` with `desk` (topdesk, servicenow) and `source` (an integriq source uuid or slug): looks the source up in integriq's register, creates the synchronizations for each feed in integriq's register, fills the templates (placeholders `%SOURCE%`, `%SYNC_*%`, `%PRESET_*%`, `%RUN_AS%` and the desk profile's endpoints), validates every flow with OpenRegister's `FlowNodePreflight::inspect()`, and only when all are valid saves them with `FlowService::save()`, publishes them and enables them. If one is invalid, nothing is created, and the answer names the node and the reason. Running it again updates the flows it created (their uuids are kept in app setting `itsm_exchange`). +- `POST /api/itsm/import`: the file import (D6). + +The desk profiles (create and update endpoints, the response path of the new record id, the record link pattern) are data in the service, one per desk. They are not a client: every call goes through `openconnector.source-call`. + +## D8. The CMDB page + +A page at `/cmdb`, menu entry "CMDB" under Applications. It says what stackiq records (applications, their components, connections, licences and contracts) and what it does not (hardware, network discovery, tickets), links to each list, shows the service desk exchange with its last run, and takes a file import. Applications in use gets a Service desk column. English and Dutch, written with the `writing` skill. ## Declarative versus imperative -Declarative: fields, the connection entry, and the flows and mappings as data run by OpenRegister and integriq (ADR-031, ADR-065). The set-up action only fills in and creates the flows; no stackiq code calls outside. +Declarative: fields, the connection entry, the flows and the mapping presets, run by OpenRegister and integriq (ADR-031, ADR-065). Imperative, and only because it needs the administrator's choice: the set-up action that fills in and creates the flows, and the file import that reads the file and starts the flow. ## Seed data -None; the flows are created on purpose by the set-up action. +None. The flows are created on purpose by the set-up action. + +## Related changes + +- `operations-record-reconciliation`: owns duplicate candidates and the merge. This change creates near-duplicates on purpose and relies on it. +- `operations-sync-status-and-progress`: owns stackiq's own organisation sync. Outside synchronisation runs belong to integriq and show on the Flows page and the Integrations page, so this change does not add a run log of its own. +- `operations-technology-components`: owns servers and other infrastructure configuration items. An import of those from a service desk is a follow-up on that change, through the same flow pattern. +- `landscape-application-components`: `module.partOf`. A desk relation of type "part of" maps to it in a follow-up; v1 imports relations as connections. +- `contracts-expiry-and-owner`: the contract status and responsible user. The import writes neither; the daily contract job keeps owning the status. ## Risks -- Integriq's service desk source templates do not exist yet (its connector catalogue lists ServiceNow among planned categories). Until they do, the administrator configures a generic REST source in integriq, and the flows work against it. +- Until lane iq ships the presets and the ownership keys, preflight refuses the flows and the set-up action says so. That is a real dependency, listed in the tasks. +- OpenRegister fires `object.updated` on a patch that changes nothing. D5 makes that harmless; it still costs a flow run per imported record. +- A desk whose record id is not unique across feeds (relations and applications in one table) needs the system prefix. The presets map the record id as the desk returns it; the contract is per synchronization, so ids from different feeds never meet. diff --git a/openspec/changes/sharing-itsm-exchange/proposal.md b/openspec/changes/sharing-itsm-exchange/proposal.md index 439cf3fa..2ea64f29 100644 --- a/openspec/changes/sharing-itsm-exchange/proposal.md +++ b/openspec/changes/sharing-itsm-exchange/proposal.md @@ -4,11 +4,17 @@ depends_on: - landscape-usage-registration --- -# Exchange the application landscape with the organisation's service desk +# Keep the application landscape in step with the service desk, as a CMDB ## Summary -A functional administrator connects stackiq to the organisation's service management tool, such as TOPdesk, ServiceNow or GLPI. The applications the organisation uses go to the service desk as configuration items, with supplier, version, status and owners, and the service desk's record id and link come back onto each application in use. Service desk staff then log calls against the same applications the catalogue holds, and the catalogue shows where each one lives in the service desk. +Stackiq becomes the configuration management database (CMDB) for the application level: the applications an organisation uses, the components they are made of, the connections between them, and the licences and contracts behind them. A functional administrator connects stackiq to the organisation's service desk, TOPdesk or ServiceNow, through integriq. From then on the two stay in step in both directions: + +- **Import**: a nightly run reads the service desk's application records, relations, licences and contracts, and creates or updates the matching stackiq records. A second run updates; it never duplicates. +- **Export**: when someone changes what stackiq owns on an application in use (owners, licences, contract, BBN level, TIME class), that change goes to the service desk record. +- **Ownership decides conflicts, not time.** Each mapping marks every field as owned by the service desk or by stackiq. The service desk wins for the fields it owns (name, supplier, installed version, status). Stackiq-only fields (licences, contracts, publication) always stay with stackiq. An import never overwrites a stackiq-owned field, and an export never sends a service-desk-owned field. +- **No ping-pong.** A write in one direction never comes back as a change in the other. +- **A file import** (CSV or XLSX) feeds the same flow for organisations without a service desk API. ## Why @@ -16,28 +22,41 @@ Row from the stackiq matrix: - `stackiq:share-itsm-integration`, "Exchange application data with the organisation's service management tool." Rated no. Four competitors rate yes: SAP LeanIX (https://www.leanix.net/hubfs/Legal/Metrics-and-Feature-List-EAM-SAP-LeanIX-v3.1.pdf, "ServiceNow integration ... to synchronize infrastructure and software asset information"), BlueDolphin (https://help.bluedolphin.io/en/articles/11967779-add-an-integration-in-bluedolphin, "out-of-the-box integrations with ITSM platforms like TOPdesk, ServiceNow, and JIRA"), GLPI (source read at 11.0.9, application records used directly by tickets, `src/Appliance.php:105`) and TOPdesk (https://docs.topdesk.com/en/linking-assets-to-cards.html, assets linked to calls and changes). -No tender, feature request or roadmap row names it. The matrix category keeps stackiq from being a service desk itself (`stackiq:ops-tickets` and the other service desk rows are decided no); exchanging with one is this row. +Ruben widened the row on 2026-10-01 for the Rotterdam programme: stackiq is the CMDB at application level plus licences and contracts, with a two-way sync to TOPdesk and ServiceNow built on OpenRegister and integriq flows and mappings, and per-field ownership as the conflict rule. The earlier version of this change only attached a service desk link to usages that already existed. + +The matrix category still keeps stackiq from being a service desk (`stackiq:ops-tickets` is decided no) and from being a discovery agent. ## What stackiq has today +Read at development `f262512c`, OpenRegister development `9a4e28e9a9`, integriq development `c67abca0c`. + - No ITSM connector: `lib/Settings/connections.json` declares `email`, `federation` and `eol-feed` only, and `lib/` and `src/` hold no TOPdesk, ServiceNow or ITSM code. -- The connection registry (open change `adopt-connection-registry`) shows stackiq's outside connections on the Integrations page, backed by integriq. -- Stackiq's Flows page (`src/manifest.json:1057`) lists OpenRegister flows scoped to stackiq, and OpenRegister validates and creates flows through its API (`/api/flow/validate`, `/api/flows`). -- Integriq runs synchronisation as flow steps (fetch, map, contract, save) over its sources, with credentials held by integriq (integriq open change `flow-native-synchronization`, ADR-064, ADR-091). +- The application level exists as data: `module` (the application as a supplier offers it, with `provider`), `usage` (an organisation's use of it, `lib/Settings/softwarecatalogus_register.json`), `connection` (two applications linked), and components through `module.partOf` (open change `landscape-application-components`). +- Licences and contracts: `catalogContract` has `contractNumber`, `contractType` (SLA, Licence, Maintenance), `startDate`, `endDate`, `cost`, `costPeriod` and `status`; `lib/Settings/register.d/contracts-licence-seats.json` adds `licenceMetric`, `licencesBought` and `licencesInUse`. A contract requires a `service` (a catalogue service), which a licence bought for an application does not have. +- OpenRegister's flow engine has an object trigger, a schedule trigger, `object-read`, `object-write` with `upsert` on any `match` list, `explode`, `set-fields` and `switch`. It has no outside call and no loop guard on object triggers (`openspec/changes/lifecycle-auto-transitions/design.md:43` in OpenRegister). +- Integriq contributes the outside half as flow nodes: `openconnector.source-paginate`, `openconnector.apply-mapping`, `openconnector.contract`, `openconnector.contract-commit` and `openconnector.source-call` (integriq `lib/Flow/FlowNodeListener.php:108`). A contract records the origin id, the hash of what was read, and the stackiq record it became; `openconnector.contract` answers create, update or skip. +- Integriq has no TOPdesk or ServiceNow source and no per-field ownership yet. Both are built in integriq's open change `connectors-service-desk-templates` (Rotterdam lane iq), which defines the ownership marker this change relies on. ## What this change builds -1. On `usage`: `externalReferences`, a list of `{ system, recordId, url, syncedAt }`, so an application in use knows its service desk record. -2. An `itsm` entry in `lib/Settings/connections.json`, so the Integrations page shows whether the exchange is set up and when it last ran. -3. Two flow templates with mapping presets for TOPdesk, ServiceNow and GLPI, and a set-up action for the administrator that fills in the integriq source and creates the flows: outbound (a usage created or changed goes to the service desk through integriq's source call) and inbound (a nightly read of the service desk's application records that writes their id and link back onto the matching usages). -4. The service desk link on the usage page and a Service desk column on Applications in use. +1. **Fields** in a register fragment: on `usage`, `connection` and `catalogContract` the service desk reference (system, record id, link, last synchronised); on `usage` the installed version and a publication date; on `catalogContract` the licence fields still missing (vendor reference, currency, supplier). A contract no longer requires a catalogue service. +2. **An `itsm` connection** on the Integrations page. +3. **Flow templates** shipped by stackiq: inbound applications, inbound relations, inbound licences and contracts, outbound applications, and a file import. A set-up action fills them with the administrator's integriq source and the desk's mapping presets, validates them with OpenRegister, creates and publishes them. +4. **Matching** on import: by the service desk record id first (integriq's contract), then by name and supplier (`object-write` upsert). Near-duplicates that do not match exactly surface on OpenRegister's duplicate candidates page through the rules `operations-record-reconciliation` declares, and a steward merges them there. +5. **A CMDB page** in stackiq that says plainly what stackiq records and what it does not, shows the service desk exchange and its last run, and takes a file import. English and Dutch. ## Out of scope -- The service desk sources and their credentials: integriq's half. Integriq holds the TOPdesk, ServiceNow or GLPI source and its secret (ADR-064), and its connector catalogue gets the source templates. Stackiq names the source by reference only. -- Logging calls or incidents in stackiq: decided no (`stackiq:ops-tickets`, the matrix category). -- Discovering installed software from the service desk's inventory: the matrix category says stackiq is not a discovery agent. +- The service desk sources, their credentials, the mapping presets with their ownership marker, and the mock servers: integriq's half (`connectors-service-desk-templates`, ADR-064, ADR-091). Stackiq names sources and presets by slug only and never holds a secret. +- GLPI: integriq ships its template; stackiq's set-up offers it once a GLPI preset exists. +- Hardware and infrastructure configuration items: `operations-technology-components`. +- Logging calls or incidents in stackiq: decided no (`stackiq:ops-tickets`). +- Discovering installed software: stackiq is not a discovery agent. +- Deleting a stackiq record when its service desk record disappears. The import reports it in the flow run; a person decides. ## Risks -- Matching an existing service desk record to a usage is by name and supplier on the first run; a record that does not match stays unlinked and is listed in the flow run for a person to link by hand. +- Integriq's ownership enforcement (`apply-mapping` `ownership` and `exists`) and the TOPdesk and ServiceNow presets do not exist until lane iq ships them. Until then OpenRegister's preflight reports the flows as invalid and the set-up action refuses, saying which node or preset is missing. It never creates a half-working flow. +- A desk record that matches no stackiq application by id, name or supplier is created as a new application. If it was a spelling variant it shows up as a duplicate candidate; merging it is a steward's task. +- Two people changing the same field in both systems at once: the owner's value wins on the next run. That is the rule, and the docs say so. +- Contracts and licences carry costs. The fields stay behind `catalogContract`'s read rule, and lane oc publishes nothing from that schema. diff --git a/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md b/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md index 4b59cdaf..7493b6e6 100644 --- a/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md +++ b/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md @@ -7,25 +7,114 @@ ## Purpose -The organisation's applications in use are exchanged with its service management tool through integriq, and each carries a link to its service desk record. Matrix row `stackiq:share-itsm-integration`. +Stackiq is the CMDB for the application level: applications, components, connections, licences and contracts. It stays in step with the organisation's service desk (TOPdesk, ServiceNow) in both directions through integriq, with per-field ownership deciding every conflict. Matrix row `stackiq:share-itsm-integration`. ## ADDED Requirements -### Requirement: REQ-ITX-001 The organisation's applications in use reach its service desk +### Requirement: REQ-ITX-001 An administrator sets up the exchange without stackiq holding a credential -Stackiq SHALL let an administrator set up, from the admin settings, an outbound flow that sends a usage's application, supplier, version, status, owners and BBN level to the service desk source the administrator configured in integriq when the usage is created or changed, and an inbound flow that reads the service desk's application records nightly and links them to matching usages. Stackiq SHALL NOT hold the service desk credentials. +Stackiq SHALL let a Nextcloud admin set up the exchange from the admin settings by choosing the service desk (TOPdesk or ServiceNow) and an integriq source. The set-up SHALL create the inbound and outbound flows from stackiq's templates, validate every flow with OpenRegister before saving any, and save, publish and enable them only when all are valid. When one is invalid it SHALL create nothing and SHALL name the node and the reason. Running it again SHALL update the flows it created instead of adding new ones. Stackiq SHALL NOT store or send the service desk credentials. -#### Scenario: A new application in use reaches TOPdesk -@e2e tests/e2e/workflows/itsm-exchange.spec.ts +#### Scenario: Set up against the TOPdesk source +@e2e exclude Admin settings set-up runs against integriq's source and OpenRegister's flow store; verified by tests/Unit/Service/ItsmExchangeServiceTest.php and the live run on the test instance recorded in the PR. + +- **GIVEN** integriq holds a TOPdesk source with its credential +- **WHEN** the admin chooses TOPdesk and that source and starts the set-up +- **THEN** the inbound flows and the outbound flow exist, published and enabled, on stackiq's Flows page +- **AND** starting the set-up again leaves the same number of flows + +#### Scenario: A missing mapping preset stops the set-up +@e2e exclude Exercised by tests/Unit/Service/ItsmExchangeServiceTest.php with OpenRegister's preflight answering blocking. + +- **GIVEN** integriq has no ServiceNow mapping preset +- **WHEN** the admin sets up with ServiceNow +- **THEN** no flow is created +- **AND** the answer names the node and the reason preflight gave + +### Requirement: REQ-ITX-002 The import creates and updates stackiq records and never duplicates them + +The inbound flows SHALL read the service desk's application records, relations, licences and contracts on a schedule and create or update the matching stackiq records: the supplier as an organisation, the application as a module, the organisation's use of it as a usage, relations as connections, and licences and contracts as contracts. A record SHALL be matched by its service desk record id first and by name and supplier second. A second run over the same records SHALL update, not create. + +#### Scenario: A first import creates, a second updates +@e2e exclude Server-side flow runs against the TOPdesk mock; recorded in the PR's live run. + +- **GIVEN** the TOPdesk mock holds three applications, one relation and one licence contract +- **WHEN** the inbound flows run +- **THEN** stackiq holds three usages with their modules and supplier, one connection and one contract, each with its service desk record id +- **WHEN** the inbound flows run again +- **THEN** the number of usages, modules, connections and contracts is unchanged + +#### Scenario: An application already in the catalogue is reused +@e2e exclude Server-side flow run; recorded in the PR's live run. + +- **GIVEN** the catalogue holds module "Zaaksysteem X" from supplier "Leverancier B" +- **WHEN** the import reads a desk record named "Zaaksysteem X" from "Leverancier B" +- **THEN** the new usage points at that module and no second module is created + +### Requirement: REQ-ITX-003 The owner of a field wins + +Every mapped field SHALL have an owner, the service desk or stackiq, declared in the mapping preset. An import SHALL write service-desk-owned fields on an existing record and SHALL NOT change a stackiq-owned field. An export SHALL send stackiq-owned fields for a record the service desk already knows and SHALL NOT send a service-desk-owned field. A record created by either side SHALL get every mapped field. Ownership SHALL NOT be decided by timestamps. + +#### Scenario: Both sides changed, each keeps its own +@e2e exclude Server-side flows against the mock; recorded in the PR's live run. + +- **GIVEN** an imported usage +- **WHEN** the desk renames the application and someone in stackiq changes the business owner, before the next run +- **THEN** after the import and the export, stackiq shows the desk's new name and the desk shows stackiq's business owner + +#### Scenario: A licence edited in stackiq survives the import +@e2e exclude Server-side flow run; recorded in the PR's live run. -- **GIVEN** a Nextcloud admin set up the service desk exchange with the integriq source for the municipality's TOPdesk and the TOPdesk preset -- **WHEN** an information manager moves the usage of application X to In production -- **THEN** the flow run shows the usage sent to the source -- **AND** the usage carries the record id TOPdesk returned +- **GIVEN** a contract created by the import with 100 licences bought +- **WHEN** stackiq changes it to 120 and the desk still says 100 +- **THEN** after the next import the contract says 120 -### Requirement: REQ-ITX-002 An application in use shows its service desk record +### Requirement: REQ-ITX-004 A write never echoes back + +A change written by the import SHALL NOT cause an export call, and a change written by the export SHALL NOT cause an import write. Writing the returned record id after a create SHALL NOT cause a second export call. + +#### Scenario: One change, one call +@e2e exclude Counts calls on the mock; recorded in the PR's live run. + +- **GIVEN** the exchange is set up and the import has run +- **WHEN** someone changes the technical owner of one usage +- **THEN** the mock receives exactly one update call for that record +- **AND** the next import writes nothing +- **AND** the mock receives no further call + +### Requirement: REQ-ITX-005 Licences and contracts carry what a CMDB needs + +A contract SHALL record the licence metric, licences bought and in use, start and end, cost with its period and currency, the supplier, the supplier's own reference, and the service desk reference. A contract SHALL NOT require a catalogue service. Contracts, licences and costs SHALL NOT be public. + +#### Scenario: A licence for an application without a service +@e2e exclude Register fragment; verified by tests/Unit/Settings/ItsmExchangeFragmentTest.php. + +- **GIVEN** the merged register +- **WHEN** a contract is created with a usage, type Licence, 50 licences bought per named user, EUR 12000 a year and no service +- **THEN** it validates against the contract schema + +### Requirement: REQ-ITX-006 A file feeds the same import + +An administrator SHALL be able to import applications from a CSV or XLSX file whose columns are stackiq's field names. The file SHALL run through the same flow, mapping and matching as the service desk import, and importing the same file twice SHALL update rather than duplicate. A row without a record id SHALL be refused with its row number. + +#### Scenario: Import a spreadsheet twice +@e2e exclude Upload runs the server-side flow; verified by tests/Unit/Service/ItsmFileImportServiceTest.php and the live run in the PR. + +- **GIVEN** a CSV with two applications +- **WHEN** the admin imports it twice +- **THEN** stackiq holds two usages from it, not four + +### Requirement: REQ-ITX-007 The CMDB page says what stackiq is + +Stackiq SHALL have a CMDB page that names what it records (applications, components, connections, licences and contracts) and what it does not (hardware, network discovery, tickets), links to each list, shows the service desk exchange and the outcome of its last run, and offers the file import to admins. A usage SHALL show its service desk link, and Applications in use SHALL have a Service desk column. The Integrations page SHALL list the service desk exchange. + +#### Scenario: An information manager opens the CMDB page +@e2e tests/e2e/workflows/itsm-exchange.spec.ts -A usage SHALL keep its service desk references (system, record id, link, last synchronised), show the link on its page and in a Service desk column on Applications in use, and the Integrations page SHALL show the service desk exchange with the outcome of its last run. +- **GIVEN** a signed-in admin +- **WHEN** they open the CMDB page +- **THEN** it lists applications, components, connections, licences and contracts with links +- **AND** it says stackiq does not discover hardware #### Scenario: A service desk employee finds the catalogue entry and back @e2e tests/e2e/workflows/itsm-exchange.spec.ts diff --git a/openspec/changes/sharing-itsm-exchange/tasks.md b/openspec/changes/sharing-itsm-exchange/tasks.md index 7efaeb21..9e1a1ee3 100644 --- a/openspec/changes/sharing-itsm-exchange/tasks.md +++ b/openspec/changes/sharing-itsm-exchange/tasks.md @@ -2,42 +2,75 @@ ## Implementation tasks -### Task 1: External references and the connection entry -- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-002-an-application-in-use-shows-its-service-desk-record -- **files**: `lib/Settings/register.d/itsm-exchange.json`, `lib/Settings/connections.json`, `tests/Unit/Settings/ConnectionsDeclarationTest.php` +### Task 1: Fields, contract licence fields and the connection entry +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-005-licences-and-contracts-carry-what-a-cmdb-needs +- **files**: `lib/Settings/register.d/sharing-itsm-exchange.json`, `lib/Settings/register.d/value-assessment.json` (usage version only), `lib/Settings/softwarecatalogus_register.json` (`catalogContract` no longer requires `service`; register version), `lib/Settings/connections.json`, `tests/Unit/Settings/ItsmExchangeFragmentTest.php`, `tests/Unit/Settings/ConnectionsDeclarationTest.php` - **acceptance_criteria**: - - GIVEN the merged register WHEN it is imported THEN usage carries externalReferences + - GIVEN the merged register WHEN it is read THEN usage, connection and catalogContract carry the service desk reference, usage carries installedVersion and publicationDate, catalogContract carries vendorReference, currency and supplier, and each schema's version is higher than on development + - GIVEN a licence contract payload without a service WHEN it is validated against the merged catalogContract schema THEN it is valid - GIVEN integriq installed WHEN the Integrations page opens THEN it lists Service desk - [ ] Implement -- [ ] Test (PHPUnit `tests/Unit/Settings/ItsmExchangeFragmentTest.php`; the existing `ConnectionsDeclarationTest.php` extended for the itsm entry) +- [ ] Test (PHPUnit `tests/Unit/Settings/ItsmExchangeFragmentTest.php`; `ConnectionsDeclarationTest.php` covers the itsm entry) -### Task 2: Flow templates and the set-up action -- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-the-organisations-applications-in-use-reach-its-service-desk -- **files**: `lib/Settings/flows/itsm-outbound.json`, `lib/Settings/flows/itsm-inbound.json`, `lib/Service/ItsmExchangeService.php`, `lib/Controller/ItsmExchangeController.php`, `appinfo/routes.php`, `src/views/settings/sections/ItsmExchange.vue`, `lib/Service/ConnectionReportService.php` (report for the itsm key) +### Task 2: Flow templates +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-002-the-import-creates-and-updates-stackiq-records-and-never-duplicates-them +- **files**: `lib/Settings/flows/itsm-inbound-applications.json`, `lib/Settings/flows/itsm-inbound-relations.json`, `lib/Settings/flows/itsm-inbound-contracts.json`, `lib/Settings/flows/itsm-outbound-applications.json`, `lib/Settings/flows/itsm-file-applications.json`, `tests/Unit/Settings/ItsmFlowTemplatesTest.php` - **acceptance_criteria**: - - GIVEN the administrator set up the exchange with a TOPdesk source WHEN a usage goes live THEN the flow sends it to the source and writes the returned record id back - - GIVEN a nightly run WHEN a service desk record matches a usage by name and supplier THEN the usage gets its link + - GIVEN each template filled with a source, synchronizations and presets WHEN it is checked THEN every node type is one OpenRegister or integriq registers, no edge carries a step, there is one trigger and one end, and no placeholder is left + - GIVEN the outbound template WHEN its hash input is read THEN it holds only stackiq-owned fields, and the inbound hash input is the ownership-filtered mapping - [ ] Implement -- [ ] Test (PHPUnit `tests/Unit/Service/ItsmExchangeServiceTest.php` fills and validates the templates; a flow test run against a mock source in `tests/e2e/workflows/itsm-exchange.spec.ts`) +- [ ] Test (PHPUnit `tests/Unit/Settings/ItsmFlowTemplatesTest.php`; preflight on the live instance in Task 3) -### Task 3: Service desk link on the pages -- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-002-an-application-in-use-shows-its-service-desk-record -- **files**: `src/manifest.d/usages.json`, `l10n/en.json`, `l10n/nl.json` +### Task 3: Set-up action +- **spec_ref**: 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 +- **files**: `lib/Service/ItsmExchangeService.php`, `lib/Controller/ItsmExchangeController.php`, `appinfo/routes.php`, `lib/Service/ConnectionReportService.php` (report for the itsm key), `tests/Unit/Service/ItsmExchangeServiceTest.php` - **acceptance_criteria**: + - GIVEN a TOPdesk source WHEN the admin sets up THEN the synchronizations and flows are created, every flow validated before any is saved, then published and enabled + - GIVEN preflight answers blocking for one flow WHEN the admin sets up THEN nothing is created and the answer names the node and reason + - GIVEN the set-up ran before WHEN it runs again THEN the stored flows are updated, not duplicated +- [ ] Implement +- [ ] Test (PHPUnit `tests/Unit/Service/ItsmExchangeServiceTest.php`) + +### Task 4: File import +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import +- **files**: `lib/Service/ItsmFileImportService.php`, `lib/Controller/ItsmExchangeController.php`, `tests/Unit/Service/ItsmFileImportServiceTest.php` +- **acceptance_criteria**: + - GIVEN a CSV or XLSX with stackiq column names WHEN it is imported THEN the file flow runs once with the rows as payload + - GIVEN a row without recordId WHEN it is imported THEN nothing runs and the answer names the row +- [ ] Implement +- [ ] Test (PHPUnit `tests/Unit/Service/ItsmFileImportServiceTest.php`) + +### Task 5: CMDB page, admin section and the service desk column +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is +- **files**: `src/manifest.d/cmdb.json`, `src/views/cmdb/CmdbOverview.vue`, `src/customComponents.js`, `src/views/settings/sections/ItsmExchange.vue`, `src/views/settings/StackiqSettings.vue`, `src/manifest.d/usages.json`, `l10n/en.json`, `l10n/nl.json`, `tests/e2e/workflows/itsm-exchange.spec.ts` +- **acceptance_criteria**: + - GIVEN a signed-in admin WHEN the CMDB page opens THEN it names what stackiq records and what it does not, links to each list, and shows the exchange status - GIVEN a usage with a service desk reference WHEN Applications in use opens THEN the Service desk column links to the record - [ ] Implement -- [ ] Test (Playwright case in `tests/e2e/workflows/itsm-exchange.spec.ts`) +- [ ] Test (Playwright `tests/e2e/workflows/itsm-exchange.spec.ts`) + +### Task 6: Live run against the mocks +- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-004-a-write-never-echoes-back +- **files**: `tests/live/itsm-exchange-live.sh` +- **acceptance_criteria**: + - GIVEN lane iq's TOPdesk and ServiceNow mocks WHEN the script runs THEN the first import creates, the second updates, an outbound change reaches the mock once, a conflict keeps each owner's field, and no echo call follows +- [ ] Run against the TOPdesk mock +- [ ] Run against the ServiceNow mock +- [ ] Run against Ruben's ServiceNow developer instance (comes from Ruben later) -### Task 4: Documentation -- **spec_ref**: openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-the-organisations-applications-in-use-reach-its-service-desk -- **files**: `docs/features/service-desk-exchange.md`, `docs/images/service-desk-exchange.png` +### Task 7: Documentation +- **spec_ref**: 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 +- **files**: `docs/features/service-desk-exchange.md` - **acceptance_criteria**: - - GIVEN the docs site WHEN a reader opens Service desk exchange THEN setting up the integriq source, installing the set and reading the results are explained with a screenshot + - GIVEN the docs site WHEN a reader opens Service desk exchange THEN setting up the integriq source, the ownership rule, the file import and what to do with duplicate candidates are explained - [ ] Implement -- [ ] Test (docs build, screenshot with Playwright) + +## Dependencies + +- Integriq (lane iq, `connectors-service-desk-templates`): the `topdesk` and `servicenow` source templates, the mapping presets `itsm--application-inbound`, `-application-outbound`, `-relation-inbound`, `-licence-inbound`, `-contract-inbound` and `itsm-file-application-inbound` with the `ownership` marker, the `apply-mapping` keys `ownership` and `exists`, and the TOPdesk and ServiceNow mocks. ## Verification - `openspec validate sharing-itsm-exchange --type change --strict` passes. -- `composer check:strict` and `npm run lint` pass; the PHPUnit and Playwright cases above pass. -- English and Dutch strings for every new label (ADR-005); docs with a screenshot (ADR-010). +- `composer check:strict` and `npm run lint` pass; `phpunit -c phpunit-unit.xml` runs the new tests (the strict run's own test step skips outside a Nextcloud tree). +- English and Dutch strings for every new label (ADR-005). From 7529d6dec48bde16f84b5e38dd44d03bd7df4c51 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 1 Oct 2026 07:46:01 +0200 Subject: [PATCH 03/16] feat(itsm): fields, flow templates, set-up and file import for the service desk exchange The fragment adds the service desk reference to usage, connection and contract, the installed version and publication date to usage, and the licence fields still missing to contract; a contract no longer needs a catalogue service. Five flow templates (three imports, the export, the file import) are filled per desk by ItsmExchangeService, checked with OpenRegister's preflight, and only saved when all pass. --- appinfo/routes.php | 8 + lib/Controller/ItsmExchangeController.php | 151 ++++++ lib/Service/ConnectionReportService.php | 26 + lib/Service/Itsm/ItsmFlowGateway.php | 212 ++++++++ lib/Service/ItsmExchangeService.php | 391 +++++++++++++++ lib/Service/ItsmFileImportService.php | 216 +++++++++ lib/Settings/connections.json | 13 + .../flows/itsm-file-applications.json | 304 ++++++++++++ .../flows/itsm-inbound-applications.json | 322 +++++++++++++ .../flows/itsm-inbound-contracts.json | 359 ++++++++++++++ .../flows/itsm-inbound-relations.json | 308 ++++++++++++ .../flows/itsm-outbound-applications.json | 454 ++++++++++++++++++ .../register.d/sharing-itsm-exchange.json | 218 +++++++++ lib/Settings/register.d/value-assessment.json | 2 +- lib/Settings/softwarecatalogus_register.json | 6 +- .../Unit/Service/ItsmExchangeServiceTest.php | 217 +++++++++ .../Service/ItsmFileImportServiceTest.php | 167 +++++++ .../Settings/ConnectionsDeclarationTest.php | 25 + .../Settings/ItsmExchangeFragmentTest.php | 172 +++++++ tests/Unit/Settings/ItsmFlowTemplatesTest.php | 299 ++++++++++++ 20 files changed, 3865 insertions(+), 5 deletions(-) create mode 100644 lib/Controller/ItsmExchangeController.php create mode 100644 lib/Service/Itsm/ItsmFlowGateway.php create mode 100644 lib/Service/ItsmExchangeService.php create mode 100644 lib/Service/ItsmFileImportService.php create mode 100644 lib/Settings/flows/itsm-file-applications.json create mode 100644 lib/Settings/flows/itsm-inbound-applications.json create mode 100644 lib/Settings/flows/itsm-inbound-contracts.json create mode 100644 lib/Settings/flows/itsm-inbound-relations.json create mode 100644 lib/Settings/flows/itsm-outbound-applications.json create mode 100644 lib/Settings/register.d/sharing-itsm-exchange.json create mode 100644 tests/Unit/Service/ItsmExchangeServiceTest.php create mode 100644 tests/Unit/Service/ItsmFileImportServiceTest.php create mode 100644 tests/Unit/Settings/ItsmExchangeFragmentTest.php create mode 100644 tests/Unit/Settings/ItsmFlowTemplatesTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 57ec9fbc..3586e5e4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -292,6 +292,14 @@ ['name' => 'settings#triggerEolSync', 'url' => '/api/eol-sync/trigger', 'verb' => 'POST'], ['name' => 'settings#getEolSyncStatus', 'url' => '/api/eol-sync/status', 'verb' => 'GET'], + // SERVICE DESK EXCHANGE (sharing-itsm-exchange): status for the CMDB + // page, set-up and file import for admins. The flows themselves run + // in OpenRegister and integriq; these only create and start them. + ['name' => 'itsmExchange#status', 'url' => '/api/itsm/status', 'verb' => 'GET'], + ['name' => 'itsmExchange#config', 'url' => '/api/itsm/config', 'verb' => 'GET'], + ['name' => 'itsmExchange#setUp', 'url' => '/api/itsm/setup', 'verb' => 'POST'], + ['name' => 'itsmExchange#import', 'url' => '/api/itsm/import', 'verb' => 'POST'], + // Gebruik by group ['name' => 'gebruik#getGebruiken', 'url' => '/api/gebruik', 'verb' => 'GET'], ['name' => 'gebruik#getGebruikenForDeelnemer', 'url' => '/api/gebruik/deelnemer', 'verb' => 'GET'], diff --git a/lib/Controller/ItsmExchangeController.php b/lib/Controller/ItsmExchangeController.php new file mode 100644 index 00000000..a5c347d4 --- /dev/null +++ b/lib/Controller/ItsmExchangeController.php @@ -0,0 +1,151 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @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. + */ +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(Application::APP_ID, $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. + * + * @AuthorizedAdminSetting(settings=OCA\Stackiq\Settings\StackiqAdmin) + * + * @param string $desk The desk key. + * @param string $organisation The organisation uuid. + * + * @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 = ''): 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()); + 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/Service/ConnectionReportService.php b/lib/Service/ConnectionReportService.php index dc598f4e..1e058c97 100644 --- a/lib/Service/ConnectionReportService.php +++ b/lib/Service/ConnectionReportService.php @@ -83,6 +83,13 @@ class ConnectionReportService { */ 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. * @@ -384,6 +391,25 @@ public function describeEolRun(array $runStatus): ?array { 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. * diff --git a/lib/Service/Itsm/ItsmFlowGateway.php b/lib/Service/Itsm/ItsmFlowGateway.php new file mode 100644 index 00000000..4c29fb4f --- /dev/null +++ b/lib/Service/Itsm/ItsmFlowGateway.php @@ -0,0 +1,212 @@ + + * @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. + */ +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; + } + + $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 00000000..aedc9a44 --- /dev/null +++ b/lib/Service/ItsmExchangeService.php @@ -0,0 +1,391 @@ + + * @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. + */ +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 are the ones integriq's change connectors-service-desk-templates + * ships. The endpoints are what the outbound source call uses; `%LOCATION%` + * becomes the source's base address. + * + * @var array> + */ + public const DESKS = [ + 'topdesk' => [ + 'label' => 'TOPdesk', + 'source' => 'topdesk', + 'createEndpoint' => '/tas/api/assetmgmt/assets', + 'createMethod' => 'POST', + 'updateEndpoint' => '/tas/api/assetmgmt/assets/{{ usage.recordId }}', + 'updateMethod' => 'PATCH', + 'responseId' => 'id', + 'recordUrl' => '%LOCATION%/tas/secure/assetmgmt/card.html?unid={{ response.body.id }}', + ], + 'servicenow' => [ + 'label' => 'ServiceNow', + 'source' => 'servicenow', + 'createEndpoint' => '/api/now/table/cmdb_ci_appl', + 'createMethod' => 'POST', + 'updateEndpoint' => '/api/now/table/cmdb_ci_appl/{{ usage.recordId }}', + 'updateMethod' => 'PATCH', + 'responseId' => 'result.sys_id', + 'recordUrl' => '%LOCATION%/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 base address, for record links. + * + * @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): 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), '/'); + $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('%LOCATION%', rtrim($location, '/'), $profile['recordUrl']), + ]; + if ($key === 'file') { + $values['SYNC'] = 'itsm-file-applications'; + $values['PRESET'] = 'itsm-file-application-inbound'; + } + + $flows[$key] = self::fill(value: $this->template(file: $flow['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. + * + * @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): array { + if ($this->gateway->available() === false) { + return $this->refuse(message: 'OpenRegister\'s flow engine is not available, so no exchange can be set up.'); + } + + if (isset(self::DESKS[$desk]) === false) { + return $this->refuse(message: 'Unknown service desk "' . $desk . '".'); + } + + if ($this->gateway->findObject(register: self::REGISTER, schema: 'organization', id: $organisation) === null) { + return $this->refuse(message: 'The organisation ' . $organisation . ' does not exist in stackiq.'); + } + + $source = $this->gateway->findObject(register: 'integriq', schema: 'source', id: self::DESKS[$desk]['source']); + if ($source === null) { + return $this->refuse(message: 'Integriq has no source "' . self::DESKS[$desk]['source'] . '". Add the ' . self::DESKS[$desk]['label'] . ' source in integriq first.'); + } + + $flows = $this->buildFlows(desk: $desk, organisation: $organisation, runAs: $runAs, location: (string) ($source['location'] ?? '')); + $blocking = []; + foreach ($flows as $key => $flow) { + $findings = $this->gateway->inspect(flow: $flow); + if ($findings['blocking'] !== []) { + $blocking[$key] = $findings['blocking']; + } + } + + 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) { + $previous = $stored[$key] ?? null; + $saved[$key] = $this->gateway->saveAndPublish(flow: $flow, uuid: is_string($previous) === true ? $previous : null); + } + } 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() + + /** + * Replace every `%KEY%` placeholder in the strings of a value. + * + * @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; + } + + $search = []; + $replace = []; + foreach ($values as $key => $text) { + $search[] = '%' . $key . '%'; + $replace[] = $text; + } + + return str_replace($search, $replace, $value); + }//end fill() + + /** + * 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 { + $dir = ($this->templateDir ?? __DIR__ . '/../Settings/flows'); + $content = @file_get_contents($dir . '/' . $file); + $decoded = json_decode((string) $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 00000000..7429363b --- /dev/null +++ b/lib/Service/ItsmFileImportService.php @@ -0,0 +1,216 @@ + + * @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 Throwable; + +/** + * Reads a spreadsheet and starts the file import flow with its rows. + */ +class ItsmFileImportService { + + /** + * The most rows one import takes. + * + * @var integer + */ + public const MAX_ROWS = 5000; + + /** + * 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. + */ + public function __construct( + private readonly ItsmFlowGateway $gateway, + private readonly IAppConfig $appConfig, + ) { + }//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 = (is_array($config) === true) ? ($config['flows']['file'] ?? null) : 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.']; + } + + 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]; + } + + $run = $this->gateway->run(uuid: $flow, payload: ['rows' => $rows]); + + 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. + * + * @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 { + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if ($extension === 'csv') { + $table = $this->readCsv(path: $path); + } else if ($extension === 'xlsx') { + $table = $this->readXlsx(path: $path); + } else { + throw new \RuntimeException('only .csv and .xlsx files can be imported, not .' . $extension); + } + + $header = array_map(static fn ($cell): string => trim((string) $cell), (array) array_shift($table)); + $rows = []; + foreach ($table as $cells) { + $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() + + /** + * 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 ' . count($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 list> The cells. + */ + private function readCsv(string $path): array { + $handle = fopen($path, 'r'); + if ($handle === false) { + throw new \RuntimeException('cannot open the uploaded file'); + } + + $first = (string) fgets($handle); + $delimiter = ','; + if (substr_count($first, ';') > substr_count($first, ',')) { + $delimiter = ';'; + } + + rewind($handle); + $table = []; + while (($cells = fgetcsv($handle, null, $delimiter, '"', '\\')) !== false) { + $table[] = array_map(static fn ($cell): string => (string) $cell, $cells); + } + + fclose($handle); + if (isset($table[0][0]) === true) { + $table[0][0] = preg_replace('/^\xEF\xBB\xBF/', '', $table[0][0]); + } + + return $table; + }//end readCsv() + + /** + * Read the first sheet of an XLSX file into rows of cells, with PhpSpreadsheet as OpenRegister ships it. + * + * @param string $path The file. + * + * @return list> The cells. + */ + private function readXlsx(string $path): array { + $factory = '\PhpOffice\PhpSpreadsheet\IOFactory'; + if (class_exists($factory) === false) { + throw new \RuntimeException('reading .xlsx needs PhpSpreadsheet, which OpenRegister provides; save the sheet as .csv instead'); + } + + $sheet = $factory::load($path)->getActiveSheet()->toArray(null, true, false, false); + + return array_map(static fn ($cells): array => array_map(static fn ($cell): string => (string) $cell, (array) $cells), (array) $sheet); + }//end readXlsx() +}//end class diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json index 2e8c10bd..d9fc531f 100644 --- a/lib/Settings/connections.json +++ b/lib/Settings/connections.json @@ -41,6 +41,19 @@ "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 00000000..ba3ac674 --- /dev/null +++ b/lib/Settings/flows/itsm-file-applications.json @@ -0,0 +1,304 @@ +{ + "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": "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-map-all", + "from": "each", + "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 00000000..f56f27c0 --- /dev/null +++ b/lib/Settings/flows/itsm-inbound-applications.json @@ -0,0 +1,322 @@ +{ + "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": "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-map-all", + "from": "each", + "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 00000000..bf351cd1 --- /dev/null +++ b/lib/Settings/flows/itsm-inbound-contracts.json @@ -0,0 +1,359 @@ +{ + "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": "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-map-all", + "from": "each", + "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.json b/lib/Settings/flows/itsm-inbound-relations.json new file mode 100644 index 00000000..f0827e1e --- /dev/null +++ b/lib/Settings/flows/itsm-inbound-relations.json @@ -0,0 +1,308 @@ +{ + "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": "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-map-all", + "from": "each", + "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 00000000..9e7bb7b0 --- /dev/null +++ b/lib/Settings/flows/itsm-outbound-applications.json @@ -0,0 +1,454 @@ +{ + "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 }}" + } + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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/sharing-itsm-exchange.json b/lib/Settings/register.d/sharing-itsm-exchange.json new file mode 100644 index 00000000..25434a1c --- /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/value-assessment.json b/lib/Settings/register.d/value-assessment.json index 29d4d4b9..e7dedce9 100644 --- a/lib/Settings/register.d/value-assessment.json +++ b/lib/Settings/register.d/value-assessment.json @@ -2,7 +2,7 @@ "components": { "schemas": { "usage": { - "version": "1.5.3", + "version": "1.5.4", "properties": { "businessValue": { "type": "integer", diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 966d2e0c..d56d5ba3 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.6", - "changelog": "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." + "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", @@ -3270,7 +3270,6 @@ "omschrijving": "", "icon": "FileSign", "required": [ - "service", "usage", "startDate", "contractNumber", @@ -3289,7 +3288,6 @@ "description": "De dienst waarop dit contract betrekking heeft", "type": "object", "facetable": false, - "required": true, "title": "Service", "order": 12, "objectConfiguration": { diff --git a/tests/Unit/Service/ItsmExchangeServiceTest.php b/tests/Unit/Service/ItsmExchangeServiceTest.php new file mode 100644 index 00000000..8806dd80 --- /dev/null +++ b/tests/Unit/Service/ItsmExchangeServiceTest.php @@ -0,0 +1,217 @@ + + * @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/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-001-an-administrator-sets-up-the-exchange-without-stackiq-holding-a-credential + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\Stackiq\Service\ConnectionReportService; +use OCA\Stackiq\Service\Itsm\ItsmFlowGateway; +use OCA\Stackiq\Service\ItsmExchangeService; +use OCP\IAppConfig; +use OCP\IURLGenerator; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Asserts what the set-up creates, refuses and reports. + */ +class ItsmExchangeServiceTest extends TestCase { + + /** + * The organisation whose applications are exchanged. + * + * @var string + */ + private const ORG = '0f6c3a8e-1111-4c2b-9d6a-2a1b3c4d5e6f'; + + /** + * OpenRegister's flow store. + * + * @var ItsmFlowGateway&MockObject + */ + private ItsmFlowGateway&MockObject $gateway; + + /** + * App settings kept in memory. + * + * @var array + */ + private array $settings = []; + + /** + * Reports to integriq. + * + * @var ConnectionReportService&MockObject + */ + private ConnectionReportService&MockObject $reports; + + /** + * The service under test. + * + * @return ItsmExchangeService + */ + private function service(): ItsmExchangeService { + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback(fn (string $app, string $key, string $default = ''): string => (string) ($this->settings[$key] ?? $default)); + $config->method('setValueString')->willReturnCallback(function (string $app, string $key, string $value): bool { + $this->settings[$key] = $value; + return true; + }); + $config->method('setValueBool')->willReturnCallback(function (string $app, string $key, bool $value): bool { + $this->settings[$key] = $value; + return true; + }); + $config->method('getValueBool')->willReturnCallback(fn (string $app, string $key, bool $default = false): bool => (bool) ($this->settings[$key] ?? $default)); + + $urls = $this->createMock(IURLGenerator::class); + $urls->method('getAbsoluteURL')->willReturn('https://cloud.example.nl/index.php/apps/stackiq'); + + return new ItsmExchangeService( + gateway: $this->gateway, + appConfig: $config, + urlGenerator: $urls, + logger: $this->createMock(LoggerInterface::class), + connectionReports: $this->reports + ); + }//end service() + + /** + * A gateway with OpenRegister present, the organisation and the TOPdesk source found. + * + * @return void + */ + protected function setUp(): void { + $this->gateway = $this->getMockBuilder(ItsmFlowGateway::class) + ->disableOriginalConstructor() + ->onlyMethods(['available', 'inspect', 'saveAndPublish', 'run', 'findObject']) + ->getMock(); + $this->gateway->method('available')->willReturn(true); + $this->gateway->method('findObject')->willReturnCallback( + static function (string $register, string $schema, string $id): ?array { + if ($register === 'stackiq' && $schema === 'organization' && $id === self::ORG) { + return ['uuid' => self::ORG, 'name' => 'Gemeente Rotterdam']; + } + + if ($register === 'integriq' && $schema === 'source' && $id === 'topdesk') { + return ['uuid' => 'src-1', 'slug' => 'topdesk', 'location' => 'https://rotterdam.topdesk.net']; + } + + return null; + } + ); + $this->reports = $this->createMock(ConnectionReportService::class); + $this->settings = []; + }//end setUp() + + /** + * Every flow passes preflight: all six are saved, published, stored, and the switch goes on. + * + * @return void + */ + public function testAValidSetUpCreatesEveryFlow(): void { + $this->gateway->method('inspect')->willReturn(['blocking' => [], 'warnings' => []]); + $saved = []; + $this->gateway->expects($this->exactly(6))->method('saveAndPublish')->willReturnCallback( + static function (array $flow, ?string $uuid) use (&$saved): string { + $saved[] = [$flow, $uuid]; + return 'flow-' . count($saved); + } + ); + $this->reports->expects($this->once())->method('itsmSetUp')->with(true, $this->stringContains('TOPdesk')); + + $result = $this->service()->setUp(desk: 'topdesk', organisation: self::ORG, runAs: 'admin'); + + $this->assertTrue($result['created']); + $this->assertSame(['applications', 'relations', 'licences', 'contracts', 'outbound', 'file'], array_keys($result['flows'])); + $this->assertSame([null, null, null, null, null, null], array_column($saved, 1), 'a first set-up creates'); + $this->assertStringContainsString('https://rotterdam.topdesk.net/tas/secure', (string) json_encode($saved[4][0], JSON_UNESCAPED_SLASHES), 'the record link uses the source the admin set up'); + $this->assertStringContainsString(self::ORG, (string) json_encode($saved[0][0]), 'the import writes usages of the chosen organisation'); + $this->assertTrue($this->settings['itsm_exchange_enabled']); + $stored = json_decode((string) $this->settings['itsm_exchange'], true); + $this->assertSame('topdesk', $stored['desk']); + $this->assertSame(self::ORG, $stored['organisation']); + $this->assertSame('flow-1', $stored['flows']['applications']); + }//end testAValidSetUpCreatesEveryFlow() + + /** + * Setting up again passes the stored uuids, so the flows are updated, not added. + * + * @return void + */ + public function testASecondSetUpUpdatesTheSameFlows(): void { + $this->gateway->method('inspect')->willReturn(['blocking' => [], 'warnings' => []]); + $uuids = []; + $this->gateway->method('saveAndPublish')->willReturnCallback( + static function (array $flow, ?string $uuid) use (&$uuids): string { + $uuids[] = $uuid; + return $uuid ?? ('flow-' . count($uuids)); + } + ); + + $service = $this->service(); + $first = $service->setUp(desk: 'topdesk', organisation: self::ORG, runAs: 'admin'); + $uuids = []; + $second = $service->setUp(desk: 'topdesk', organisation: self::ORG, runAs: 'admin'); + + $this->assertSame(array_values($first['flows']), $uuids); + $this->assertSame($first['flows'], $second['flows']); + }//end testASecondSetUpUpdatesTheSameFlows() + + /** + * One flow blocked by preflight: nothing is saved, and the answer names the flow, the step and the reason. + * + * @return void + */ + public function testABlockedFlowCreatesNothing(): void { + $this->gateway->method('inspect')->willReturnCallback( + static function (array $flow): array { + if (str_contains($flow['name'], 'relations') === true) { + return ['blocking' => [['step' => 'map-all', 'reason' => 'node-config-rejected', 'detail' => 'no mapping itsm-topdesk-relation-inbound']], 'warnings' => []]; + } + + return ['blocking' => [], 'warnings' => []]; + } + ); + $this->gateway->expects($this->never())->method('saveAndPublish'); + $this->reports->expects($this->once())->method('itsmSetUp')->with(false, $this->anything()); + + $result = $this->service()->setUp(desk: 'topdesk', organisation: self::ORG, runAs: 'admin'); + + $this->assertFalse($result['created']); + $this->assertSame(['relations'], array_keys($result['blocking'])); + $this->assertStringContainsString('relations', $result['message']); + $this->assertStringContainsString('map-all', $result['message']); + $this->assertStringContainsString('node-config-rejected', $result['message']); + $this->assertArrayNotHasKey('itsm_exchange_enabled', $this->settings); + }//end testABlockedFlowCreatesNothing() + + /** + * An unknown desk, an unknown organisation or a missing integriq source is refused before any flow is built. + * + * @return void + */ + public function testWhatIsMissingIsNamed(): void { + $this->gateway->expects($this->never())->method('inspect'); + $this->gateway->expects($this->never())->method('saveAndPublish'); + $service = $this->service(); + + $this->assertStringContainsString('Unknown service desk', $service->setUp(desk: 'jira', organisation: self::ORG, runAs: 'admin')['message']); + $this->assertStringContainsString('does not exist', $service->setUp(desk: 'topdesk', organisation: 'nope', runAs: 'admin')['message']); + $this->assertStringContainsString('no source "servicenow"', $service->setUp(desk: 'servicenow', organisation: self::ORG, runAs: 'admin')['message']); + }//end testWhatIsMissingIsNamed() +}//end class diff --git a/tests/Unit/Service/ItsmFileImportServiceTest.php b/tests/Unit/Service/ItsmFileImportServiceTest.php new file mode 100644 index 00000000..1817134e --- /dev/null +++ b/tests/Unit/Service/ItsmFileImportServiceTest.php @@ -0,0 +1,167 @@ + + * @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/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-006-a-file-feeds-the-same-import + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\Stackiq\Service\Itsm\ItsmFlowGateway; +use OCA\Stackiq\Service\ItsmFileImportService; +use OCP\IAppConfig; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Asserts the reading, the refusals and the run. + */ +class ItsmFileImportServiceTest extends TestCase { + + /** + * OpenRegister's flow store. + * + * @var ItsmFlowGateway&MockObject + */ + private ItsmFlowGateway&MockObject $gateway; + + /** + * Files written by a test. + * + * @var list + */ + private array $files = []; + + /** + * A gateway double with only the real methods. + * + * @return void + */ + protected function setUp(): void { + $this->gateway = $this->getMockBuilder(ItsmFlowGateway::class) + ->disableOriginalConstructor() + ->onlyMethods(['run']) + ->getMock(); + }//end setUp() + + /** + * Remove the files a test wrote. + * + * @return void + */ + protected function tearDown(): void { + foreach ($this->files as $file) { + @unlink($file); + } + }//end tearDown() + + /** + * The service, with or without a set-up file flow. + * + * @param string|null $flow The file flow's uuid, or null when the exchange is not set up. + * + * @return ItsmFileImportService + */ + private function service(?string $flow): ItsmFileImportService { + $config = $this->createMock(IAppConfig::class); + $setting = '{}'; + if ($flow !== null) { + $setting = (string) json_encode(['desk' => 'topdesk', 'flows' => ['file' => $flow]]); + } + + $config->method('getValueString')->willReturn($setting); + + return new ItsmFileImportService(gateway: $this->gateway, appConfig: $config); + }//end service() + + /** + * Write a CSV file. + * + * @param string $content The content. + * + * @return string The path. + */ + private function csv(string $content): string { + $path = (string) tempnam(sys_get_temp_dir(), 'itsm'); + file_put_contents($path, $content); + $this->files[] = $path; + return $path; + }//end csv() + + /** + * A semicolon CSV with a byte order mark reads as rows keyed by header, empty cells left out. + * + * @return void + */ + public function testACsvReadsAsRowsKeyedByHeader(): void { + $path = $this->csv("\xEF\xBB\xBFrecordId;name;supplierName;installedVersion;status\nA-1;Zaaksysteem X;Leverancier B;4.2;In production\nA-2;Burgerzaken;Leverancier C;;Planned\n;;;;\n"); + + $rows = $this->service(flow: null)->readRows(path: $path, name: 'landscape.csv'); + + $this->assertSame( + [ + ['recordId' => 'A-1', 'name' => 'Zaaksysteem X', 'supplierName' => 'Leverancier B', 'installedVersion' => '4.2', 'status' => 'In production'], + ['recordId' => 'A-2', 'name' => 'Burgerzaken', 'supplierName' => 'Leverancier C', 'status' => 'Planned'], + ], + $rows + ); + }//end testACsvReadsAsRowsKeyedByHeader() + + /** + * A file is imported as one run of the file flow, with every row in its payload. + * + * @return void + */ + public function testAFileStartsOneRunWithItsRows(): void { + $path = $this->csv("recordId,name,supplierName\nA-1,Zaaksysteem X,Leverancier B\nA-2,Burgerzaken,Leverancier C\n"); + $this->gateway->expects($this->once())->method('run') + ->with('file-flow', ['rows' => [ + ['recordId' => 'A-1', 'name' => 'Zaaksysteem X', 'supplierName' => 'Leverancier B'], + ['recordId' => 'A-2', 'name' => 'Burgerzaken', 'supplierName' => 'Leverancier C'], + ]]) + ->willReturn('run-1'); + + $result = $this->service(flow: 'file-flow')->import(path: $path, name: 'landscape.csv'); + + $this->assertSame(['started' => true, 'run' => 'run-1', 'rows' => 2], $result); + }//end testAFileStartsOneRunWithItsRows() + + /** + * A row without a record id stops the import and is named as the spreadsheet numbers it. + * + * @return void + */ + public function testARowWithoutARecordIdIsNamed(): void { + $path = $this->csv("recordId,name\nA-1,Zaaksysteem X\n,Burgerzaken\n"); + $this->gateway->expects($this->never())->method('run'); + + $result = $this->service(flow: 'file-flow')->import(path: $path, name: 'landscape.csv'); + + $this->assertFalse($result['started']); + $this->assertStringStartsWith('Row 3 has no recordId', $result['message']); + }//end testARowWithoutARecordIdIsNamed() + + /** + * Without a set-up there is no file flow, and another file type is refused. + * + * @return void + */ + public function testNoSetUpAndAnotherTypeAreRefused(): void { + $this->gateway->expects($this->never())->method('run'); + $path = $this->csv("recordId\nA-1\n"); + + $this->assertStringContainsString('Set up the exchange first', $this->service(flow: null)->import(path: $path, name: 'a.csv')['message']); + $this->assertStringContainsString('only .csv and .xlsx', $this->service(flow: 'file-flow')->import(path: $path, name: 'a.ods')['message']); + }//end testNoSetUpAndAnotherTypeAreRefused() +}//end class diff --git a/tests/Unit/Settings/ConnectionsDeclarationTest.php b/tests/Unit/Settings/ConnectionsDeclarationTest.php index 9dad4350..df7dab01 100644 --- a/tests/Unit/Settings/ConnectionsDeclarationTest.php +++ b/tests/Unit/Settings/ConnectionsDeclarationTest.php @@ -325,6 +325,31 @@ public function testFederationAndTheFeedAreReportedOnly(): void { $this->assertArrayNotHasKey(key: 'requiredConfig', array: $byKey['email']); }//end testFederationAndTheFeedAreReportedOnly() + /** + * The service desk exchange is reported only, and its switch is the setting the set-up writes. + * + * @return void + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-007-the-cmdb-page-says-what-stackiq-is + */ + public function testTheServiceDeskExchangeIsReportedAndSwitchedByTheSetUp(): void { + $itsm = $this->connectionsByKey()['itsm']; + + $this->assertTrue(condition: $itsm['reportedOnly']); + $this->assertSame(expected: ['configKey' => 'itsm_exchange_enabled'], actual: $itsm['switch']); + $this->assertSame(expected: '/settings/admin/stackiq#section-itsm', actual: $itsm['settingsUrl']); + $this->assertArrayNotHasKey(key: 'sourceTemplate', array: $itsm, message: 'the admin picks the desk, so no single template fits'); + $this->assertSame(expected: ConnectionReportService::KEY_ITSM, actual: $itsm['key']); + + $service = (string) file_get_contents($this->root() . '/lib/Service/ItsmExchangeService.php'); + $this->assertStringContainsString(needle: "ENABLED_KEY = 'itsm_exchange_enabled'", haystack: $service); + $this->assertStringContainsString(needle: 'setValueBool(Application::APP_ID, self::ENABLED_KEY, true)', haystack: $service); + $this->assertStringContainsString(needle: 'connectionReports?->itsmSetUp(', haystack: $service); + + $section = (string) file_get_contents($this->root() . '/src/views/settings/sections/ItsmExchange.vue'); + $this->assertStringContainsString(needle: 'id="section-itsm"', haystack: $section); + }//end testTheServiceDeskExchangeIsReportedAndSwitchedByTheSetUp() + /** * Federation and the end-of-life sync are switched off through the settings stackiq reads. * diff --git a/tests/Unit/Settings/ItsmExchangeFragmentTest.php b/tests/Unit/Settings/ItsmExchangeFragmentTest.php new file mode 100644 index 00000000..3bee9762 --- /dev/null +++ b/tests/Unit/Settings/ItsmExchangeFragmentTest.php @@ -0,0 +1,172 @@ + + * @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/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-005-licences-and-contracts-carry-what-a-cmdb-needs + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use OCA\Stackiq\Service\SettingsService; +use Opis\JsonSchema\Validator; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Asserts the reference fields, the licence fields, the versions and a real licence payload. + */ +class ItsmExchangeFragmentTest extends TestCase { + + /** + * The schema versions on development before this change. + * + * @var array + */ + private const VERSIONS_BEFORE = [ + 'usage' => '1.5.3', + 'connection' => '0.3.3', + 'catalogContract' => '0.1.3', + ]; + + /** + * The register merged the way SettingsService::loadSettings merges it: every fragment, in file name order. + * + * @return array + */ + private function register(): array { + $dir = __DIR__ . '/../../../lib/Settings'; + $merged = json_decode((string) file_get_contents($dir . '/softwarecatalogus_register.json'), true); + $merge = new ReflectionMethod(SettingsService::class, 'deepMergeConfig'); + $files = glob($dir . '/register.d/*.json'); + sort($files); + foreach ($files as $file) { + $merged = $merge->invoke(null, $merged, json_decode((string) file_get_contents($file), true)); + } + + return $merged; + }//end register() + + /** + * Usage, connection and contract carry the service desk reference, written by the import, not typed. + * + * @return void + */ + public function testThreeSchemasCarryTheServiceDeskReference(): void { + $schemas = $this->register()['components']['schemas']; + foreach (array_keys(self::VERSIONS_BEFORE) as $key) { + $props = $schemas[$key]['properties']; + foreach (['serviceDeskSystem', 'serviceDeskRecordId', 'serviceDeskUrl', 'serviceDeskSyncedAt'] as $field) { + $this->assertArrayHasKey($field, $props, $key . '.' . $field); + $this->assertTrue($props[$field]['hideOnForm'], $key . '.' . $field . ' is written by the import'); + $this->assertNotEmpty($props[$field]['title']); + $this->assertNotEmpty($props[$field]['description']); + } + + $this->assertSame(['topdesk', 'servicenow', 'file'], $props['serviceDeskSystem']['enum']); + $this->assertSame('uri', $props['serviceDeskUrl']['format']); + $this->assertSame('date-time', $props['serviceDeskSyncedAt']['format']); + } + + $usage = $schemas['usage']['properties']; + $this->assertSame('string', $usage['installedVersion']['type']); + $this->assertSame('date-time', $usage['publicationDate']['format']); + }//end testThreeSchemasCarryTheServiceDeskReference() + + /** + * Every changed schema moved its version up, so the import applies it. + * + * @return void + */ + public function testEveryChangedSchemaMovedItsVersionUp(): void { + $schemas = $this->register()['components']['schemas']; + foreach (self::VERSIONS_BEFORE as $key => $before) { + $this->assertTrue(version_compare($schemas[$key]['version'], $before, '>'), $key . ' is ' . $schemas[$key]['version']); + } + }//end testEveryChangedSchemaMovedItsVersionUp() + + /** + * A licence for an application in use, with no catalogue service, validates against the merged contract schema. + * + * @return void + */ + public function testALicenceWithoutAServiceValidates(): void { + $schema = $this->register()['components']['schemas']['catalogContract']; + $this->assertNotContains('service', $schema['required']); + $this->assertArrayNotHasKey('required', $schema['properties']['service']); + + $payload = [ + 'usage' => '5b2c0f4e-1111-4a9b-8c1d-9f0e1a2b3c4d', + 'supplier' => '5b2c0f4e-2222-4a9b-8c1d-9f0e1a2b3c4d', + 'contractNumber' => 'LIC-2026-014', + 'vendorReference' => 'TD-AGR-88213', + 'contractType' => 'Licence', + 'status' => 'Active', + 'startDate' => '2026-01-01T00:00:00+01:00', + 'endDate' => '2028-12-31T00:00:00+01:00', + 'cost' => 12000.0, + 'costPeriod' => 'Annually', + 'currency' => 'EUR', + 'licenceMetric' => 'Per named user', + 'licencesBought' => 50, + 'serviceDeskSystem' => 'topdesk', + 'serviceDeskRecordId' => 'a8c2d1f0-0001', + 'serviceDeskUrl' => 'https://desk.example.nl/tas/secure/contract?unid=a8c2d1f0-0001', + 'serviceDeskSyncedAt' => '2026-10-01T02:00:00+02:00', + ]; + + $result = (new Validator())->validate(json_decode((string) json_encode($payload)), json_decode((string) json_encode($this->validatable(schema: $schema)))); + $this->assertTrue($result->isValid(), (string) json_encode($result->error()?->args())); + + $bad = $payload; + $bad['currency'] = 'euro'; + $result = (new Validator())->validate(json_decode((string) json_encode($bad)), json_decode((string) json_encode($this->validatable(schema: $schema)))); + $this->assertFalse($result->isValid(), 'a currency that is not an ISO 4217 code is refused'); + + unset($payload['usage']); + $result = (new Validator())->validate(json_decode((string) json_encode($payload)), json_decode((string) json_encode($this->validatable(schema: $schema)))); + $this->assertFalse($result->isValid(), 'a contract still needs its application in use'); + }//end testALicenceWithoutAServiceValidates() + + /** + * The schema in the shape a JSON Schema validator reads: relations are uuids, OpenRegister's own keys dropped. + * + * @param array $schema The register schema. + * + * @return array The validatable schema. + */ + private function validatable(array $schema): array { + $props = []; + foreach ($schema['properties'] as $name => $prop) { + if (isset($prop['$ref']) === true || isset($prop['items']['$ref']) === true) { + $props[$name] = ['type' => ['string', 'object', 'array', 'null']]; + continue; + } + + $keep = []; + foreach (['type', 'enum', 'format', 'pattern', 'minimum', 'maximum', 'maxLength'] as $key) { + if (isset($prop[$key]) === true) { + $keep[$key] = $prop[$key]; + } + } + + if (($keep['format'] ?? '') === 'date-time' || ($keep['format'] ?? '') === 'date') { + unset($keep['format']); + } + + $props[$name] = $keep; + } + + return ['type' => 'object', 'required' => $schema['required'], 'properties' => $props]; + }//end validatable() +}//end class diff --git a/tests/Unit/Settings/ItsmFlowTemplatesTest.php b/tests/Unit/Settings/ItsmFlowTemplatesTest.php new file mode 100644 index 00000000..07209155 --- /dev/null +++ b/tests/Unit/Settings/ItsmFlowTemplatesTest.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 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md#requirement-req-itx-002-the-import-creates-and-updates-stackiq-records-and-never-duplicates-them + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use OCA\Stackiq\Service\Itsm\ItsmFlowGateway; +use OCA\Stackiq\Service\ItsmExchangeService; +use OCA\Stackiq\Service\SettingsService; +use OCP\IAppConfig; +use OCP\IURLGenerator; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * Asserts the shape, the field names and the hash inputs of every filled flow. + */ +class ItsmFlowTemplatesTest extends TestCase { + + /** + * Node types OpenRegister registers (lib/Listener/FlowNodeRegistrationListener.php) + * and integriq registers (lib/Flow/FlowNodeListener.php), on development 2026-10-01. + * + * @var list + */ + private const KNOWN_TYPES = [ + 'openregister.trigger-object', + 'openregister.trigger-schedule', + 'openregister.trigger-manual', + 'openregister.object-read', + 'openregister.object-write', + 'openregister.filter', + 'openregister.explode', + 'openregister.set-fields', + 'openregister.end', + 'openconnector.source-paginate', + 'openconnector.apply-mapping', + 'openconnector.contract', + 'openconnector.contract-commit', + 'openconnector.source-call', + ]; + + /** + * The stackiq-owned fields of an application in use (design D2). + * + * @var list + */ + private const STACKIQ_OWNED = ['bbnLevel', 'timeClassification', 'publicationDate', 'licencesBought', 'licencesInUse', 'licenceMetric', 'contractNumber', 'contractEndDate']; + + /** + * The service-desk-owned fields of an application in use (design D2). + * + * @var list + */ + private const SOURCE_OWNED = ['recordId', 'recordUrl', 'name', 'supplierName', 'installedVersion', 'status', 'description']; + + /** + * Every flow, filled for one desk. + * + * @param string $desk The desk. + * + * @return array> + */ + private function flows(string $desk): array { + $urls = $this->createMock(IURLGenerator::class); + $urls->method('getAbsoluteURL')->willReturn('https://cloud.example.nl/index.php/apps/stackiq'); + + $service = new ItsmExchangeService( + gateway: $this->createMock(ItsmFlowGateway::class), + appConfig: $this->createMock(IAppConfig::class), + urlGenerator: $urls, + logger: $this->createMock(LoggerInterface::class) + ); + + return $service->buildFlows(desk: $desk, organisation: '0f6c3a8e-1111-4c2b-9d6a-2a1b3c4d5e6f', runAs: 'admin', location: 'https://desk.example.nl/'); + }//end flows() + + /** + * The merged register's schemas. + * + * @return array> + */ + private function schemas(): array { + $dir = __DIR__ . '/../../../lib/Settings'; + $merged = json_decode((string) file_get_contents($dir . '/softwarecatalogus_register.json'), true); + $merge = new ReflectionMethod(SettingsService::class, 'deepMergeConfig'); + $files = glob($dir . '/register.d/*.json'); + sort($files); + foreach ($files as $file) { + $merged = $merge->invoke(null, $merged, json_decode((string) file_get_contents($file), true)); + } + + return $merged['components']['schemas']; + }//end schemas() + + /** + * Nodes by id. + * + * @param array $flow The flow. + * + * @return array> + */ + private function nodes(array $flow): array { + $byId = []; + foreach ($flow['nodes'] as $node) { + $byId[$node['id']] = $node; + } + + return $byId; + }//end nodes() + + /** + * Every flow is filled, uses known node types, and its edges join nodes that exist. + * + * @return void + */ + public function testEveryFlowIsFilledAndWellFormed(): void { + foreach (['topdesk', 'servicenow'] as $desk) { + $flows = $this->flows(desk: $desk); + $this->assertSame(['applications', 'relations', 'licences', 'contracts', 'outbound', 'file'], array_keys($flows)); + foreach ($flows as $key => $flow) { + $label = $desk . '/' . $key; + $this->assertDoesNotMatchRegularExpression('/%[A-Z_]+%/', (string) json_encode($flow), $label . ' has an unfilled placeholder'); + $this->assertSame('stackiq', $flow['app']); + $nodes = $this->nodes(flow: $flow); + $this->assertCount(count($flow['nodes']), $nodes, $label . ' has duplicate node ids'); + + $incoming = []; + foreach ($flow['edges'] as $edge) { + $this->assertArrayNotHasKey('type', $edge, $label . ': a step lives on a node, never on an edge'); + $this->assertArrayHasKey($edge['from'], $nodes, $label . ' edge from'); + $this->assertArrayHasKey($edge['to'], $nodes, $label . ' edge to'); + $incoming[$edge['to']] = true; + } + + $ends = 0; + foreach ($nodes as $id => $node) { + $this->assertContains($node['type'], self::KNOWN_TYPES, $label . ' node ' . $id); + if (str_starts_with($node['type'], 'openregister.trigger-') === false) { + $this->assertArrayHasKey($id, $incoming, $label . ' node ' . $id . ' is unreachable'); + } + + if ($node['type'] === 'openregister.end') { + $ends++; + } + } + + $this->assertGreaterThanOrEqual(1, $ends, $label . ' has an end'); + }//end foreach + }//end foreach + }//end testEveryFlowIsFilledAndWellFormed() + + /** + * Every field a write sets and every filter a read uses exists on the schema it names. + * + * @return void + */ + public function testEveryWrittenFieldExistsOnItsSchema(): void { + $schemas = $this->schemas(); + $writes = 0; + foreach ($this->flows(desk: 'topdesk') as $key => $flow) { + foreach ($flow['nodes'] as $node) { + $config = $node['config']; + if (in_array($node['type'], ['openregister.object-write', 'openregister.object-read'], true) === false) { + continue; + } + + $this->assertSame('stackiq', $config['register'], $key . '/' . $node['id']); + $this->assertArrayHasKey($config['schema'], $schemas, $key . '/' . $node['id']); + $props = $schemas[$config['schema']]['properties']; + $named = array_keys($config['fields'] ?? []); + foreach (($config['match'] ?? []) as $pair) { + $named[] = $pair['property']; + } + + $named = array_merge($named, array_keys($config['filters'] ?? [])); + foreach ($named as $field) { + if ($field === '@self' || str_starts_with($field, '@self.') === true) { + continue; + } + + $this->assertArrayHasKey($field, $props, $key . '/' . $node['id'] . ' names ' . $config['schema'] . '.' . $field); + } + + $writes++; + }//end foreach + }//end foreach + + $this->assertGreaterThanOrEqual(15, $writes); + }//end testEveryWrittenFieldExistsOnItsSchema() + + /** + * The import hashes only what the service desk owns, so an export's change to a stackiq field never comes back as a write. + * + * @return void + */ + public function testTheImportHashesTheOwnershipFilteredRecord(): void { + foreach ($this->flows(desk: 'topdesk') as $key => $flow) { + if ($key === 'outbound') { + continue; + } + + $nodes = $this->nodes(flow: $flow); + $decide = $nodes['decide']['config']; + $owned = $nodes['map-owned']['config']; + $this->assertSame('owned', $owned['output'], $key); + $this->assertSame('inbound', $owned['ownership'], $key); + $this->assertSame('record.recordId', $owned['exists'], $key . ': exists is always set, so the projection is always the source-owned one'); + $this->assertSame($nodes['map-all']['config']['mapping'], $owned['mapping'], $key . ': both maps use the same preset'); + $this->assertSame('owned', $decide['hashPosition'], $key); + $this->assertSame('owned', $nodes['commit']['config']['targetHashPosition'], $key); + } + + $applications = $this->nodes(flow: $this->flows(desk: 'topdesk')['applications']); + foreach ($applications['usage-update']['config']['fields'] as $field => $value) { + $this->assertDoesNotMatchRegularExpression('/record\./', (string) $value, 'an update writes ' . $field . ' from the owned projection only'); + } + }//end testTheImportHashesTheOwnershipFilteredRecord() + + /** + * The export hashes only what stackiq owns, so an import's write never makes a call. + * + * @return void + */ + public function testTheExportHashesOnlyStackiqOwnedFields(): void { + $nodes = $this->nodes(flow: $this->flows(desk: 'servicenow')['outbound']); + $this->assertSame('stackiqOwned', $nodes['decide']['config']['hashPosition']); + $this->assertSame('usage.uuid', $nodes['decide']['config']['idPosition']); + + $hashed = []; + foreach (array_keys($nodes['owned']['config']['set']) as $path) { + $this->assertStringStartsWith('stackiqOwned.', $path); + $hashed[] = substr($path, strlen('stackiqOwned.')); + } + + $this->assertSame(self::STACKIQ_OWNED, $hashed); + $this->assertSame([], array_intersect($hashed, self::SOURCE_OWNED)); + + $this->assertSame('outbound', $nodes['map']['config']['ownership']); + $this->assertSame('usage.recordId', $nodes['map']['config']['exists']); + $this->assertSame('/api/now/table/cmdb_ci_appl', $nodes['call-create']['config']['endpoint']); + $this->assertSame('{{ response.body.result.sys_id }}', $nodes['link-back']['config']['fields']['serviceDeskRecordId']); + $this->assertStringStartsWith('https://desk.example.nl/nav_to.do', $nodes['link-back']['config']['fields']['serviceDeskUrl']); + }//end testTheExportHashesOnlyStackiqOwnedFields() + + /** + * The imports run on a schedule, the export on usage changes, the file import by hand. + * + * @return void + */ + public function testEachFlowStartsTheWayTheDesignSays(): void { + $flows = $this->flows(desk: 'topdesk'); + foreach (['applications', 'relations', 'licences', 'contracts'] as $key) { + $this->assertSame('schedule', $flows[$key]['trigger'], $key); + $this->assertSame(ItsmExchangeService::CRON, $flows[$key]['cron'], $key); + $start = $this->nodes(flow: $flows[$key])['start']; + $this->assertSame('openregister.trigger-schedule', $start['type']); + $this->assertSame('admin', $start['config']['runAs']); + $this->assertSame('itsm-topdesk-' . $key, $this->nodes(flow: $flows[$key])['pages']['config']['synchronization']); + } + + $this->assertSame('Licence', $this->nodes(flow: $flows['licences'])['flags']['config']['compute']['contractType']['or'][1]); + $this->assertSame('itsm-topdesk-licence-inbound', $this->nodes(flow: $flows['licences'])['map-all']['config']['mapping']); + + $events = []; + foreach ($flows['outbound']['nodes'] as $node) { + if ($node['type'] === 'openregister.trigger-object') { + $events[] = $node['config']['event']; + $this->assertSame('usage', $node['config']['schema']); + } + } + + $this->assertSame(['object.created', 'object.updated'], $events); + $this->assertSame('openregister.trigger-manual', $this->nodes(flow: $flows['file'])['start']['type']); + $this->assertSame('itsm-file-application-inbound', $this->nodes(flow: $flows['file'])['map-all']['config']['mapping']); + $this->assertSame('file', $this->nodes(flow: $flows['file'])['usage-create']['config']['fields']['serviceDeskSystem']); + }//end testEachFlowStartsTheWayTheDesignSays() +}//end class From 854722540202335375d057b21534e70417bedbda Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 1 Oct 2026 08:08:20 +0200 Subject: [PATCH 04/16] feat(itsm): CMDB page, service desk exchange settings and service desk column The CMDB page says what stackiq records and what it does not, shows the exchange and takes a file import from admins. The admin section sets the exchange up for TOPdesk or ServiceNow and shows what OpenRegister refused, word for word. Applications in use gets a Service desk column and the usage page a Service desk widget. English and Dutch. --- l10n/en.js | 54 ++- l10n/en.json | 54 ++- l10n/nl.js | 54 ++- l10n/nl.json | 54 ++- .../specs/itsm-exchange/spec.md | 5 +- src/customComponents.js | 8 + src/manifest.d/cmdb.json | 21 ++ src/manifest.d/usages.json | 8 +- src/views/cmdb/CmdbOverview.vue | 320 ++++++++++++++++++ src/views/settings/StackiqSettings.vue | 5 + src/views/settings/sections/ItsmExchange.vue | 258 ++++++++++++++ tests/e2e/workflows/itsm-exchange.spec.ts | 111 ++++++ 12 files changed, 943 insertions(+), 9 deletions(-) create mode 100644 src/manifest.d/cmdb.json create mode 100644 src/views/cmdb/CmdbOverview.vue create mode 100644 src/views/settings/sections/ItsmExchange.vue create mode 100644 tests/e2e/workflows/itsm-exchange.spec.ts diff --git a/l10n/en.js b/l10n/en.js index f81da82a..4dd4f956 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -901,7 +901,59 @@ OC.L10N.register( "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." + "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." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 21f9325c..120d2712 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -900,6 +900,58 @@ "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." + "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." } } diff --git a/l10n/nl.js b/l10n/nl.js index 5d5db0a5..22a91bed 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -971,7 +971,59 @@ OC.L10N.register( "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." + "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." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index f84eed6e..120f540e 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -970,6 +970,58 @@ "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." + "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." } } diff --git a/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md b/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md index 7493b6e6..fe0c19f4 100644 --- a/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md +++ b/openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md @@ -106,7 +106,7 @@ An administrator SHALL be able to import applications from a CSV or XLSX file wh ### Requirement: REQ-ITX-007 The CMDB page says what stackiq is -Stackiq SHALL have a CMDB page that names what it records (applications, components, connections, licences and contracts) and what it does not (hardware, network discovery, tickets), links to each list, shows the service desk exchange and the outcome of its last run, and offers the file import to admins. A usage SHALL show its service desk link, and Applications in use SHALL have a Service desk column. The Integrations page SHALL list the service desk exchange. +Stackiq SHALL have a CMDB page that names what it records (applications, components, connections, licences and contracts) and what it does not (hardware, network discovery, tickets), links to each list, shows the service desk exchange and the outcome of its last run, and offers the file import to admins. A usage SHALL show its service desk reference and link on its page, and Applications in use SHALL have a Service desk column. The Integrations page SHALL list the service desk exchange. #### Scenario: An information manager opens the CMDB page @e2e tests/e2e/workflows/itsm-exchange.spec.ts @@ -121,4 +121,5 @@ Stackiq SHALL have a CMDB page that names what it records (applications, compone - **GIVEN** a usage linked to service desk record A-123 - **WHEN** the information manager opens Applications in use -- **THEN** the Service desk column shows A-123 and opens the record in the service desk +- **THEN** the Service desk column shows A-123 +- **AND** the usage page shows the link that opens the record in the service desk diff --git a/src/customComponents.js b/src/customComponents.js index 02650884..326d1c25 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -29,6 +29,7 @@ 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 CmdbOverview from './views/cmdb/CmdbOverview.vue' import ComplianceMatrixView from './views/ComplianceMatrixView.vue' import FacetedCatalogIndexView from './views/FacetedCatalogIndexView.vue' import KwetsbaarhedenView from './views/KwetsbaarhedenView.vue' @@ -140,6 +141,13 @@ export default { // until the lib grows a declarative aggregation/rollup widget. LicensePostureView, + // --- CMDB overview (sharing-itsm-exchange). --- + // Says what stackiq records as a CMDB and what it does not, links to the + // four lists, shows the service desk exchange from GET /api/itsm/status and + // takes a file import (POST /api/itsm/import, admin only). A mixed page of + // prose, a status and an upload form; no built-in page type holds that. + CmdbOverview, + // --- Lib gap: live GEMMA-dimension facet counts on the module/dienst index pages. --- // CnIndexPage's own embedded `sidebar.enabled` facet machinery treats every // active-filter key as a directly-filterable schema field and applies it diff --git a/src/manifest.d/cmdb.json b/src/manifest.d/cmdb.json new file mode 100644 index 00000000..ee1d4ea4 --- /dev/null +++ b/src/manifest.d/cmdb.json @@ -0,0 +1,21 @@ +{ + "$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). Custom because it mixes prose, a status and an upload form; see customComponents.js.", + "menu": [ + { + "id": "Modules", + "children": [ + { "id": "Cmdb", "label": "CMDB", "icon": "Sitemap", "route": "Cmdb", "order": 1 } + ] + } + ], + "pages": [ + { + "id": "Cmdb", + "route": "/cmdb", + "type": "custom", + "title": "CMDB", + "component": "CmdbOverview" + } + ] +} diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json index fac1de13..1ff71f97 100644 --- a/src/manifest.d/usages.json +++ b/src/manifest.d/usages.json @@ -19,7 +19,7 @@ "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"], + "columns": ["module", "moduleVersion", "status", "businessOwner", "technicalOwner", "timeClassification", "serviceDeskRecordId"], "filterMenu": true, "quickFilters": [ { "label": "All", "filter": {}, "default": true }, @@ -44,7 +44,8 @@ "_note": "A usage is read for what runs where and who owns it (lifecycle-application-value-assessment added the value assessment section with the scores and the suggested TIME class, and the risk signals after it): data 8 wide (application, organisation, version, status, owners, phase dates, cloud model, annotation), documents 4 wide at the right (DPIA, contract, processing agreement), then the related panel. Status transitions come from the schema's x-openregister-lifecycle.", "lifecycleActions": { "field": "status" }, "widgets": [ - { "id": "gb-data", "type": "data", "title": "Application in use", "icon": "OfficeBuilding", "content": { "columns": 2, "include": [ "module", "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner", "startDateAcquisition", "startDatePlanned", "startDateInProduction", "startDateOutPhasing", "startDateOutPhased", "cloudDienstverleningsmodel", "interneAnnotation" ] } }, + { "id": "gb-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" } @@ -53,7 +54,8 @@ { "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": "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 } diff --git a/src/views/cmdb/CmdbOverview.vue b/src/views/cmdb/CmdbOverview.vue new file mode 100644 index 00000000..99fce90c --- /dev/null +++ b/src/views/cmdb/CmdbOverview.vue @@ -0,0 +1,320 @@ + + + + + + + diff --git a/src/views/settings/StackiqSettings.vue b/src/views/settings/StackiqSettings.vue index 00b3c6d8..f9ac8906 100644 --- a/src/views/settings/StackiqSettings.vue +++ b/src/views/settings/StackiqSettings.vue @@ -116,6 +116,9 @@ + + + @@ -135,6 +138,7 @@ 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' @@ -165,6 +169,7 @@ export default defineComponent({ ModerationQueue, FederationSettings, EolSyncSettings, + ItsmExchange, AlwaysVisibleSection, Web, }, diff --git a/src/views/settings/sections/ItsmExchange.vue b/src/views/settings/sections/ItsmExchange.vue new file mode 100644 index 00000000..f0487b1a --- /dev/null +++ b/src/views/settings/sections/ItsmExchange.vue @@ -0,0 +1,258 @@ + + + + + + + diff --git a/tests/e2e/workflows/itsm-exchange.spec.ts b/tests/e2e/workflows/itsm-exchange.spec.ts new file mode 100644 index 00000000..adf5d693 --- /dev/null +++ b/tests/e2e/workflows/itsm-exchange.spec.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Service desk exchange, the part a browser sees: the CMDB page says what + * stackiq records and what it does not, and Applications in use shows the + * service desk record of a usage. + * + * Seeds one supplier, one application and one usage carrying this run's + * RUN_ID and a service desk reference, through the objects API, and removes + * exactly those rows afterwards. The flows themselves run server-side; their + * set-up is covered by tests/Unit/Service/ItsmExchangeServiceTest.php and the + * live run recorded in the pull request. + * + * @spec openspec/changes/sharing-itsm-exchange/specs/itsm-exchange/spec.md + */ +import type { APIRequestContext } from '@playwright/test' +import type { VoorzieningenConfig } from './_fixtures.ts' + +import { expect, test } from '@playwright/test' +import { + createObject, + deleteObject, + newApiContext, + resolveConfig, + RUN_ID, +} from './_fixtures.ts' +import { dismissSupportDialog, gotoAppRoute } from './_ui.ts' + +let apiCtx: APIRequestContext +let cfg: VoorzieningenConfig +const seeded: Array<[string, string]> = [] +const recordId = `${RUN_ID}-A-123` + +/** + * Create a row and remember it for cleanup. + * + * @param schema The schema slug. + * @param data The object. + * @return The new id. + */ +async function seed(schema: string, data: Record): Promise { + const id = await createObject(apiCtx, cfg.register, schema, data) + seeded.push([schema, id]) + return id +} + +test.beforeAll(async () => { + apiCtx = await newApiContext() + cfg = await resolveConfig(apiCtx) + const supplier = await seed('organization', { + name: `${RUN_ID} supplier`, + type: 'Supplier', + }) + const consumer = await seed('organization', { + name: `${RUN_ID} municipality`, + type: 'Municipality', + }) + const module = await seed('module', { + name: `${RUN_ID} application`, + provider: supplier, + }) + await seed('usage', { + module, + consumer, + status: 'In production', + serviceDeskSystem: 'topdesk', + serviceDeskRecordId: recordId, + serviceDeskUrl: `https://desk.example.nl/tas/secure/assetmgmt/card.html?unid=${recordId}`, + }) +}) + +test.afterAll(async () => { + if (!apiCtx) return + for (const [schema, id] of seeded.reverse()) { + await deleteObject(apiCtx, cfg.register, schema, id) + } + await apiCtx.dispose() +}) + +// @e2e itsm-exchange::an-information-manager-opens-the-cmdb-page +test('the CMDB page names what stackiq records and what it does not', async ({ + page, +}) => { + await gotoAppRoute(page, '/cmdb') + await dismissSupportDialog(page) + const records = page.getByTestId('cmdb-records') + await expect(records).toBeVisible({ timeout: 30000 }) + for (const label of [ + 'Applications in use', + 'Applications and their components', + 'Connections', + 'Licences and contracts', + ]) { + await expect(records.getByRole('link', { name: label })).toBeVisible() + } + await expect(page.getByTestId('cmdb-not-recorded')).toContainText( + 'does not discover hardware', + ) + await expect(page.getByTestId('cmdb-exchange')).toBeVisible() + await records.getByRole('link', { name: 'Licences and contracts' }).click() + await expect(page).toHaveURL(/\/contracten/) +}) + +// @e2e itsm-exchange::a-service-desk-employee-finds-the-catalogue-entry-and-back +test('Applications in use shows the service desk record of a usage', async ({ + page, +}) => { + await gotoAppRoute(page, '/gebruik') + await dismissSupportDialog(page) + await expect(page.getByText(recordId).first()).toBeVisible({ timeout: 30000 }) +}) From 4ed9a3ddb65df2ae06bf43fbf501180ff281438d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 1 Oct 2026 08:11:57 +0200 Subject: [PATCH 05/16] feat(itsm): follow integriq's desk interface: desk context, endpoints, TOPdesk relations per application Imports put _desk.baseUrl on the record before mapping; the export puts the base and the TOPdesk asset template on usage and sends the mapped object with bodyFrom. TOPdesk lists asset links one asset at a time, so its relations flow reads them per imported application. --- l10n/en.js | 4 +- l10n/en.json | 4 +- l10n/nl.js | 4 +- l10n/nl.json | 4 +- lib/Controller/ItsmExchangeController.php | 5 +- lib/Service/ItsmExchangeService.php | 105 ++++-- .../flows/itsm-file-applications.json | 16 +- .../flows/itsm-inbound-applications.json | 16 +- .../flows/itsm-inbound-contracts.json | 16 +- ...tsm-inbound-relations-per-application.json | 347 ++++++++++++++++++ .../flows/itsm-inbound-relations.json | 16 +- .../flows/itsm-outbound-applications.json | 10 +- .../changes/sharing-itsm-exchange/design.md | 6 +- src/views/settings/sections/ItsmExchange.vue | 26 +- .../Unit/Service/ItsmExchangeServiceTest.php | 2 +- tests/Unit/Settings/ItsmFlowTemplatesTest.php | 21 +- 16 files changed, 555 insertions(+), 47 deletions(-) create mode 100644 lib/Settings/flows/itsm-inbound-relations-per-application.json diff --git a/l10n/en.js b/l10n/en.js index 4dd4f956..a2dd9df9 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -953,7 +953,9 @@ OC.L10N.register( "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." + "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." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 120d2712..fbee94c6 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -952,6 +952,8 @@ "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." + "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." } } diff --git a/l10n/nl.js b/l10n/nl.js index 22a91bed..ea47b67c 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1023,7 +1023,9 @@ OC.L10N.register( "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." + "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." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 120f540e..f340f06b 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1022,6 +1022,8 @@ "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." + "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." } } diff --git a/lib/Controller/ItsmExchangeController.php b/lib/Controller/ItsmExchangeController.php index a5c347d4..e1ed8b49 100644 --- a/lib/Controller/ItsmExchangeController.php +++ b/lib/Controller/ItsmExchangeController.php @@ -105,19 +105,20 @@ public function config(): JSONResponse { * * @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. * * @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 = ''): JSONResponse { + 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()); + $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); } diff --git a/lib/Service/ItsmExchangeService.php b/lib/Service/ItsmExchangeService.php index aedc9a44..10fcc305 100644 --- a/lib/Service/ItsmExchangeService.php +++ b/lib/Service/ItsmExchangeService.php @@ -72,32 +72,38 @@ class ItsmExchangeService { /** * One profile per service desk: integriq's slugs and the outbound endpoints. * - * The slugs are the ones integriq's change connectors-service-desk-templates - * ships. The endpoints are what the outbound source call uses; `%LOCATION%` - * becomes the source's base address. + * 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', - 'createEndpoint' => '/tas/api/assetmgmt/assets', - 'createMethod' => 'POST', - 'updateEndpoint' => '/tas/api/assetmgmt/assets/{{ usage.recordId }}', - 'updateMethod' => 'PATCH', - 'responseId' => 'id', - 'recordUrl' => '%LOCATION%/tas/secure/assetmgmt/card.html?unid={{ response.body.id }}', + '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', - 'createEndpoint' => '/api/now/table/cmdb_ci_appl', - 'createMethod' => 'POST', - 'updateEndpoint' => '/api/now/table/cmdb_ci_appl/{{ usage.recordId }}', - 'updateMethod' => 'PATCH', - 'responseId' => 'result.sys_id', - 'recordUrl' => '%LOCATION%/nav_to.do?uri=cmdb_ci_appl.do?sys_id={{ response.body.result.sys_id }}', + '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 }}', ], ]; @@ -166,7 +172,8 @@ public function status(): array { * @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 base address, for record links. + * @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. * @@ -174,13 +181,14 @@ public function status(): array { * * @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): array { + 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; @@ -206,14 +214,22 @@ public function buildFlows(string $desk, string $organisation, string $runAs, st 'UPDATE_ENDPOINT' => $profile['updateEndpoint'], 'UPDATE_METHOD' => $profile['updateMethod'], 'RESPONSE_ID' => $profile['responseId'], - 'RECORD_URL' => str_replace('%LOCATION%', rtrim($location, '/'), $profile['recordUrl']), + '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'; } - $flows[$key] = self::fill(value: $this->template(file: $flow['template']), values: $values); + $template = $flow['template']; + if ($key === 'relations') { + $template = $profile['relationsTemplate']; + } + + $flows[$key] = self::fill(value: $this->template(file: $template), values: $values); }//end foreach return $flows; @@ -228,12 +244,13 @@ public function buildFlows(string $desk, string $organisation, string $runAs, st * @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): array { + public function setUp(string $desk, string $organisation, string $runAs, string $templateId = ''): array { if ($this->gateway->available() === false) { return $this->refuse(message: 'OpenRegister\'s flow engine is not available, so no exchange can be set up.'); } @@ -251,7 +268,7 @@ public function setUp(string $desk, string $organisation, string $runAs): array return $this->refuse(message: 'Integriq has no source "' . self::DESKS[$desk]['source'] . '". Add the ' . self::DESKS[$desk]['label'] . ' source in integriq first.'); } - $flows = $this->buildFlows(desk: $desk, organisation: $organisation, runAs: $runAs, location: (string) ($source['location'] ?? '')); + $flows = $this->buildFlows(desk: $desk, organisation: $organisation, runAs: $runAs, location: (string) ($source['location'] ?? ''), templateId: $templateId); $blocking = []; foreach ($flows as $key => $flow) { $findings = $this->gateway->inspect(flow: $flow); @@ -293,8 +310,11 @@ public function setUp(string $desk, string $organisation, string $runAs): array /** * Replace every `%KEY%` placeholder in the strings of a value. * - * @param mixed $value The template, or a part of it. - * @param array $values The placeholder values, by key without the percent signs. + * 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. * @@ -314,16 +334,45 @@ public static function fill(mixed $value, array $values): mixed { 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[] = $text; + $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. + */ + 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. * diff --git a/lib/Settings/flows/itsm-file-applications.json b/lib/Settings/flows/itsm-file-applications.json index ba3ac674..eb51deed 100644 --- a/lib/Settings/flows/itsm-file-applications.json +++ b/lib/Settings/flows/itsm-file-applications.json @@ -20,6 +20,15 @@ "as": "source" } }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, { "id": "map-all", "type": "openconnector.apply-mapping", @@ -241,8 +250,13 @@ "to": "each" }, { - "id": "e-each-map-all", + "id": "e-each-desk", "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", "to": "map-all" }, { diff --git a/lib/Settings/flows/itsm-inbound-applications.json b/lib/Settings/flows/itsm-inbound-applications.json index f56f27c0..d1958d09 100644 --- a/lib/Settings/flows/itsm-inbound-applications.json +++ b/lib/Settings/flows/itsm-inbound-applications.json @@ -33,6 +33,15 @@ "as": "source" } }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, { "id": "map-all", "type": "openconnector.apply-mapping", @@ -259,8 +268,13 @@ "to": "each" }, { - "id": "e-each-map-all", + "id": "e-each-desk", "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", "to": "map-all" }, { diff --git a/lib/Settings/flows/itsm-inbound-contracts.json b/lib/Settings/flows/itsm-inbound-contracts.json index bf351cd1..1cc4c237 100644 --- a/lib/Settings/flows/itsm-inbound-contracts.json +++ b/lib/Settings/flows/itsm-inbound-contracts.json @@ -33,6 +33,15 @@ "as": "source" } }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, { "id": "map-all", "type": "openconnector.apply-mapping", @@ -291,8 +300,13 @@ "to": "each" }, { - "id": "e-each-map-all", + "id": "e-each-desk", "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", "to": "map-all" }, { 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 00000000..4a444cb2 --- /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 index f0827e1e..c3df3153 100644 --- a/lib/Settings/flows/itsm-inbound-relations.json +++ b/lib/Settings/flows/itsm-inbound-relations.json @@ -33,6 +33,15 @@ "as": "source" } }, + { + "id": "desk", + "type": "openregister.set-fields", + "config": { + "set": { + "source._desk.baseUrl": "%DESK_BASE%" + } + } + }, { "id": "map-all", "type": "openconnector.apply-mapping", @@ -245,8 +254,13 @@ "to": "each" }, { - "id": "e-each-map-all", + "id": "e-each-desk", "from": "each", + "to": "desk" + }, + { + "id": "e-desk-map-all", + "from": "desk", "to": "map-all" }, { diff --git a/lib/Settings/flows/itsm-outbound-applications.json b/lib/Settings/flows/itsm-outbound-applications.json index 9e7bb7b0..b5cc6203 100644 --- a/lib/Settings/flows/itsm-outbound-applications.json +++ b/lib/Settings/flows/itsm-outbound-applications.json @@ -135,7 +135,9 @@ "usage.licencesInUse": "{{ contractRead.0.licencesInUse }}", "usage.licenceMetric": "{{ contractRead.0.licenceMetric }}", "usage.contractNumber": "{{ contractRead.0.contractNumber }}", - "usage.contractEndDate": "{{ contractRead.0.endDate }}" + "usage.contractEndDate": "{{ contractRead.0.endDate }}", + "usage._desk.baseUrl": "%DESK_BASE%", + "usage._desk.templateId": "%TEMPLATE_ID%" } } }, @@ -280,7 +282,8 @@ "endpoint": "%CREATE_ENDPOINT%", "method": "%CREATE_METHOD%", "bodyFrom": "send", - "output": "response" + "output": "response", + "query": "%CALL_QUERY%" } }, { @@ -328,7 +331,8 @@ "endpoint": "%UPDATE_ENDPOINT%", "method": "%UPDATE_METHOD%", "bodyFrom": "send", - "output": "response" + "output": "response", + "query": "%CALL_QUERY%" } }, { diff --git a/openspec/changes/sharing-itsm-exchange/design.md b/openspec/changes/sharing-itsm-exchange/design.md index 84eaf242..2da740fa 100644 --- a/openspec/changes/sharing-itsm-exchange/design.md +++ b/openspec/changes/sharing-itsm-exchange/design.md @@ -67,6 +67,10 @@ Relations read the desk's relation records, look up both ends by `serviceDeskRec Licences and contracts look up the usage by `applicationRecordId`, upsert `catalogContract` (match `serviceDeskRecordId`) with every field on create and only the service desk reference on update, and link `usage` and `supplier`. +**Desk context.** A mapping only sees its input, so each import puts `_desk.baseUrl` (the tenant's scheme and host, from the source's location) on the record before mapping, and the outbound flow puts `_desk.baseUrl` and `_desk.templateId` (TOPdesk needs an asset template to create an asset; the admin gives its id at set-up) on `usage`. That is integriq's convention in `connectors-service-desk-templates`. + +**TOPdesk relations.** TOPdesk has no list of all asset links, only the links of one asset (`GET /assetmgmt/assetLinks?sourceId=`). Its relations flow (`itsm-inbound-relations-per-application.json`) reads the usages that came from TOPdesk, asks for each one's links, and then runs the same mapping, contract and write as ServiceNow's, whose `cmdb_rel_ci` table is read page by page. + ## D4. Outbound flow: only what stackiq owns `lib/Settings/flows/itsm-outbound-applications.json`: @@ -77,7 +81,7 @@ Licences and contracts look up the usage by `applicationRecordId`, upsert `catal 4. `apply-mapping` with the outbound preset, `ownership: outbound`, `exists: usage.recordId`, output `send`. A usage the desk does not know yet sends every field; a known one sends only stackiq-owned fields. 5. `set-fields` builds `stackiqOwned`: only the stackiq-owned source fields of `usage` (owners, BBN level, TIME class, licence and contract fields). 6. `contract` on the outbound synchronization with `idPosition: usage.uuid` and `hashPosition: stackiqOwned`. Unchanged stackiq-owned fields give `skip`, and nothing is sent. -7. `switch` on `usage.recordId`: empty means `source-call` POST to the desk's create endpoint, then `object-write` on the usage with the returned record id and link. Set means `source-call` PATCH or PUT to the record. +7. An exit on `usage.recordId`: empty means `source-call` POST to the desk's create endpoint with `bodyFrom: send`, then `object-write` on the usage with the returned record id (TOPdesk `data.id`, ServiceNow `result.sys_id`) and link. Set means `source-call` to the record (TOPdesk updates with POST, ServiceNow with PATCH). 8. `contract-commit`. ## D5. Why there is no ping-pong diff --git a/src/views/settings/sections/ItsmExchange.vue b/src/views/settings/sections/ItsmExchange.vue index f0487b1a..3c5ecda8 100644 --- a/src/views/settings/sections/ItsmExchange.vue +++ b/src/views/settings/sections/ItsmExchange.vue @@ -58,6 +58,16 @@ :placeholder=" t('stackiq', 'The organisation whose applications are exchanged') " /> +

{{ t( @@ -91,7 +101,13 @@