import { Injectable, inject } from '@angular/core'; import { UploadAdapter, XhrUploadRequest, XhrUploadHandle, UPLOAD_ABORTED, } from '@shared/infrastructure/upload.adapter'; import { problemDetail } from '@shared/infrastructure/api-error'; import { UploadMsg, Upload } from '@shared/domain/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 }); } }