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:
87
src/app/shared/upload/upload-shell.service.ts
Normal file
87
src/app/shared/upload/upload-shell.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
125
src/app/shared/upload/upload.adapter.ts
Normal file
125
src/app/shared/upload/upload.adapter.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user