feat: adopt OpenRegister declarative annotations (lifecycle / aggregations / calculations / notifications)#141
Conversation
Pilot of the manifest renderer landing in @conduction/nextcloud-vue PR #89. Routes the Decisions and DecisionDetail pages through CnPageRenderer instead of mounting their view components directly; every other route is unchanged so the blast radius stays minimal. Changes: - src/manifest.json: declares Decisions and DecisionDetail as type:"custom" pages mapped to registry component names. The manifest also carries `dependencies: ["openregister"]`, anticipating the eventual move to Tier 4 with CnDependencyMissing. - src/customComponents.js: registry mapping DecisionsView / DecisionDetailView to the existing Decisions.vue / DecisionDetail.vue components. - src/App.vue: setup() calls useAppManifest('decidesk', bundledManifest); provide() exposes cnManifest, cnCustomComponents, cnTranslate so CnPageRenderer can inject them. The visible shell (NcContent → MainMenu → router-view, the OpenRegister-installed gate, and the loading state) is untouched. - src/router/index.js: Decisions and DecisionDetail routes now mount CnPageRenderer; route names match the manifest's pages[].id so the renderer matches by $route.name === page.id. - docs/manifest-pilot.md: rationale, what changed, why type:"custom", how to run in dev with the local nextcloud-vue alias, and what the pilot does NOT cover. Why type:"custom" for routes that look like list/detail pages: the renderer's built-in type:"index"/"detail" paths only forward page.config as props to the corresponding Cn*Page; they don't run useListView / useDetailView. Decidesk's existing views need those composables AND a #create-dialog slot override, so wrapping them in the registry keeps full app-side control while routing through the manifest. A future renderer iteration can add a config shape that auto-wires the composable; until then, the registry is the right tool for views with composable-driven data loading. Dev setup until @conduction/nextcloud-vue ships a beta with PR #89: cd ../nextcloud-vue && git checkout feature/json-manifest-renderer cd ../decidesk && USE_LOCAL_LIB=1 npm run dev webpack.config.js already aliases @conduction/nextcloud-vue to ../nextcloud-vue/src when USE_LOCAL_LIB=1.
Two compounding bugs that together meant every install thought it had
configured Decidesk while no register was ever created in OpenRegister:
1. lib/Service/SettingsService.php — loadConfiguration() called the
OpenRegister importer with only `appId` + `force`, but the importer
signature is importFromApp(appId, data, version, force). PHP named
args silently dropped `data` and `version`, so the importer ran
against an empty configuration and returned an empty result. Now the
service reads lib/Settings/decidesk_register.json, parses it, picks
up the version from `info.version`, and passes all four arguments.
2. lib/Settings/decidesk_register.json — only `components.schemas`
was declared; no `components.registers` block. The OpenRegister
importer creates registers from `data.components.registers[*]` —
without it, schemas were imported but no register entity bound them
together, so `/api/objects?register=decidesk&schema=decision`
returned 404 ("Register not found: 'decidesk'"). Added a `decidesk`
register entry referencing all 17 schema slugs (governance-body,
meeting, participant, agenda-item, motion, amendment, voting-round,
vote, decision, action-item, minutes, digital-document,
monetary-amount, offer, order, product, report) following the same
shape used by procest / opencatalogi / pipelinq.
Bumped info.version from 0.1.0 to 0.2.0 so existing installs that
already imported v0.1.0 (schemas-only) pick up the new register on
the next `occ maintenance:repair`.
Verified locally: `occ maintenance:repair --include-expensive` now
reports "Decidesk configuration imported successfully (version: 0.2.0)"
and `/apps/openregister/api/registers?_search=decidesk` returns the
new register with all 17 schemas attached.
Aligns decidesk with the @conduction/nextcloud-vue stack so the new
manifest renderer's useListView API path actually works against
decidesk's existing views.
ADR-022 ("apps consume OpenRegister abstractions over local
duplication"):
- src/store/store.js: imports useObjectStore from
@conduction/nextcloud-vue (the canonical Pinia store) instead of
the local fork. The configure() + registerObjectType() calls in
initializeStores() use the same shape so no other call sites needed
changes there.
- src/store/modules/object.js: deleted. It was a 90-line fork of
the canonical store missing fetchSchema / fetchCollection /
pagination / facets / errors — exactly what useListView's new
one-arg API expects.
- 7 view/component files: rename the canonical .fetchObjects(...)
callsite to .fetchCollection(...) — the canonical store uses the
plural-collection name; both return the array so the rest of the
call site is unchanged.
Manifest renderer bootstrap (per @conduction/nextcloud-vue's
CLAUDE.md required-bootstrap section):
- src/main.js: adds registerIcons({}) + registerTranslations() before
Vue mount so library-rendered strings translate to the user's NC
locale and CnIcon resolves icon names. Wraps loadTranslations() in
.catch().then(mount) so a missing per-locale l10n/<lang>.json
doesn't block app boot — pre-existing behaviour silently dropped
the mount callback on a 404 in the locale file.
Dev-environment unblockers (USE_LOCAL_LIB=1):
- webpack.config.js: adds a vue-loader-compatible scss rule so
CnCard.vue's `<style lang="scss">` compiles in dev (sass-loader
was already in node_modules but not registered). Also sets
output.publicPath = 'auto' so dynamically-loaded chunks resolve
via document.currentScript.src instead of a hardcoded
/apps/<appId>/js/ that doesn't match the bind-mounted custom_apps
path.
Verified end-to-end via Playwright: navigating to /decisions now
mounts CnPageRenderer → Decisions.vue → CnIndexPage with the empty
state, "Add Decision" → CnSchemaFormDialog opens with all 17 fields
auto-rendered from the schema. No JS console errors.
Three pilot-soak findings, all making decidesk match procest / pipelinq visually rather than diverging. Dashboard (`src/views/Dashboard.vue`): - Replaces the hand-rolled `<div class="decidesk-dashboard">` shell with `<CnDashboardPage>` so the page picks up the same outer padding / margins used by every other Conduction app dashboard. Earlier the custom CSS (`padding: 8px 4px 24px`) drifted from the library default and rendered noticeably tighter on the left. - Wires the `#header-actions` slot with the standard quick-action set (New Decision / New Action Item / New Minutes / Refresh) — the slot was simply absent in the hand-rolled markup, so the top-right action area was empty. - Reuses the existing KPI counts via custom widgets in the CnDashboardPage grid layout instead of an inline `CnKpiGrid`. - Refresh button is wired to a `loadCounts()` method so refreshing the KPIs doesn't require a full page reload. Main menu (`src/navigation/MainMenu.vue`): - Dashboard nav icon: `Home` → `ViewDashboard` (the matrix-of-dots glyph). This is the icon every other Conduction app uses for its Dashboard entry and matches Nextcloud core's own dashboard. - Settings entry now lives inside `<NcAppNavigationSettings>` (the canonical footer slot) instead of a bare `NcAppNavigationItem`. Visually identical for the user but matches the NC convention so future settings entries can stack inside the same group without another wrapper change. Verified end-to-end via Playwright at /apps/decidesk/: the four header action buttons render in the top right, the title block has the same left margin as procest's dashboard, and the Settings entry occupies the bottom-left footer with the cog icon.
Two divergences from the canonical pattern that caused decidesk's dashboard widgets to look subtly off: 1. Empty widget titles. WIDGET_DEFS had `title: ''` for every entry; procest / pipelinq pass real translated strings. Even when `showTitle: false` hides the header bar, the title is read by CnWidgetWrapper for its aria attributes and content-area class selectors. Now resolved through a computed widgetDefs() so titles re-translate when the locale changes. 2. Double card chrome. Bottom-row "Notulen" / "Besluiten" widgets wrapped a `CnConfigurationCard` inside a (borderless) custom widget slot, so the visible card was the inner CnConfigurationCard while CnWidgetWrapper sat empty around it. Procest puts content directly in the slot and lets CnWidgetWrapper provide the card chrome via `showTitle: true` (the default). Now the bottom row drops `showTitle: false` from its layout entries — CnWidgetWrapper renders a single card with a proper title bar — and the slot contents are plain content (the link), not another card. KPI row continues to render with `showTitle: false` (borderless CnWidgetWrapper, just the CnStatsBlock visible) — same as procest's KPI row.
Migrates decidesk from Tier 2 (just /decisions through CnPageRenderer)
to Tier 4 of the manifest-renderer adoption ladder: the app shell,
side navigation, and every route are now driven by `src/manifest.json`.
Manifest expansion (`src/manifest.json` v0.2.0):
- `menu[]` grew from empty to 6 entries (Dashboard, Minutes, Decisions,
Action items, Motions, Meetings) — CnAppNav reads this and renders
the side panel.
- `pages[]` grew from 2 to 20, covering every route the old hand-rolled
router carried (Dashboard, GovernanceBodies + detail, Meetings +
detail, LiveMeeting, Participants + detail, AgendaItems + detail,
Motions + detail, AmendmentDetail, Minutes + detail, Decisions +
detail, ActionItems + detail, Settings).
- Every page is `type: "custom"` for now — they wrap existing app
views (which all use the useListView / useDetailView composables and
ship slot-overridden dialogs). Once nextcloud-vue#90 lands the
auto-wire for `type: "index"` / `type: "detail"`, most of these
collapse to declarative entries with no registry component.
`src/customComponents.js`:
- 20 entries mapping registry names to view imports.
- Each wrapped in `defineAsyncComponent` so views still chunk-split
into separate bundles (preserves the lazy-load behaviour the old
`() => import(...)` router config gave).
`src/router/index.js`:
- Routes are now generated from `manifest.pages.map(...)`. Every entry
becomes `{ name: page.id, path: page.route, component: CnPageRenderer,
props: page.route.includes(':') }`. A new route is one manifest line.
- Catch-all `path: '*'` redirect to `/` preserved.
`src/App.vue`:
- Replaces the hand-rolled NcContent + MainMenu + NcAppContent + custom
OpenRegister-installed gate with a single `<CnAppRoot>` element.
- `useAppManifest('decidesk', bundledManifest)` provides the manifest
+ isLoading state.
- `customComponents` and `translate` (closure over @nextcloud/l10n's
`translate('decidesk', key)`) are passed in.
- `manifest.dependencies: ["openregister"]` drives CnAppRoot's
dependency-check phase, replacing the old `useSettingsStore.hasOpenRegisters`
gate.
`src/main.js`:
- `initializeStores()` moved out of App.vue's `created()` hook into
the bootstrap chain (after `loadTranslations`, before mount). Stores
are ready by the time CnAppRoot renders.
`src/navigation/MainMenu.vue`:
- Deleted. CnAppNav (driven by manifest.menu[]) replaces it. Apps that
need a hand-rolled menu can use CnAppRoot's `#menu` slot to override.
Verified end-to-end via Playwright after the matching nextcloud-vue
fixes in feature/json-manifest-renderer:
- Side nav: 6 manifest items render with the right icons
- Dashboard: header actions + KPIs + Notulen/Besluiten widgets render
- Decisions list (manifest-driven): CnIndexPage renders the empty state
- Minutes list: same, navigates correctly
- Layout: content area fills the full width once CnAppRoot wraps the
router-view in NcAppContent
- No more "Required apps are missing" false positive (useAppStatus now
checks OC.appswebroots, not capabilities)
Out of scope for this commit (filed as follow-ups):
- Documentation external link in the menu — the manifest schema has
no `href` field for non-route items. Drop for now.
- Settings entry in NcAppNavigationSettings (the footer group) — the
schema has no concept of menu sections. Drop for now.
- Auto-wire for type:"index" / type:"detail" pages — see
ConductionNL/nextcloud-vue#90.
Both placed in section "settings" so CnAppNav renders them inside the NcAppNavigationSettings collapsible footer group: - Documentation: external href, opens conduction.nl in a new tab - SettingsMenu: routes to the existing Settings page Bumps manifest version to 0.3.0.
- Documentation drops the section: "settings" so it renders as the last item of the main list (always visible) instead of being hidden behind the NcAppNavigationSettings popover. Settings stays in the settings section. - icon-dashboard is not a built-in Nextcloud icon class, so the entry rendered without an icon. Switch to icon-category-dashboard. - Bump app version to 0.1.2 for cache busting after the JS change.
Documentation belongs visually next to Settings at the bottom of the navigation, not floating in the middle of the main list. CnAppNav now renders section:settings items directly in the footer slot, so moving Documentation to that section anchors it to the bottom right above Settings — same UX as procest's MainMenu without the popover. Bumps app version to 0.1.3 for cache busting.
Decidesk shipped four bespoke per-schema Pinia stores
(meetings/minutes/decisions/actionItems) that hand-rolled CRUD
against /apps/openregister/api/objects (or in meetings' case,
/apps/decidesk/api/meetings). The canonical useObjectStore from
@conduction/nextcloud-vue already covers every method they
implemented (fetchCollection, fetchObject, saveObject, deleteObject,
deleteObjects, pagination, search, errors, caching).
A code audit shows the duplication was almost entirely dead:
- minutes.js / actionItems.js: 0 callers anywhere
- meetings.js: 1 view used 3 filter setters; the 5 CRUD actions and
the lifecycle action were never imported
- decisions.js: 1 view used `publishDecision`; the CRUD actions were
never imported
Changes:
- Delete the four dead store modules (-840 lines).
- Drop dead exports from store/store.js.
- In initializeStores(), register every object type the views
actually reference (9 total: minutes, decision, action-item,
meeting, agenda-item, motion, amendment, governance-body,
participant). The previous list of 3 left useListView calls for
the other 6 throwing "type not registered" silently — surfaced by
the Playwright smoke test against /apps/decidesk/meetings.
- Add `setActivePinia(pinia)` in pinia.js. main.js calls
initializeStores() *before* app.$mount(), at which point Pinia is
not yet bound to the Vue app. Without setActivePinia, useStore()
returned a detached store missing its `_s` plugin chain and every
registerObjectType() call was a silent no-op (caught by the
catch() in main.js's bootstrap chain). Surfaced by an empty
objectTypeRegistry at runtime even though the source said
otherwise.
- Meetings.vue: drop meetingStore import + dead .setSearchQuery /
.setFilters / .resetFilters / .page = 1 calls. Local searchQuery
and selectedLifecycle data() props remain to bind to the toolbar
inputs.
- DecisionDetail.vue: inline the single POST to
/apps/decidesk/api/decisions/{id}/publish (formerly
decisionStore.publishDecision). Server still enforces admin /
outcome / isPublished guards.
- Bump app version to 0.1.7.
Smoke test (browser-6): /apps/decidesk/{,minutes,decisions,
action-items,motions,agenda-items,participants,governance-bodies,
meetings,settings} — all 0 console errors, 9/9 types registered.
The MeetingController CRUD endpoints (index/create/show/update/destroy) were thin pass-throughs to OpenRegister's ObjectService — every MeetingService method just called createFromArray/find/updateFromArray/ deleteFromId with register='decidesk', schema='meeting' and a log line. The frontend never used them: views go through useObjectStore → /apps/openregister/api/objects?register=decidesk&schema=meeting directly, which is the canonical path per ADR-022. Deleted: - MeetingController::index, create, show, update, destroy (kept lifecycle — that one wraps real workflow logic in MeetingService::transition) - MeetingService::create, read, update, delete (pass-throughs) - The 5 matching routes in appinfo/routes.php - The container DI on MeetingController + the matching test mock Net: -260 PHP lines + -5 routes; the CRUD path is unchanged from the UI's point of view since it was already hitting openregister. Pre-existing fixes encountered: - SettingsService.php:196 PHPCS concat-with-spaces - Application.php AnalyticsController DI was missing userSession + groupManager (Psalm TooFewArguments — service registration drifted from the constructor signature). Verification: - composer phpcs / psalm: clean - phpunit Meeting* tests: 17 pass, 4 skipped - Playwright /apps/decidesk/meetings: 0 console errors - Direct hits on the deleted endpoints return 405 (POST/PUT/DELETE); GET falls through to the SPA catch-all
…on stack
Pilot of OpenRegister's declarative lifecycle annotation against the Meeting schema.
Backend:
- Annotate Meeting schema with x-openregister-lifecycle (field, initial, final, transitions)
under configuration.x-openregister-lifecycle so OR's existing schema persistence carries it.
- Add MeetingTransitionGuard implementing OCA\OpenRegister\Lifecycle\LifecycleGuardInterface;
delegates to existing WorkflowService (domain rules + chair gates) and QuorumService
(open-meeting quorum check). Registered under DI tag `decidesk.meeting.transitionGuard`
matching the schema's `requires` field.
- Remove MeetingService, MeetingController, /api/meetings/{id}/lifecycle route, and
associated unit tests — replaced wholesale by OR's POST /api/objects/{id}/transition
+ GET /api/objects/{id}/available-actions.
Frontend:
- MeetingLifecycle.vue: drop hardcoded transition map mirroring the backend (the warning
comment said it would silently diverge — now removed at the source). Load actions from
GET /apps/openregister/api/objects/{id}/available-actions; apply via POST
/apps/openregister/api/objects/{id}/transition. Labels and badge colours stay client-side.
Net deletion: ~533 lines of meeting-specific transition machinery + tests, replaced with
~125 lines of guard + schema annotation. Lifecycle shape is now single-source-of-truth in
the schema; the frontend cannot drift.
Live-pilot follow-ups: - Bump register version to 0.3.2 + Meeting schema version to 0.2.2 so the next maintenance:repair re-imports the lifecycle annotation. - Switch the `requires` field from the local DI tag string `decidesk.meeting.transitionGuard` to the guard's FQCN `OCA\Decidesk\Lifecycle\MeetingTransitionGuard`. OpenRegister resolves guards via the server container so it can autowire across apps; FQCNs are the only stable cross-app identifier.
The guard is referenced by FQCN in the schema's `requires` field; OpenRegister's LifecycleGuardRegistry resolves FQCNs via the server container, which autowires constructor dependencies. The local registerService block was redundant.
Declares totalCompleted, byStatus, completedThisMonth, totalOpen.
Verified end-to-end against OpenRegister's
GET /api/objects/aggregations/{register}/{schema}/{name}.
ActionItemAnalyticsService is left in place for now: avgDaysToClose
needs computed fields, getCompletionRates needs cross-schema rollup,
getMyItems needs user-scoped buckets. Migration to fully declarative
analytics ships after the calculations-annotation change lands.
- daysLate (materialise:true): diffDays(completedAt, dueDate) - isOverdue (materialise:true): and(ne(taskStatus, "completed"), lt(dueDate, $now)) - New aggregations leaning on the materialised fields: - totalOverdue: count where isOverdue=true - avgDaysLate: avg(daysLate) where taskStatus=completed Verified end-to-end via OR's CalculationOnSaveListener + AggregationRunner.
Two transition-triggered notifications: meetingOpened (action=open) and
meetingClosed (action=close), both delivered to admin via the Nextcloud
INotificationManager. Subject template uses {{title}} interpolation.
Verified end-to-end via OR's AnnotationNotificationDispatcher +
AnnotationNotifier. Trigger action filter correctly rejects unrelated
transitions (e.g. schedule fires no notification).
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-29 08:55 UTC
Download the full PDF report from the workflow artifacts.
…ansition stack Three more schemas migrate off hand-written state machines onto the declarative platform path that landed in PR #141: | Schema | Annotation actions | |-----------|---------------------------------------------------| | Motion | debate / vote / adopt / reject / withdraw | | Amendment | debate / vote / adopt / reject | | Minutes | submit / approve / sign / publish | ### Deletes - MotionService::transitionLifecycle + MOTION_TRANSITIONS + AMENDMENT_TRANSITIONS constants - MotionService::transitionLifecycle related tests - MotionController::transition + MotionController::amendmentTransition - MinutesGenerationService::transition + LIFECYCLE_TRANSITIONS + MissingObjectException import - MinutesController::transition (admin-gated bespoke endpoint) - /api/motions/{id}/transition + /api/amendments/{id}/transition + /api/minutes/{minutesId}/transition routes - MotionService dependency on VotingService ### Replaces with - VotingService now resolves OR's TransitionEngine from the container and calls .transition($motionId, 'vote'/'adopt'/'reject') directly. - MinutesTransitionListener subscribes to ObjectTransitionedEvent and patches Minutes-specific bookkeeping (approvedAt on `approve`, signedBy on `approve`/`sign`) — preserves the side-effects that the old MinutesGenerationService::transition had inline. - MinutesDetail.vue: drop the hardcoded transition map; load actions from /apps/openregister/api/objects/{id}/available-actions and apply via /apps/openregister/api/objects/{id}/transition. ### Verified end-to-end - Motion: submitted→debating→voting→rejected - Amendment: submitted→debating→voting→adopted - Minutes: draft→review→approved→signed→published; approvedAt set on approve, signedBy=[admin] accumulates across approve+sign
Update — Motion / Amendment / Minutes lifecycle adoptionPushed
Deletes: Replaces with:
Verified end-to-end in the dev container: motion submitted→debating→voting→rejected; amendment submitted→debating→voting→adopted; minutes draft→review→approved→signed→published with side-effect listener correctly populating Net additional change: −430 / +255 lines. |
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-29 09:09 UTC
Download the full PDF report from the workflow artifacts.
Surfaces meetings as read-only events in Nextcloud Calendar via
OpenRegister's RegisterCalendarProvider. dtstart=scheduledDate,
dtend=endDate, titleTemplate={{title}}, color=#0082c9.
The provider scopes calendars by the user's active organisation; in
the dev container admin's active org doesn't match the test schemas'
org, so the smoke test surfaces 0 calendars. Once admin's active org
matches the schemas' org (or schemas have NULL org), Meeting will
appear as a calendar in the Calendar app.
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-29 09:14 UTC
Download the full PDF report from the workflow artifacts.
New calculation: daysOpen = diffDays($now, @self.created) Materialises on every save thanks to OR's CalculationOnSaveListener injecting @self metadata at evaluation time (PR ConductionNL/openregister#1357). This is the canonical "time since creation" metric that previously required a hand-written analytics query — the listener does it once, materialises the integer, and aggregations can target it directly: avgDaysOpen: { metric: "avg", field: "daysOpen", filter: { taskStatus: { in: ["open","in-progress","overdue"] } } }
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-29 11:59 UTC
Download the full PDF report from the workflow artifacts.
statusBadge is a materialise:false calculation: 'taskStatus' + ' (' +
(isOverdue ? 'overdue' : 'on track') + ')'.
Evaluated at response-render time only when caller passes
_extend=calculations. Demonstrates the virtual-calculation path that
landed in OR PR #1357.
Companion: ConductionNL/openregister#1357
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-29 12:23 UTC
Download the full PDF report from the workflow artifacts.
…larative aggregations
Replaces the in-PHP iteration over every ActionItem (~50 lines per
metric) with 4 calls to OR's AggregationRunner.
### Schema additions
- daysToClose calculation (materialise:true): diffDays(completedAt, @self.created)
- daysToClose property declaration (integer, populated by the calc listener)
- avgDaysToClose aggregation (avg of daysToClose where taskStatus=completed)
### Service refactor
- getSummary delegates to a thin private aggregate() helper that calls
OCA\OpenRegister\Service\Aggregation\AggregationRunner directly.
- Each metric (totalOpen, totalOverdue, completedThisMonth,
avgDaysToClose) is now declared on the schema and computed by the
platform — the service no longer iterates objects in PHP.
### Live verification
ActionItemAnalyticsService::getSummary("2026-01-01","2026-12-31")
→ {"totalOpen":10,"totalOverdue":3,"completedThisMonth":4,"avgDaysToClose":0}
avgDaysToClose is 0 today because newly-imported ActionItems don't have
daysToClose materialised yet — once OR's
\`occ openregister:rematerialise-calculations decidesk action-item\`
command lands (class is shipped in OR PR #1357 but the <commands>
block is gated on an upstream DI fix), existing items will pick up
the materialised value on the next bulk pass.
Companion: ConductionNL/openregister#1357
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-29 12:24 UTC
Download the full PDF report from the workflow artifacts.
…t-webhook notifications - Meeting.meetingReminderDaily — scheduled notification (intervalSec=86400, filter: lifecycle=scheduled). Picked up by openregister's ScheduledNotificationJob (verified live: "fired meetingReminderDaily on schema 657"). - Meeting.meetingClosed — adds webhook channel with persistent: true. NotificationsAnnotationInstaller auto-creates a managed Webhook entity (verified live: or-notif-meeting-meetingClosed provisioned with url + ObjectTransitionedEvent subscription). - ActionItem.tooManyOverdue — threshold notification on the totalOverdue aggregation (op: gt, value: 10). AggregationThresholdListener fires once per below->above transition. These three rules implement notif-v2 pilot tasks 9.1 (scheduled), 9.2 (threshold), 9.3 (persistent-webhook).
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-29 15:14 UTC
Download the full PDF report from the workflow artifacts.
Formal administrative decisions (besluiten under ZGW BRC + Wet open overheid) are decision-management domain logic — that belongs in Decidesk, not the OpenRegister foundation. OpenRegister provides the underlying storage / authorization / audit primitives the spec builds on, but the lifecycle workflow (concept -> definitief -> ingetrokken), zaak-besluit linking, BesluitType catalog, and Woo publication path are all decidesk-domain concerns. Spec content unchanged from the original. Top-of-proposal note added to record the relocation date. Companion change in openregister will remove the misplaced spec.
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 416/416 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-30 12:12 UTC
Download the full PDF report from the workflow artifacts.
Summary
Pilot adoption of OpenRegister's four declarative schema annotations against decidesk's Meeting + ActionItem schemas. Replaces ~533 lines of bespoke transition machinery wholesale with the platform path.
Companion PR: ConductionNL/openregister#1357 (the platform side).
What landed
Meetingx-openregister-lifecycle(6 transitions, 5 guarded) +x-openregister-notifications(transition open/close → admin)MeetingService::transition,MeetingController::lifecycle,/api/meetings/{id}/lifecycleroute, two unit testsActionItemx-openregister-aggregations(6 metrics) +x-openregister-calculations(daysLate,isOverdue)Net change
Frontend
MeetingLifecycle.vueno longer hardcodes the transition table. It calls:GET /apps/openregister/api/objects/{id}/available-actionsto populate buttonsPOST /apps/openregister/api/objects/{id}/transitionto applyThe schema is now the single source of truth — frontend cannot drift.
Guard wiring
MeetingTransitionGuardimplementsOCA\OpenRegister\Lifecycle\LifecycleGuardInterfaceand delegates to existingWorkflowService(domain rules, chair gates) +QuorumService(open quorum check). The schema'srequiresfield uses the guard's FQCN so OR'sLifecycleGuardRegistryautowires it from the server container.Calculations + aggregations interaction
isOverdueis materialised on save by the calculation evaluator and immediately becomes filterable by aggregations (totalOverdue: count where isOverdue=true).avgDaysLateaverages the materialiseddaysLatefield. This proves the annotations compose.Verified end-to-end in the dev container
TransitionControlleroperationsdomain denied by guard with structured 422daysLate+isOverduematerialise correctly on create + updateavgDaysLatereads the materialised field;totalOverduecounts via the materialised flagmeetingOpened/meetingClosedfire to admin viaINotificationManager;scheduleaction correctly does NOT fire either (action filter)Test plan
occ maintenance:repairto re-import the schemas (versions bumped)schedule → open → close; check admin gets two notificationsdaysLate+isOverdueare populated/api/objects/aggregations/decidesk/action-item/{name}for each declared aggregationNote on PR #138
PR #138 (Tier 2 manifest pilot) is on a different branch but shares the same commits via the same author/timeline. After this PR merges to development, PR #138 will only show its original manifest-pilot scope.