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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/adopt-integration-leaves/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-18
110 changes: 110 additions & 0 deletions openspec/changes/adopt-integration-leaves/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Design — adopt-integration-leaves

## 1. Leaf-per-schema mapping and why each pairing is the right one

| Schema | Leaf | Grounding (real fields / services) |
|---|---|---|
| `contactPerson` | `contacts` | `contactsUid` — "Verwijzing (UID) naar de Nextcloud-contactpersoon in het adresboek (OCP\Contacts\IManager)". The catalogue record is explicitly only the ROLE; identity lives in NC Contacts. The leaf makes the vCard (display name, email, avatar) visible and linkable on `ContactpersoonDetail` instead of a bare UID string. |
| `organization` | `contacts` | `organization.contactsUid` — same convention, "contactpersoon van het type organisatie". |
| `contract` | `calendar` | `contract.startDate` / `contract.endDate` ("De einddatum van het contract (indien van toepassing)"). End dates drive renewal planning; the leaf gives every contract a Meetings/Events tab plus the synced end-date event (section 3). |
| `moduleVersion` | `calendar` | `dateEndSupport` ("Startdatum einde ondersteuning") and `dateWithdrawn`. `dateEndSupport` is machine-maintained by the EOL sync (`eolSource`, `eolUpdatedOn` — "Alleen gezet door de EOL-matcher", `EolSyncService::run()`), so the synced event tracks upstream endoflife.date data. |
| `assessment` | `deck` | Reviews carry moderation state (`status` pending/approved/rejected, forced to `pending` server-side by `ReviewService::submit()`, transitioned only by `ModerationService::approve()/reject()` — see `register.d/catalog-ratings.json`). Follow-ups ("moderate this review", "discuss rating 2/10 for module X with the vendor") are card-shaped work; `DeckProvider` supports both link-existing (`{cardId}`) and create-and-link (`{boardId, stackId, title}`). |
| `module` | `bookmarks` | `module.website` is one URL; real applications have docs, changelog, security advisories, pricing pages. `BookmarksProvider` stores links in OR's own `openregister_bookmark_links` table (survives Bookmarks tag edits, caches title/url for the sidebar). |
| `service` | `bookmarks` | `service.website` — same reasoning for supplier service offerings. |

Leaf ids verified against openregister at HEAD:
`LinkedEntityService::legacyLinkedTypeIds()` = `files`, `mail`, `contacts`,
`notes`, `todos`, `calendar`, `talk`, `deck`; registered
`IntegrationProvider::getId()` values include `contacts`, `calendar`,
`deck`, `bookmarks` (`openregister/lib/Service/Integration/Providers/`).
`LinkedEntityService::validateType()` throws on anything else, and
`Repair/LogDanglingLinkedTypes` logs schemas whose `linkedTypes` name an
unregistered integration — both act as loud guards against a typo in the
fragment.

## 2. Fragment mechanics — the `contract` array hazard

`contract` is the ONE schema that already carries `linkedTypes`
(`["decidesk-decisions"]`, in the monolith). ADR-037 fragments are
deep-merged by `SettingsService::loadSettings()` (`deepMergeConfig()`); for
scalar/object keys the merge is a union, but array-of-scalar semantics
(union vs replace) must not be assumed. The fragment therefore declares the
FULL intended array — `["decidesk-decisions", "calendar"]` — which is
correct under either semantic:

- replace → the merged value is exactly the full array;
- union → `decidesk-decisions` deduplicates, `calendar` is added.

Task 1.3 verifies the merged output (`/api/settings/load`) contains both
entries exactly once. Losing `decidesk-decisions` would silently break the
ContractApprovalPanel's decision leaf — this is the highest-risk line of the
whole change, hence its own task and scenario.

## 3. Lifecycle-date calendar sync

`CalendarProvider` is a read/render surface: it lists CalDAV VEVENTs that
carry `X-OPENREGISTER-*` properties identifying the owning object
(persistence is owned by the Calendar app; creation flows via OR's
`CalendarEventService`). Declaring `calendar` in `linkedTypes` gives the
tab and manual link/create, but nobody will hand-create "contract ends"
events for every contract — so this change adds a thin app-side sync:

