diff --git a/src/app/shared/upload/upload-shell.service.ts b/src/app/shared/upload/upload-shell.service.ts new file mode 100644 index 0000000..56266c0 --- /dev/null +++ b/src/app/shared/upload/upload-shell.service.ts @@ -0,0 +1,87 @@ +import { Injectable, inject } from '@angular/core'; +import { UploadAdapter, XhrUploadRequest, XhrUploadHandle, UPLOAD_ABORTED } from './upload.adapter'; +import { problemDetail } from '@shared/infrastructure/api-error'; +import { UploadMsg, Upload } from './upload.machine'; + +/** + * Transport seam (PRD §6): how upload bytes leave the browser. The shipped impl is + * KeepaliveTransport (XHR for progress; poll-on-return covers the background gap). + * A ServiceWorkerTransport would set `backgroundSyncAvailable = true` and survive a + * closed tab — swapping it in touches only this interface. + */ +export interface UploadTransport { + readonly backgroundSyncAvailable: boolean; + send(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle; +} + +@Injectable({ providedIn: 'root' }) +class KeepaliveTransport implements UploadTransport { + private adapter = inject(UploadAdapter); + readonly backgroundSyncAvailable = false; // no Service Worker registered in this POC + send(req: XhrUploadRequest, onProgress: (pct: number) => void) { + return this.adapter.xhrUpload(req, onProgress); + } +} + +type Dispatch = (m: UploadMsg) => void; + +/** + * Orchestrates upload effects: drives the transport and translates outcomes into + * domain messages. Holds no UI state itself — the host wizard's reducer owns it. + * Effects only; the reducer stays pure. + */ +@Injectable({ providedIn: 'root' }) +export class UploadShellService { + private transport: UploadTransport = inject(KeepaliveTransport); + private adapter = inject(UploadAdapter); + private inflight = new Map void>(); // localId → cancel + + get backgroundSyncAvailable(): boolean { + return this.transport.backgroundSyncAvailable; + } + + /** Start (or retry) an upload: queue → progress → complete/failed. */ + upload(req: XhrUploadRequest, dispatch: Dispatch): void { + dispatch({ type: 'UploadQueued', localId: req.localId, backgroundSync: this.backgroundSyncAvailable }); + const { done, cancel } = this.transport.send(req, (pct) => + dispatch({ type: 'UploadProgress', localId: req.localId, progressPct: pct }), + ); + this.inflight.set(req.localId, cancel); + done + .then(({ documentId }) => dispatch({ type: 'UploadComplete', localId: req.localId, documentId })) + .catch((e) => { + if (e !== UPLOAD_ABORTED) { + dispatch({ type: 'UploadFailed', localId: req.localId, reason: typeof e === 'string' ? e : '' }); + } + }) + .finally(() => this.inflight.delete(req.localId)); + } + + /** Optimistic delete: mark deleting → complete (removed) / failed (reverted). */ + delete(localId: string, documentId: string, dispatch: Dispatch): void { + dispatch({ type: 'UploadDeleting', localId }); + this.adapter + .deleteDocument(documentId) + .then(() => dispatch({ type: 'UploadDeleteComplete', localId })) + .catch((e) => dispatch({ type: 'UploadDeleteFailed', localId, reason: problemDetail(e, '') })); + } + + /** Cancel any in-flight XHRs (e.g. when a category switches to post-delivery). */ + cancel(localIds: string[]): void { + for (const id of localIds) { + this.inflight.get(id)?.(); + this.inflight.delete(id); + } + } + + /** Poll-on-return: ask the BFF which still-in-flight uploads have arrived. */ + async pollReturning(uploads: Upload[], dispatch: Dispatch): Promise { + const ids = uploads.map((u) => u.localId); + if (ids.length === 0) return; + const items = await this.adapter.status(ids); + const results = items + .filter((i) => i.status === 'complete' && i.documentId) + .map((i) => ({ localId: i.localId, success: true as const, documentId: i.documentId! })); + if (results.length > 0) dispatch({ type: 'BackgroundUploadsReturned', results }); + } +} diff --git a/src/app/shared/upload/upload.adapter.ts b/src/app/shared/upload/upload.adapter.ts new file mode 100644 index 0000000..ef7ef85 --- /dev/null +++ b/src/app/shared/upload/upload.adapter.ts @@ -0,0 +1,125 @@ +import { Injectable, inject, resource } from '@angular/core'; +import { + ApiClient, + DocumentCategoryDto, + UploadStatusItemDto, +} from '@shared/infrastructure/api-client'; +import { problemDetail } from '@shared/infrastructure/api-error'; +import { environment } from '../../../environments/environment'; +import { DocumentCategory } from './upload.machine'; + +/** 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'); + +/** + * 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); + + categoriesResource(wizardId: string) { + return resource({ loader: () => this.client.categories(wizardId).then((r) => (r.categories ?? []).map(toCategory)) }); + } + + /** Poll-on-return: which client localIds have arrived at the BFF. */ + status(localIds: string[]): Promise { + 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 { + return this.client.uploads(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 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`); + 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; +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 }; +}