Upload feature (b): HTTP adapter (XHR multipart) + shell transport service

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 14:01:36 +02:00
parent 0e48f44773
commit c4bfe9d39b
2 changed files with 212 additions and 0 deletions

View File

@@ -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<string, () => 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<void> {
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 });
}
}

View File

@@ -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<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);
}
/**
* 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 };
}