- `lib/Service/LifecycleCalendarService.php` — `syncContract(objectData)`
and `syncModuleVersion(objectData)`; upserts (creates, moves, or
deletes) one all-day linked event per tracked date field via OR's
calendar link path. Event titles are English per fleet convention
(`feedback_english-code`): "Contract ends: {contractNumber}" /
"End of support: {module name} {version}".
- `lib/Listener/LifecycleCalendarListener.php` — subscribes to
OpenRegister's object-saved event for the voorzieningen register,
filters on the `contract` / `moduleVersion` schema slugs resolved
through `SettingsService` (never hard-coded register ids), and delegates
to the service. Deletion of the object removes the linked event (OR's
`ObjectCleanupListener` already unlinks leaf rows; the listener only
needs to handle date-cleared-on-save).
- Idempotency: the event is looked up by its object link + a
deterministic marker (one tracked field = one event), so re-saves and
EOL re-stamps move the single event instead of accumulating duplicates.
- Fail-soft: calendar unavailable (app disabled, no writable calendar) is
logged and never blocks the object save — same graceful-degradation
posture the register's other integrations use.

**Which calendar?** The events are personal CalDAV objects; the sync runs
in the saving user's session and writes to that user's default calendar
(the same calendar OR's create-event leaf flow targets). A shared
"portfolio calendar" is a legitimate future improvement, deferred — it
needs an ownership/config decision (`declared-config-enforced-nowhere` is
the failure mode to avoid: no config key is introduced here until
something reads it).

## 4. Deferred (explicitly out of scope)

- `connection.dateEndSupport` / `dateWithdrawn` — same calendar shape as
`moduleVersion`, deferred until the koppeling detail surface is
reviewed; adding it later is one fragment line + one listener case.
- `compliancy.url` / `evidenceReference` as bookmarks — compliance
evidence is file/reference-shaped and already has `allowFiles: true` +
`evidenceReference`; forcing it into bookmarks would duplicate an
existing surface.
- `usage` deck leaf (TIME-classification review follow-ups via
`timeReviewDate`) — plausible, but the assessment leaf should prove the
pattern first.
- NC Mail (`configuration.linkedTypes: ["mail"]` sidebar target +
`mailObjectTemplate`) — a separate comms-rule discussion; the manifest
`_note`s document a deliberate "comms hard-rule" that email widgets stay
off these detail pages, and this change does not reopen it.

## 5. Manifest touch-points

`src/manifest.json` detail pages affected: `ContactpersoonDetail`,
`ContractDetail`, `ModuleDetail`, `Diensten`/`DienstDetail` equivalent,
`ModuleversieDetail`, `ReviewDetail`, `OrganisatieDetail`. The leaf tabs
render from schema `linkedTypes` via the shared detail-page sidebar
(`CnObjectSidebar` — "so the CnObjectSidebar and dashboard widgets can
render a … tab without per-app glue", per `CalendarProvider`'s own
docblock); no per-page widget wiring is expected, but the two `_note`
strings that assert "declares NO email/calendar linkedType" become false
for `contract`/`contactPerson` and MUST be rewritten to describe the new
state, so the next audit doesn't read a stale premise
(`reference_design-system-adoption-silent-failures`: notes that lie are
worse than no notes).
96 changes: 96 additions & 0 deletions openspec/changes/adopt-integration-leaves/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
kind: code
depends_on: []
---

# softwarecatalog — adopt OpenRegister integration leaves (contacts, calendar, deck, bookmarks)

## Why

OpenRegister ships an app-agnostic integration-leaf registry
(`openregister/lib/Service/Integration/IntegrationRegistry.php` +
`Providers/`): a schema that declares a leaf id in
`configuration.linkedTypes` gets that Nextcloud app's link surface (sidebar
tab / widgets on the object detail page) with zero per-app glue —
`ContactsProvider` (id `contacts`), `CalendarProvider` (`calendar`),
`DeckProvider` (`deck`), `BookmarksProvider` (`bookmarks`) all exist today,
alongside the legacy allow-list ids in
`LinkedEntityService::legacyLinkedTypeIds()` (`files`, `mail`, `contacts`,
`notes`, `todos`, `calendar`, `talk`, `deck`).

