|
| 1 | +# Design — softwarecatalog-adopt-or-abstractions |
| 2 | + |
| 3 | +## Reuse analysis |
| 4 | + |
| 5 | +| Capability | Reuse from | Why | |
| 6 | +|------------|-----------|-----| |
| 7 | +| App-manifest schema + loader | `@conduction/nextcloud-vue` (`src/schemas/app-manifest.schema.json`, `useAppManifest`) | Single source of truth per `hydra/openspec/changes/adopt-app-manifest/`. | |
| 8 | +| Index / detail page-type renderers | nc-vue `CnIndexPage`, `CnDetailPage` (already used) | Existing views already wrap these. Manifest declares which schema each consumes. | |
| 9 | +| Register / schema ID resolution | OR `RegisterResolverService` (`openregister/openspec/changes/register-resolver-service/`) | Eliminates 5 duplicated `getValueString(...register/schema...)` call shapes. | |
| 10 | +| Tenant context | `useTenantContext()` from nc-vue (`nextcloud-vue/openspec/changes/multi-tenancy-context/`) | Cache invalidation + write header stamping. | |
| 11 | +| i18n source-of-truth metadata | OR `sourceLanguage` (`openregister/openspec/changes/i18n-source-of-truth/`) | Read-only consumption — display badge. | |
| 12 | +| API language negotiation | OR `?_lang=` + `X-Translation-Target-Language` (`openregister/openspec/changes/i18n-api-language-negotiation/`) | Wire into single `orClient.js` composable. | |
| 13 | +| Pinia stores | SoftwareCatalog's existing entity-typed Pinia stores | Keep. Migrate fetch URL building into `orClient.js`. | |
| 14 | +| Sidebars / dialogs / modals | Existing `src/sidebars/`, `src/dialogs/`, `src/modals/` | Register via manifest's `slots` and `customComponents`. No reorganisation needed. | |
| 15 | + |
| 16 | +### What we deliberately do NOT reuse |
| 17 | + |
| 18 | +- **OR's lifecycle annotations** — applications and components do |
| 19 | + not have a status state machine that benefits from |
| 20 | + `x-openregister-lifecycle`. Procurement / publication workflow |
| 21 | + is out of scope here. |
| 22 | +- **OR's notification engine** — no notification triggers in |
| 23 | + SoftwareCatalog today. If product reasons emerge, follow-up |
| 24 | + change adopts `x-openregister-notifications`. |
| 25 | +- **VNG `Softwarecatalogus/` client repo** — read-only, separate. |
| 26 | + |
| 27 | +### What's deferred |
| 28 | + |
| 29 | +- **Concept Organisations** consumption — the |
| 30 | + `conceptOrganisatiesWidget.js` integrates with an external feed. |
| 31 | + Phase 1 declares the page in the manifest with |
| 32 | + `type: "custom"`; future change may model it as `type: "index"` |
| 33 | + if the feed exposes a register-shaped contract. |
| 34 | + |
| 35 | +## Public API / migration shape |
| 36 | + |
| 37 | +### `src/manifest.json` (new file) |
| 38 | + |
| 39 | +```json |
| 40 | +{ |
| 41 | + "$schema": "https://unpkg.com/@conduction/nextcloud-vue@latest/dist/schemas/app-manifest.schema.json", |
| 42 | + "version": "0.1.0", |
| 43 | + "dependencies": ["openregister"], |
| 44 | + "menu": [ |
| 45 | + { "id": "apps", "label": "softwarecatalog.menu.apps", "icon": "icon-apps", "route": "/apps", "section": "main", "order": 10 }, |
| 46 | + { "id": "components", "label": "softwarecatalog.menu.components", "icon": "icon-component", "route": "/components", "section": "main", "order": 20 }, |
| 47 | + { "id": "organisations", "label": "softwarecatalog.menu.organisations", "icon": "icon-organisation", "route": "/organisations", "section": "main", "order": 30 }, |
| 48 | + { "id": "catalogs", "label": "softwarecatalog.menu.catalogs", "icon": "icon-catalog", "route": "/catalogs", "section": "main", "order": 40 }, |
| 49 | + { "id": "concept-organisations", "label": "softwarecatalog.menu.conceptOrganisations", "icon": "icon-concept", "route": "/concept-organisations", "section": "main", "order": 50 }, |
| 50 | + { "id": "settings", "label": "softwarecatalog.menu.settings", "icon": "icon-settings", "route": "/settings", "section": "settings", "permission": "admin" } |
| 51 | + ], |
| 52 | + "pages": [ |
| 53 | + { |
| 54 | + "id": "apps-index", |
| 55 | + "route": "/apps", |
| 56 | + "type": "index", |
| 57 | + "title": "softwarecatalog.pages.apps", |
| 58 | + "config": { |
| 59 | + "register": "@resolve:apps_register", |
| 60 | + "schema": "@resolve:apps_schema", |
| 61 | + "columns": ["name", "organisation", "status", "version"] |
| 62 | + } |
| 63 | + }, |
| 64 | + { |
| 65 | + "id": "apps-detail", |
| 66 | + "route": "/apps/:id", |
| 67 | + "type": "detail", |
| 68 | + "title": "softwarecatalog.pages.app", |
| 69 | + "config": { |
| 70 | + "register": "@resolve:apps_register", |
| 71 | + "schema": "@resolve:apps_schema" |
| 72 | + }, |
| 73 | + "slots": { "sidebar": "AppSidebar" } |
| 74 | + } |
| 75 | + // ... components, organisations, catalogs (similar shape) |
| 76 | + , { |
| 77 | + "id": "concept-organisations-index", |
| 78 | + "route": "/concept-organisations", |
| 79 | + "type": "custom", |
| 80 | + "title": "softwarecatalog.pages.conceptOrganisations", |
| 81 | + "component": "ConceptOrganisationsPage" |
| 82 | + } |
| 83 | + , { |
| 84 | + "id": "settings", |
| 85 | + "route": "/settings", |
| 86 | + "type": "custom", |
| 87 | + "title": "softwarecatalog.pages.settings", |
| 88 | + "component": "SettingsPage" |
| 89 | + } |
| 90 | + ] |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +Notes: |
| 95 | +- `@resolve:{key}` sentinel: same convention as LarpingApp adoption |
| 96 | + change. The renderer pre-processor runs `RegisterResolverService` |
| 97 | + (or its frontend equivalent) at render time. |
| 98 | +- `slots: { sidebar: "AppSidebar" }` registers |
| 99 | + `src/sidebars/AppSidebar.vue` for the apps detail page. |
| 100 | + |
| 101 | +### `src/composables/orClient.js` (new file) |
| 102 | + |
| 103 | +Identical contract to LarpingApp's `orClient.js`: |
| 104 | + |
| 105 | +```js |
| 106 | +export function useOrClient () { |
| 107 | + const baseUrl = '/index.php/apps/openregister/api' |
| 108 | + |
| 109 | + async function fetchObject ({ register, schema, uuid }) { |
| 110 | + const lang = OC.getLocale().split('_')[0] |
| 111 | + const url = `${baseUrl}/objects/${register}/${schema}/${uuid}?_lang=${lang}` |
| 112 | + return axios.get(url, { headers: buildHeaders() }) |
| 113 | + } |
| 114 | + |
| 115 | + async function patchObject ({ register, schema, uuid, body, targetLang }) { |
| 116 | + const url = `${baseUrl}/objects/${register}/${schema}/${uuid}` |
| 117 | + const headers = buildHeaders() |
| 118 | + if (targetLang) headers['X-Translation-Target-Language'] = targetLang |
| 119 | + return axios.patch(url, body, { headers }) |
| 120 | + } |
| 121 | + |
| 122 | + return { fetchObject, patchObject } |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +### Service migrations (Phase 2) |
| 127 | + |
| 128 | +All five files follow the same pattern: inject |
| 129 | +`RegisterResolverService` and replace the resolver call. |
| 130 | + |
| 131 | +`lib/Service/ModuleComplianceService.php`: |
| 132 | + |
| 133 | +```php |
| 134 | +// before |
| 135 | +$register = $this->config->getValueString('softwarecatalog', 'modules_register', ''); |
| 136 | +$schema = $this->config->getValueString('softwarecatalog', 'modules_schema', ''); |
| 137 | + |
| 138 | +// after |
| 139 | +$pair = $this->resolver->resolveForObjectType('modules'); |
| 140 | +[$register, $schema] = [$pair->registerId, $pair->schemaId]; |
| 141 | +``` |
| 142 | + |
| 143 | +Same shape for the other four classes with their respective |
| 144 | +object types: `gebruik`, `organisations`, `views`, |
| 145 | +`user-profile-organisation`. |
| 146 | + |
| 147 | +### Migration risk surface |
| 148 | + |
| 149 | +| Risk | Mitigation | |
| 150 | +|------|-----------| |
| 151 | +| `RegisterResolverService` not yet deployed | DI null-check fallback to legacy `getValueString` (Phase 2) plus deprecation log. | |
| 152 | +| 5-file resolver migration introduces typo / incorrect object-type string | Unit tests assert each service's `resolveForObjectType` argument matches the existing config-key naming convention. | |
| 153 | +| Manifest validation fails CI on first introduction | Tier 2 keeps router hand-wired; failed validation does not break runtime. | |
| 154 | +| Tenant switch on detail page may interrupt user mid-edit | Detail navigates back; pending edits are lost (existing UX). Document in spec; future change MAY add an unsaved-changes guard. | |
| 155 | +| External feeds (GEMMA, GitHub) used by sync services have their own auth context — confused with NC tenant context | Sync services run server-side; `useTenantContext()` is frontend-only. No conflict. | |
| 156 | +| `concept-organisations` page differs from other entity pages | `type: "custom"` covers the difference; widget keeps its existing implementation. | |
| 157 | + |
| 158 | +## Open design questions |
| 159 | + |
| 160 | +1. **Q1 — `@resolve:{key}` sentinel.** Same as LarpingApp Q1. |
| 161 | + Local pre-processor for now; upstream when ≥2 apps need it. |
| 162 | + |
| 163 | +2. **Q2 — Multi-tenancy gating.** Same as LarpingApp Q2. Ship |
| 164 | + Phases 1-3 first; Phase 4 trails the nc-vue release. |
| 165 | + |
| 166 | +3. **Q3 — Sidebar slot conventions.** Apps detail page registers |
| 167 | + `slot.sidebar = AppSidebar`. Should organisations / catalogs |
| 168 | + also expose sidebars by default, or keep them off until the user |
| 169 | + opts in? Recommend default-on for the entities that have sidebars |
| 170 | + today (apps, organisations); off for the others. |
| 171 | + |
| 172 | +4. **Q4 — Concept-organisations page.** The widget pulls from an |
| 173 | + external feed (GEMMA / GitHub). It's modelled as `type: "custom"` |
| 174 | + in this change. Should we declare it as `type: "index"` with a |
| 175 | + pseudo-source URN (e.g. |
| 176 | + `softwarecatalog:concept-organisations`) so the page-type |
| 177 | + contract is uniform? Recommend: stay `custom` until nc-vue grows |
| 178 | + a "data source URN" concept. |
| 179 | + |
| 180 | +5. **Q5 — User-profile event listener resolver injection.** |
| 181 | + `UserProfileUpdatedEventListener` reacts to a Nextcloud user |
| 182 | + profile event and writes a SoftwareCatalog organisation |
| 183 | + membership row. Should the listener inject the resolver in |
| 184 | + its constructor (DI-via-app-container) or fetch it lazily from |
| 185 | + the container? Recommend constructor injection for testability. |
| 186 | + |
| 187 | +6. **Q6 — Sync service interval keys.** Each sync service has |
| 188 | + non-register `getValueString` keys for cron interval, retry |
| 189 | + policy, feature flags. Phase 2.4 keeps these on `IAppConfig`. |
| 190 | + Should we collect them under a `softwarecatalog/openspec/specs/ |
| 191 | + sync-tunables/spec.md` so a future audit doesn't re-flag them as |
| 192 | + "hardcoded keys"? Recommend yes — small extra capability spec |
| 193 | + that documents the intentional separation. |
| 194 | + |
| 195 | +7. **Q7 — Translation target on sync writes.** When sync services |
| 196 | + write to OR (e.g. updating an application's description from |
| 197 | + GitHub), should they stamp `X-Translation-Target-Language`? |
| 198 | + GitHub README content is typically English. Recommend: yes, |
| 199 | + stamp `en` so OR's i18n source-of-truth tracks where the |
| 200 | + content came from. |
0 commit comments