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
@@ -0,0 +1,221 @@
import { Injectable, computed, inject, resource } from '@angular/core';
import {
ApiClient,
DocumentCategoryDto,
UploadStatusItemDto,
} from '@shared/infrastructure/api-client';
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 '@shared/domain/upload.machine';
/** Answer-derived query params that affect which categories the server presents. */
export interface CategoryParams {
diplomaHerkomst?: string;
taalvaardigheid?: string;
}
/** One arrived/known status item from poll-on-return. */
export interface StatusItem {
localId: string;
status: string; // 'complete' | 'unknown'
documentId?: string;
}
export interface XhrUploadRequest {
localId: string;
categoryId: string;
wizardId: string;
file: File;
}
/** A live upload: progress is reported via the callback; cancel aborts the XHR. */
export interface XhrUploadHandle {
done: Promise<{ documentId: string }>;
cancel: () => void;
}
/** Sentinel rejection so the caller can tell a user-cancel from a real failure. */
export const UPLOAD_ABORTED = Symbol('upload-aborted');
/** Direct URL to a stored document's bytes. Pure (no injection) so a store can build
a letterhead-logo `src` without pulling `ApiClient` into its dependency graph. */
export function uploadContentUrl(documentId: string): string {
return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`;
}
/**
* Infrastructure: the only place upload HTTP lives. Reads (categories, status,
* delete) go through the NSwag client; the multipart POST is hand-written XHR
* because the generated client is JSON-only (the endpoint is ExcludeFromDescription)
* and we need upload-progress events + cancellation, which fetch can't give us.
*/
@Injectable({ providedIn: 'root' })
export class UploadAdapter {
private client = inject(ApiClient);
/** Categories are server-owned; some depend on answers (e.g. registratie's diploma
set varies by DUO/handmatig + course language). `extra` makes the resource reactive
so it re-fetches when those answers change. */
categoriesResource(wizardId: string, extra?: () => CategoryParams) {
// Structural equality so we re-fetch only when a category-affecting answer changes,
// not on every unrelated draft edit (a computed with `equal` returns the cached
// reference while equal, so the resource sees no change).
const params = computed(
() => ({
wizardId,
diplomaHerkomst: extra?.().diplomaHerkomst,
taalvaardigheid: extra?.().taalvaardigheid,
}),
{
equal: (a, b) =>
a.wizardId === b.wizardId &&
a.diplomaHerkomst === b.diplomaHerkomst &&
a.taalvaardigheid === b.taalvaardigheid,
},
);
return resource({
params,
loader: ({ params }) =>
this.client
.categories(params.wizardId, params.diplomaHerkomst, params.taalvaardigheid)
.then((r) => (r.categories ?? []).map(toCategory)),
});
}
/** Poll-on-return: which client localIds have arrived at the BFF. */
status(localIds: string[]): Promise<StatusItem[]> {
if (localIds.length === 0) return Promise.resolve([]);
return this.client.status(localIds.join(',')).then((r) => (r.results ?? []).map(toStatusItem));
}
/** User delete; throws a ProblemDetails (e.g. 409 once linked) the caller maps. */
deleteDocument(documentId: string): Promise<void> {
return this.client.uploads(documentId);
}
/**
* Direct URL to the stored bytes, for a preview/download link (`<a href>`) — the
* server serves it inline for pdf/image, attachment otherwise. Not a fetch: the
* browser opens it. demo-* ids (dev simulation) have no bytes and 404 here.
*/
contentUrl(documentId: string): string {
return uploadContentUrl(documentId);
}
/**
* Multipart upload with progress + cancel. ponytail: XHR (not fetch keepalive) —
* progress events need XHR; the keepalive/background gap is covered by
* poll-on-return (UploadShellService.pollReturning). Swap in a Service Worker
* transport behind UploadTransport for true background sync.
*/
xhrUpload(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle {
const scenario = currentScenario();
if (scenario === 'upload-slow' || scenario === 'upload-fail')
return simulateUpload(scenario, onProgress);
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('file', req.file);
form.append('categoryId', req.categoryId);
form.append('localId', req.localId);
form.append('wizardId', req.wizardId);
let aborted = false;
const done = new Promise<{ documentId: string }>((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve({ documentId: JSON.parse(xhr.responseText).documentId });
} catch {
reject(genericError());
}
} else {
reject(parseError(xhr.responseText));
}
});
xhr.addEventListener('error', () => reject(genericError()));
xhr.addEventListener('abort', () =>
aborted ? reject(UPLOAD_ABORTED) : reject(genericError()),
);
});
xhr.open('POST', `${environment.apiBaseUrl}/api/v1/uploads`);
// WP-74: this XHR bypasses `HttpClient`'s `subjectInterceptor` (the same reason
// `letter-preview.adapter.ts` sets `X-Role` explicitly) — without `X-Subject` a
// document always uploaded under `DocumentStore.DemoOwner` regardless of who was
// actually logged in, so a submission attempted under any other BSN would find
// its own required document "missing" (owned by someone else).
const subject = currentSubject();
if (subject) xhr.setRequestHeader('X-Subject', subject);
xhr.send(form);
return { done, cancel: () => ((aborted = true), xhr.abort()) };
}
}
const UPLOAD_FAILED = $localize`:@@upload.failed:Uploaden is niet gelukt. Probeer het opnieuw.`;
const genericError = (): string => UPLOAD_FAILED;
/**
* Demo-only (dev): the real XHR POST finishes instantly for metadata, so progress
* and failure can't otherwise be shown. Drives the progress bar over ~2.5s, then
* succeeds (`upload-slow`) or fails (`upload-fail`). ponytail: timer-based, cancel
* via the returned handle; no network.
*/
function simulateUpload(
scenario: 'upload-slow' | 'upload-fail',
onProgress: (pct: number) => void,
): XhrUploadHandle {
let pct = 0;
let cancelled = false;
const done = new Promise<{ documentId: string }>((resolve, reject) => {
const tick = () => {
if (cancelled) {
reject(UPLOAD_ABORTED);
} else if (pct < 100) {
pct += 10;
onProgress(Math.min(pct, 100));
setTimeout(tick, 250);
} else if (scenario === 'upload-fail') {
reject(UPLOAD_FAILED);
} else {
resolve({ documentId: `demo-${crypto.randomUUID()}` });
}
};
setTimeout(tick, 250);
});
return { done, cancel: () => void (cancelled = true) };
}
const parseError = (body: string): string => {
try {
return problemDetail(JSON.parse(body), UPLOAD_FAILED);
} catch {
return genericError();
}
};
/** Wire DTO (all fields optional) → domain. */
function toCategory(c: DocumentCategoryDto): DocumentCategory {
return {
categoryId: c.categoryId ?? '',
label: c.label ?? '',
description: c.description ?? '',
required: c.required ?? false,
acceptedTypes: c.acceptedTypes ?? [],
maxSizeMb: c.maxSizeMb ?? 0,
multiple: c.multiple ?? false,
allowPostDelivery: c.allowPostDelivery ?? false,
};
}
function toStatusItem(s: UploadStatusItemDto): StatusItem {
return {
localId: s.localId ?? '',
status: s.status ?? 'unknown',
documentId: s.documentId ?? undefined,
};
}