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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user