refactor(shared): move upload/ into infrastructure/domain/application (RB-24)

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 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 20:40:41 +02:00
co-authored by Claude Opus 5
parent 424ceb604b
commit 9520d6c24e
33 changed files with 246 additions and 49 deletions
+2 -2
View File
@@ -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'],
@@ -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';
/**
@@ -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(() => {
@@ -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',
@@ -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) —
@@ -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,
@@ -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',
@@ -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.`;
@@ -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 {
@@ -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<WizardState, WizardMsg>(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<WizardState>(initial);
@@ -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: [] };
@@ -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,
@@ -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
@@ -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<RegistratieState, RegistratieMsg>(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<RegistratieState>(initial);
@@ -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<Draft> = {
@@ -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**) | SM | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open |
@@ -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 `<context context-type="sourcefile">src/app/shared/upload/upload.machine.ts</context>`
/`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.
+1 -1
View File
@@ -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
@@ -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;
@@ -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
@@ -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 {
@@ -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. */
@@ -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';
@@ -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<DocumentCategoryComponent> = {
@@ -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<UploadStatus['type'], string> = {
@@ -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<DocumentChipComponent> = {
@@ -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';
@@ -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<DocumentUploadComponent> = {
@@ -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';
@@ -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<SingleUploadComponent> = {
@@ -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;