One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
3.8 KiB
TypeScript
100 lines
3.8 KiB
TypeScript
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 });
|
|
}
|
|
}
|