Software Catalog consumes almost none of this. Verified against
`lib/Settings/softwarecatalogus_register.json` at HEAD:

- `allowFiles: true` on exactly 6 schemas (`suite`, `service`,
`organization`, `usage`, `module`, `compliancy`) — the files leaf.
- `linkedTypes` on exactly one schema: `contract` →
`["decidesk-decisions"]` (the ADR-066 approval projection).
- No schema declares `contacts`, `calendar`, `deck`, or `bookmarks`.

That leaves four gaps the domain data is already shaped for:

1. **Contacts** — `contactPerson.contactsUid` is literally "Verwijzing
(UID) naar de Nextcloud-contactpersoon in het adresboek
(`OCP\Contacts\IManager`)", and `organization.contactsUid` mirrors it.
The identity IS a Nextcloud contact by design (the
`ContactpersoonDetail` manifest note says communication happens
"through the linked Nextcloud contact via contactsUid"), yet the detail
page renders no contacts leaf — the vCard link exists only as a bare
string property.
2. **Calendar** — `contract.endDate` ("De einddatum van het contract") and
`moduleVersion.dateEndSupport` ("Startdatum einde ondersteuning",
stamped by the EOL matcher per `eolSource`/`eolUpdatedOn`) are the two
dates portfolio managers plan around, and neither is visible in any
calendar. The `ContractDetail` manifest `_note` even hard-codes the
current state: "The contract schema declares NO email/calendar
linkedType".
3. **Deck** — `assessment` records (reviews, live since the
`catalog-ratings` fragment added `auteur` + moderation `status`
pending/approved/rejected, enforced by `ReviewService::submit()` and
`ModerationService::approve()/reject()`) generate follow-up work
(moderate a pending review, chase a vendor about a bad rating) that has
no task surface.
4. **Bookmarks** — `module.website` ("Een URL naar uw applicatie") and
`service.website` are single URL strings; vendors accumulate more than
one relevant link (docs, changelog, status page, pricing) and today
have nowhere structured to put them.

## What Changes

- Add a new ADR-037 register fragment
`lib/Settings/register.d/catalog-integration-leaves.json` (never editing
the `softwarecatalogus_register.json` monolith) that declares
`configuration.linkedTypes`:
- `contactPerson`: `["contacts"]`
- `organization`: `["contacts"]`
- `contract`: `["decidesk-decisions", "calendar"]` — restating the
existing `decidesk-decisions` entry so the merged array is correct
regardless of whether the ADR-037 deep-merge unions or replaces
arrays (verified behaviour recorded in `design.md`).
- `moduleVersion`: `["calendar"]`
- `assessment`: `["deck"]`
- `module`: `["bookmarks"]`
- `service`: `["bookmarks"]`
- Add a lifecycle-date calendar sync (`lib/Service/LifecycleCalendarService.php`
+ an OR object-saved listener): when `contract.endDate` or
`moduleVersion.dateEndSupport` is set or changed, upsert a linked
all-day VEVENT through OpenRegister's calendar link path (the same
`X-OPENREGISTER-*`-marked events `CalendarProvider::list()` renders),
so the calendar leaf tab shows the end-of-contract / end-of-support
event without manual linking; remove the event when the date is
cleared. EOL-matcher re-stamps of `dateEndSupport`
(`EolSyncService::run()` → `EolMatcherService`) move the event.
- Update the stale `src/manifest.json` detail-page `_note` prose on
`ContractDetail` and `ContactpersoonDetail` (both currently assert "NO
email/calendar linkedType" as the reason no comms widgets are placed)
and verify the leaf tabs render on the `ContractDetail`,
`ContactpersoonDetail`, `ModuleversieDetail`, `ReviewDetail`,
`ModuleDetail`, and `Diensten` detail surfaces.
- No new leaf providers and no OpenRegister changes: everything consumed
here (`contacts`, `calendar`, `deck`, `bookmarks`) is already a
registered `IntegrationProvider` at openregister HEAD.

Not BREAKING: purely additive configuration plus one new sync service; no
existing route, response shape, or schema property changes. The
`connection.dateEndSupport` and `compliancy.url` fields are deliberately
out of scope (see `design.md` deferrals).
Loading
Loading