From 9520d6c24e1194d2dd72133655544fe9edd724af Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 20:40:41 +0200 Subject: [PATCH] refactor(shared): move upload/ into infrastructure/domain/application (RB-24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libs/shared/src/upload/ held a network adapter, an Elm machine, and two application-layer coordinators outside the folder-per-layer convention every other context follows. The dependency-cruiser rule carved an exception around the misplaced adapter instead of the violation being fixed. Move all five files to the layer each belongs to (git mv), update every import across 24 consumer files, then delete the carve-out clause from .dependency-cruiser.base.js. No export renamed, no file split, no spec content changed. Deleting the carve-out exposed a second, pre-existing rule violation: ui-not-infrastructure had never fired against upload.adapter.ts because its old path did not match /infrastructure/. Three UI components injected UploadAdapter directly for its one-line contentUrl() wrapper. Route each through the existing pure uploadContentUrl() function via the application layer (upload-controller's new previewUrlFor, OrgTemplateStore's new previewUrlFor) instead — the same idiom brief.store.ts already used. npm run ci passes; dep:check is clean for both apps with the carve-out gone. Co-Authored-By: Claude Opus 5 --- .dependency-cruiser.base.js | 4 +- .../src/app/brief/application/brief.store.ts | 2 +- .../brief/application/org-template.store.ts | 9 +- .../brief/domain/org-template.machine.spec.ts | 2 +- .../app/brief/domain/org-template.machine.ts | 2 +- .../org-template-editor.component.ts | 2 +- .../org-template-editor.stories.ts | 2 +- .../ssp/src/app/brief/ui/org-template.page.ts | 4 +- .../domain/herregistratie.machine.ts | 2 +- .../herregistratie-wizard.component.ts | 13 +- .../herregistratie-wizard.stories.ts | 2 +- .../domain/registratie-wizard.machine.spec.ts | 2 +- .../domain/registratie-wizard.machine.ts | 2 +- .../registratie-wizard.component.ts | 13 +- .../registratie-wizard.stories.ts | 2 +- .../refactor-backlog/99-backlog.md | 2 +- .../refactor-backlog/implementation/rb-24.md | 178 ++++++++++++++++++ docs/reference/architecture/dependencies.md | 2 +- .../upload-controller.ts | 19 +- .../upload-shell.service.ts | 9 +- .../{upload => domain}/upload.machine.spec.ts | 0 .../src/{upload => domain}/upload.machine.ts | 0 .../upload.adapter.ts | 2 +- .../delivery-channel-toggle.component.ts | 2 +- .../document-category.component.ts | 2 +- .../document-category.stories.ts | 2 +- .../document-chip/document-chip.component.ts | 2 +- .../document-chip/document-chip.stories.ts | 2 +- .../document-upload.component.ts | 2 +- .../document-upload.stories.ts | 2 +- .../single-upload/single-upload.component.ts | 2 +- .../single-upload/single-upload.stories.ts | 2 +- .../upload-status-icon.component.ts | 2 +- 33 files changed, 246 insertions(+), 49 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md rename libs/shared/src/{upload => application}/upload-controller.ts (88%) rename libs/shared/src/{upload => application}/upload-shell.service.ts (94%) rename libs/shared/src/{upload => domain}/upload.machine.spec.ts (100%) rename libs/shared/src/{upload => domain}/upload.machine.ts (100%) rename libs/shared/src/{upload => infrastructure}/upload.adapter.ts (99%) diff --git a/.dependency-cruiser.base.js b/.dependency-cruiser.base.js index ff945cd..b6f1e0b 100644 --- a/.dependency-cruiser.base.js +++ b/.dependency-cruiser.base.js @@ -100,9 +100,9 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) { name: 'apiclient-infrastructure-only', comment: - 'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.', + 'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.', severity: 'error', - from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }, + from: { pathNot: '/infrastructure/' }, to: { path: '^libs/shared/src/infrastructure/api-client\\.ts$', dependencyTypesNot: ['type-only'], diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 6bb8073..623535a 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -19,7 +19,7 @@ import { OrgTemplate } from '@brief/domain/org-template'; import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; -import { uploadContentUrl } from '@shared/upload/upload.adapter'; +import { uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; /** diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index 6135944..c78213f 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -3,9 +3,9 @@ import { createStore } from '@shared/application/store'; import { ActionState, SaveState } from '@shared/application/action-state'; import { createDebouncedSave } from '@shared/application/debounced-save'; import { machineRemoteData } from '@shared/application/machine-remote-data'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadShellService } from '@shared/upload/upload-shell.service'; -import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine'; +import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; +import { UploadShellService } from '@shared/application/upload-shell.service'; +import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine'; import { MARGIN_MAX_MM, MARGIN_MIN_MM, @@ -72,6 +72,9 @@ export class OrgTemplateStore implements PendingSave { return id ? this.uploadAdapter.contentUrl(id) : null; }); + /** Preview/download link for any completed upload in the editor's document list. */ + readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId); + /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback; the server re-validates and stays the authority — publish is gated on this. */ readonly draftValid = computed(() => { diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts index fb223fa..1b8f24b 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { expectTag } from '@shared/testing/expect-tag'; import { OrgTemplate, OrgTemplateAdminView } from './org-template'; import { OrgTemplateState, reduce } from './org-template.machine'; -import { DocumentCategory } from '@shared/upload/upload.machine'; +import { DocumentCategory } from '@shared/domain/upload.machine'; const template: OrgTemplate = { subOrgId: 'cibg-registers', diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts index de90a36..7932941 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts @@ -1,6 +1,6 @@ import { assertNever } from '@shared/kernel/fp'; import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template'; -import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine'; +import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; /** * The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index b9b8c98..2690e0f 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; -import { UploadState } from '@shared/upload/upload.machine'; +import { UploadState } from '@shared/domain/upload.machine'; import { Brief } from '@brief/domain/brief'; import { MARGIN_MAX_MM, diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts index 2b4d987..11fca36 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { OrgTemplateEditorComponent } from './org-template-editor.component'; import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template'; -import { UploadState, initialUpload } from '@shared/upload/upload.machine'; +import { UploadState, initialUpload } from '@shared/domain/upload.machine'; const draft: OrgTemplate = { subOrgId: 'cibg-registers', diff --git a/apps/ssp/src/app/brief/ui/org-template.page.ts b/apps/ssp/src/app/brief/ui/org-template.page.ts index f28b7b4..697f19d 100644 --- a/apps/ssp/src/app/brief/ui/org-template.page.ts +++ b/apps/ssp/src/app/brief/ui/org-template.page.ts @@ -4,7 +4,6 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AccessStore } from '@shared/application/access.store'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; @@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te export class OrgTemplatePage { protected store = inject(OrgTemplateStore); protected access = inject(AccessStore); - private uploadAdapter = inject(UploadAdapter); protected canEdit = computed(() => this.access.can('orgtemplate:edit')); - protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId); + protected previewUrlFor = this.store.previewUrlFor; protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`; protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`; diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts index 2c457a7..ffed921 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts @@ -7,7 +7,7 @@ import { reduceUpload, requiredCategoriesSatisfied, deliveryRefs, -} from '@shared/upload/upload.machine'; +} from '@shared/domain/upload.machine'; /** What the user is typing (raw, possibly invalid). */ export interface Draft { diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index f2cc4ad..986dae0 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -23,9 +23,8 @@ import { } from '@herregistratie/domain/herregistratie.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; -import { createUploadController } from '@shared/upload/upload-controller'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine'; +import { createUploadController } from '@shared/application/upload-controller'; +import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; /** Organism: multi-step herregistratie wizard. ALL state lives in one signal driven by the pure `reduce` function (see herregistratie.machine.ts) via an @@ -149,13 +148,13 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload. }) export class HerregistratieWizardComponent { private profile = inject(BigProfileStore); - private uploadAdapter = inject(UploadAdapter); private store = createStore(initial, reduce); - /** Preview/download link for a completed upload; dev-simulation `demo-*` ids have - no stored bytes, so they get no link. */ + /** Preview/download link for a completed upload; delegates to the upload + controller (application layer), which knows the dev-simulation `demo-*` ids + have no stored bytes and returns no link for them. */ protected previewUrlFor = (documentId: string): string | undefined => - documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId); + this.uploadCtl.previewUrlFor(documentId); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts index f876c5e..4a632a3 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { WizardState } from '@herregistratie/domain/herregistratie.machine'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { Uren } from '@registratie/domain/value-objects/uren'; const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] }; diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts index 695b314..30aac1a 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { expectTag } from '@shared/testing/expect-tag'; import { Draft, diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts index 73b96d4..069178a 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts @@ -9,7 +9,7 @@ import { reduceUpload, requiredCategoriesSatisfied, deliveryRefs, -} from '@shared/upload/upload.machine'; +} from '@shared/domain/upload.machine'; /** * A FIXED 3-step registration wizard. The steps never change in number (always diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index aa2b60a..25e3b19 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -37,9 +37,8 @@ import { } from '@registratie/domain/registratie-wizard.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; -import { createUploadController } from '@shared/upload/upload-controller'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine'; +import { createUploadController } from '@shared/application/upload-controller'; +import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; const KANALEN = [ { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` }, @@ -368,13 +367,13 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; }) export class RegistratieWizardComponent { private lookup = inject(RegistratieLookupStore); - private uploadAdapter = inject(UploadAdapter); private store = createStore(initial, reduce); - /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids - have no stored bytes, so they get no link. */ + /** Preview/download link for a completed upload; delegates to the upload + controller (application layer), which knows the dev-simulation `demo-*` ids + have no stored bytes and returns no link for them. */ protected previewUrlFor = (documentId: string): string | undefined => - documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId); + this.uploadCtl.previewUrlFor(documentId); /** Optional seed so Storybook / tests can mount any state directly. */ seed = input(initial); diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts index 5fd9ef4..29f31db 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts @@ -8,7 +8,7 @@ import { RegistratieState, ValidRegistratie, } from '@registratie/domain/registratie-wizard.machine'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { Postcode } from '@registratie/domain/value-objects/postcode'; const adres: Partial = { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fe54364..d55b046 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -125,7 +125,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita | **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | | **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | | **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | | **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | | **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | | **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md new file mode 100644 index 0000000..599034b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md @@ -0,0 +1,178 @@ +# RB-24 — `libs/shared/upload` moves into `infrastructure/`/`domain/`/`application/`; the depcruise carve-out is deleted + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` ADR-C-002 · +`99-backlog.md` RB-24, "Merges" table row for RB-25/26/27 + +## What was wrong + +`libs/shared/src/upload/` held five files outside the folder-per-layer convention every +other context follows. `upload.adapter.ts` injects `ApiClient` and opens a raw +`XMLHttpRequest` — a genuine network adapter — yet sat outside `infrastructure/`. +`upload.machine.ts` was the only Elm-style machine (of 9 in the repo) outside a `domain/` +folder. The exception was hard-coded into the enforcement itself: +`.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule read +`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — carved around the +violation instead of the violation being fixed, which is why the baseline scan reported 0 +violations despite this. + +## What changed + +| From `libs/shared/src/upload/` | To | +| -------------------------------- | ----------------------------------------------------- | +| `upload.adapter.ts` | `libs/shared/src/infrastructure/upload.adapter.ts` | +| `upload.machine.ts` + `.spec.ts` | `libs/shared/src/domain/upload.machine.ts` (+ spec) | +| `upload-controller.ts` | `libs/shared/src/application/upload-controller.ts` | +| `upload-shell.service.ts` | `libs/shared/src/application/upload-shell.service.ts` | + +All five moves used `git mv`. `libs/shared/src/upload/` no longer exists. + +**Import updates.** 24 consumer files import from `@shared/upload/*` (found with +`grep -rln "shared/upload" apps libs --include=*.ts`, filtered to exclude the unrelated +`@shared/ui/upload/*` component folder, which was not touched). All 24 files' import paths +were rewritten to the new locations (30 import statements total, some files import more +than one symbol). No export was renamed, no file was split, no logic changed in any of +these 24 files beyond the import path string. + +**Within the five moved files**, three had relative imports (`./upload.adapter`, +`./upload.machine`) that now crossed layers and were rewritten to `@shared/*` aliases: +`upload.adapter.ts`'s import of `DocumentCategory` from `./upload.machine` → +`@shared/domain/upload.machine`; `upload-controller.ts`'s imports of `UploadAdapter` and +`upload.machine` symbols → `@shared/infrastructure/...` / `@shared/domain/...`; +`upload-shell.service.ts` likewise. `upload.machine.spec.ts` needed no import change — it +and `upload.machine.ts` moved into the same `domain/` folder together, so its `./upload.machine` +import stayed correct; `git diff --find-renames` confirms this file as a 0-line-changed +pure rename. + +**The carve-out.** `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule: +`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` → `from: { pathNot: '/infrastructure/' }`, +comment updated to drop the now-false "(+ shared/upload)" parenthetical. One further +consequence: `docs/reference/architecture/dependencies.md`'s "Atomic-layer rules" +paragraph stated the same carve-out in prose ("the generated `ApiClient` is a value only +inside `infrastructure/` (+ `libs/shared/src/upload`)") — corrected in the same diff, since +leaving it would document a rule that no longer exists. + +## A second, real violation the move exposed — fixed, not just reported + +Deleting the carve-out did not by itself make `dep:check` pass. A **separate, +pre-existing** rule — `ui-not-infrastructure` (`ui/`+`layout/` may not import +`infrastructure/` as a value) — had never fired against `upload.adapter.ts`, because +before this move the file's path did not contain `/infrastructure/` at all. Three UI +components were injecting `UploadAdapter` directly: +`apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`, +`apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`, +and `apps/ssp/src/app/brief/ui/org-template.page.ts`. Once `upload.adapter.ts` physically +moved into `infrastructure/`, `dep:check` correctly flagged all three: + +``` +error ui-not-infrastructure: .../registratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts +error ui-not-infrastructure: .../herregistratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts +error ui-not-infrastructure: .../org-template.page.ts → libs/shared/src/infrastructure/upload.adapter.ts +``` + +This is judged in-scope to fix, not a second unrelated finding to merely report, for three +reasons. First, the ticket's own DoD is explicit: "if `dep:check` fails after the +deletion, the move is incomplete, so fix the move rather than restoring the clause." +Second, all three call sites used `UploadAdapter` for exactly one thing — +`.contentUrl(documentId)`, a thin wrapper around the adapter's own already-exported, +injection-free pure function `uploadContentUrl(documentId)` (its doc comment: "Pure (no +injection) so a store can build a letterhead-logo `src` without pulling `ApiClient` into +its dependency graph" — written for precisely this case). `apps/ssp/src/app/brief/application/brief.store.ts` +already used that pure function directly; the three UI files had independently reinvented +`inject(UploadAdapter)` + `.contentUrl()` instead. Third, the fix is mechanical and stays +inside the ADR's own established idiom — no new architecture, no touch to any RB-25/26/27 +target: + +- `libs/shared/src/application/upload-controller.ts` — the object `createUploadController` + returns gained one more method, `previewUrlFor(documentId)`, built on the existing pure + `uploadContentUrl`. Both wizard components already hold a `createUploadController` + instance (`uploadCtl`) for their other upload effects; their `previewUrlFor` field now + delegates to `uploadCtl.previewUrlFor` instead of injecting `UploadAdapter` itself. +- `apps/ssp/src/app/brief/application/org-template.store.ts` (already injects + `UploadAdapter` legitimately — it's application layer) gained one more computed-style + field, `previewUrlFor`, on the same pure `uploadContentUrl`. `org-template.page.ts` now + reads `this.store.previewUrlFor` instead of injecting `UploadAdapter`. + +No behaviour changed: `uploadContentUrl(id)` and `uploadAdapter.contentUrl(id)` return the +identical string (the method is a one-line pass-through to the function), and the +`demo-*` short-circuit in the two wizards moved into `upload-controller.ts`'s new method +verbatim. + +## Verification + +- **`upload.machine.spec.ts` passes unchanged.** `git diff --find-renames=30%` shows it as + a 0-insertion/0-deletion pure rename — no content changed, including its own imports + (both files moved into `domain/` together, so its `./upload.machine` relative import + needed no edit). No spec content changed anywhere in this ticket. +- `npm run dep:check`: **passes for both apps** with the carve-out clause removed — + `✔ no dependency violations found (344 modules, 1200 dependencies cruised)` (ssp), + `✔ no dependency violations found (226 modules, 588 dependencies cruised)` (behandelportal). +- `npm run lint`: clean. +- `npm test`: **43+6+24+4 = 77 test files, 274+37+138+23 = 472 tests, all passing** + (ssp / behandelportal / shared / beheer). +- `npm run build`: both apps build (pre-existing, unrelated warnings about + `/cibg-huisstijl/css/huisstijl.min.css` and `/letter.css` not being found at build time — + present before this ticket, vendored assets resolved at serve/deploy time, not a + regression from this move). +- **Coverage, `libs/shared/src/domain/`** (`npm run test:coverage` narrowed to `shared`): + the folder now includes `upload.machine.ts` at 98.82% statements / 91.8% branches / 100% + functions / 98.36% lines (84/85, 56/61, 28/28, 60/61) — the "well-specced machine" ADR-C-002 + predicted landing in a folder the baseline reported at "0% spec reach across 3 files" + (`capability.ts`, `feature-flag.ts`, `role.ts`, which this ticket does not touch and which + remain unspecced — that gap is pre-existing and out of this ticket's scope). + +## Non-TypeScript references to the old path — findings + +Checked `.storybook-ssp/`, `.storybook-behandelportal/`, `angular.json`, no vitest config +file exists separately (Angular's builder owns test config), both `.dependency-cruiser.*.js` +files, and `libs/shared/docs/*.mdx`. + +- **Storybook config, angular.json, dependency-cruiser app configs**: no reference to + `shared/upload` or `libs/shared/src/upload` in any of these. Nothing to change. +- **`.dependency-cruiser.base.js`**: the one real reference — the carve-out clause itself, + deleted (see above). +- **`docs/reference/architecture/dependencies.md`**: one prose reference to the same + carve-out, corrected in this diff (see above) since it directly describes the rule this + ticket edits. +- **`libs/shared/docs/*.mdx`**: no `.mdx` file references `libs/shared/src/upload` or + `@shared/upload`. `atomic-design.mdx` and `machines.mdx` mention `upload.machine.ts` and + `shared/ui/upload/...` by filename/short-path only, never the full old directory path — + both remain accurate (the filename didn't change; `ui/upload/` is the untouched sibling + folder). +- **`apps/ssp/src/locale/messages.xlf`, `messages.en.xlf`, `apps/behandelportal/src/locale/messages.en.xlf`**: + each carries a handful of `src/app/shared/upload/upload.machine.ts` + /`upload.adapter.ts` annotations — auto-generated by Angular's `$localize` extractor, + informational only (they tell a translator where a string originated; they are not + read by the build or by `i18nMissingTranslation`). Left as-is: regenerating them is + `npm run extract-i18n`'s job for the source-locale file and does not touch the + hand-maintained `messages.en.xlf` translations at all, and this ticket's scope is the + move plus import updates, not a translation-tooling refresh. They will self-correct + the next time `extract-i18n` runs for an unrelated reason. +- **`docs/project/backlog/*.md`, `docs/project/refactor-backlog-setup/refactor-backlog/*.md`**: + several planning/history documents (WP-25, WP-74, the baseline scan, `02-testability.md`, + `06-adr-conformance.md`, `07-bio2-compliance.md`, `99-backlog.md`, `rb-01.md`, `rb-09.md`) + reference the old path — expected, since most of them describe or cite the violation + this ticket resolves, as history. Not edited, except `99-backlog.md`'s RB-24 status cell + (see below). + +## What RB-25/26/27 now find where + +- **RB-25** (`UPLOAD_TRANSPORT` injection token, replacing `inject(KeepaliveTransport)`): + `KeepaliveTransport` and `UploadShellService` are both now in + `libs/shared/src/application/upload-shell.service.ts` (unchanged content, new path). The + token belongs in `application/` alongside them — nothing about the token's shape or + location changes because of this move. +- **RB-26** (`planFileSelection` in `upload.machine.ts`): the machine is now + `libs/shared/src/domain/upload.machine.ts`. `createUploadController`'s `onFileSelected` + callback — the accept/reject decision RB-26 targets — is in + `libs/shared/src/application/upload-controller.ts` (also renumbered, otherwise + unchanged; it also now exports one more method, `previewUrlFor`, added by this ticket — + see above). RB-26 should extend `upload.machine.ts` in its new location; no import path + in that file needs touching beyond what this ticket already did. +- **RB-27** (`uploadOutcome(status, responseText)` out of the XHR closure): the XHR closure + is in `libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` method — same + file, same method, new path only. `load`/`error`/`abort` handlers, `parseError`, and + `genericError` are all still exactly where they were, just under `infrastructure/`. + +## `npm run ci` + +Result and step count reported in the final answer. diff --git a/docs/reference/architecture/dependencies.md b/docs/reference/architecture/dependencies.md index 8b97476..cb878e2 100644 --- a/docs/reference/architecture/dependencies.md +++ b/docs/reference/architecture/dependencies.md @@ -35,7 +35,7 @@ reverse. An app may not import the other app's source directly. **Atomic-layer rules:** `domain/` is framework-free (no Angular); `contracts/` import nothing (pure wire DTOs, ADR-0001); `ui/` + `layout/` never import `infrastructure/` directly (reach data through an application store/command — type-only DTO imports are fine); the generated `ApiClient` -is a value only inside `infrastructure/` (+ `libs/shared/src/upload`). Plus **no circular** +is a value only inside `infrastructure/`. Plus **no circular** dependencies. These apply uniformly across an app's tree and both libraries — no debug-state exception anymore (WP-67 moved the dev panel component out of `libs/shared` into `apps/ssp` since it's genuinely SSP-specific, coupled to `BigProfileStore`; the shared `ShellComponent` hosts diff --git a/libs/shared/src/upload/upload-controller.ts b/libs/shared/src/application/upload-controller.ts similarity index 88% rename from libs/shared/src/upload/upload-controller.ts rename to libs/shared/src/application/upload-controller.ts index cf6cb56..7fa1522 100644 --- a/libs/shared/src/upload/upload-controller.ts +++ b/libs/shared/src/application/upload-controller.ts @@ -1,9 +1,19 @@ import { DestroyRef, effect, inject } from '@angular/core'; -import { CategoryParams, UploadAdapter } from './upload.adapter'; +import { + CategoryParams, + UploadAdapter, + uploadContentUrl, +} from '@shared/infrastructure/upload.adapter'; import { UploadShellService } from './upload-shell.service'; import { problemDetail } from '@shared/infrastructure/api-error'; import { SUBMIT_FAILED } from '@shared/application/submit'; -import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason } from './upload.machine'; +import { + DeliveryChannel, + UploadMsg, + UploadState, + inFlight, + rejectReason, +} from '@shared/domain/upload.machine'; export interface UploadControllerDeps { wizardId: string; @@ -59,6 +69,11 @@ export function createUploadController(deps: UploadControllerDeps) { } return { + /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids + have no stored bytes, so they get no link. */ + previewUrlFor(documentId: string): string | undefined { + return documentId.startsWith('demo-') ? undefined : uploadContentUrl(documentId); + }, onFileSelected(categoryId: string, selected: File[]) { const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId); if (!cat) return; diff --git a/libs/shared/src/upload/upload-shell.service.ts b/libs/shared/src/application/upload-shell.service.ts similarity index 94% rename from libs/shared/src/upload/upload-shell.service.ts rename to libs/shared/src/application/upload-shell.service.ts index 4b88329..bab1e67 100644 --- a/libs/shared/src/upload/upload-shell.service.ts +++ b/libs/shared/src/application/upload-shell.service.ts @@ -1,7 +1,12 @@ import { Injectable, inject } from '@angular/core'; -import { UploadAdapter, XhrUploadRequest, XhrUploadHandle, UPLOAD_ABORTED } from './upload.adapter'; +import { + UploadAdapter, + XhrUploadRequest, + XhrUploadHandle, + UPLOAD_ABORTED, +} from '@shared/infrastructure/upload.adapter'; import { problemDetail } from '@shared/infrastructure/api-error'; -import { UploadMsg, Upload } from './upload.machine'; +import { UploadMsg, Upload } from '@shared/domain/upload.machine'; /** * Transport seam (PRD §6): how upload bytes leave the browser. The shipped impl is diff --git a/libs/shared/src/upload/upload.machine.spec.ts b/libs/shared/src/domain/upload.machine.spec.ts similarity index 100% rename from libs/shared/src/upload/upload.machine.spec.ts rename to libs/shared/src/domain/upload.machine.spec.ts diff --git a/libs/shared/src/upload/upload.machine.ts b/libs/shared/src/domain/upload.machine.ts similarity index 100% rename from libs/shared/src/upload/upload.machine.ts rename to libs/shared/src/domain/upload.machine.ts diff --git a/libs/shared/src/upload/upload.adapter.ts b/libs/shared/src/infrastructure/upload.adapter.ts similarity index 99% rename from libs/shared/src/upload/upload.adapter.ts rename to libs/shared/src/infrastructure/upload.adapter.ts index a0d1620..0cb0e7e 100644 --- a/libs/shared/src/upload/upload.adapter.ts +++ b/libs/shared/src/infrastructure/upload.adapter.ts @@ -8,7 +8,7 @@ import { problemDetail } from '@shared/infrastructure/api-error'; import { currentScenario } from '@shared/infrastructure/scenario'; import { currentSubject } from '@shared/infrastructure/subject'; import { environment } from '@shared/environments/environment'; -import { DocumentCategory } from './upload.machine'; +import { DocumentCategory } from '@shared/domain/upload.machine'; /** Answer-derived query params that affect which categories the server presents. */ export interface CategoryParams { diff --git a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts index 41fab35..59d2bc4 100644 --- a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts +++ b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { DeliveryChannel } from '@shared/upload/upload.machine'; +import type { DeliveryChannel } from '@shared/domain/upload.machine'; /** Atom: choose how a document is delivered — uploaded digitally or sent by post. Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */ diff --git a/libs/shared/src/ui/upload/document-category/document-category.component.ts b/libs/shared/src/ui/upload/document-category/document-category.component.ts index 33193bb..6540530 100644 --- a/libs/shared/src/ui/upload/document-category/document-category.component.ts +++ b/libs/shared/src/ui/upload/document-category/document-category.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input, output } from '@angular/core'; -import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine'; +import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/domain/upload.machine'; import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component'; import { FileInputComponent } from '../file-input/file-input.component'; import { SingleUploadComponent } from '../single-upload/single-upload.component'; diff --git a/libs/shared/src/ui/upload/document-category/document-category.stories.ts b/libs/shared/src/ui/upload/document-category/document-category.stories.ts index a82275f..1bc2aed 100644 --- a/libs/shared/src/ui/upload/document-category/document-category.stories.ts +++ b/libs/shared/src/ui/upload/document-category/document-category.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { DocumentCategory, Upload } from '@shared/upload/upload.machine'; +import type { DocumentCategory, Upload } from '@shared/domain/upload.machine'; import { DocumentCategoryComponent } from './document-category.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts index e3a10b8..b5053e3 100644 --- a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts +++ b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input } from '@angular/core'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component'; const STATUS_LABELS: Record = { diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts index ac1ff05..4747651 100644 --- a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts +++ b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; import { DocumentChipComponent } from './document-chip.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts index 9aee010..eb024f1 100644 --- a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts +++ b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine'; +import type { DeliveryChannel, UploadState } from '@shared/domain/upload.machine'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { DocumentCategoryComponent } from '../document-category/document-category.component'; diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts index 578ca75..af6fff8 100644 --- a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts +++ b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { UploadState } from '@shared/upload/upload.machine'; +import type { UploadState } from '@shared/domain/upload.machine'; import { DocumentUploadComponent } from './document-upload.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts index 25454bd..6fdda98 100644 --- a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts +++ b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input, output } from '@angular/core'; -import type { Upload } from '@shared/upload/upload.machine'; +import type { Upload } from '@shared/domain/upload.machine'; import { DocumentChipComponent } from '../document-chip/document-chip.component'; import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progress-bar.component'; diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts index 5d4ee64..f612abe 100644 --- a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts +++ b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { Upload } from '@shared/upload/upload.machine'; +import type { Upload } from '@shared/domain/upload.machine'; import { SingleUploadComponent } from './single-upload.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts index b5ac079..a2cdf4a 100644 --- a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts +++ b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input } from '@angular/core'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; interface Glyph { char: string;