import { DestroyRef, effect, inject } from '@angular/core'; import { CategoryParams, UploadAdapter, uploadContentUrl, } from '@shared/infrastructure/upload.adapter'; import { UploadShellService } from './upload-shell.service'; import { problemDetail } from '@shared/infrastructure/api-error'; import { SUBMIT_FAILED } from '@shared/application/submit'; import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason, } from '@shared/domain/upload.machine'; export interface UploadControllerDeps { wizardId: string; getUpload: () => UploadState; dispatch: (m: UploadMsg) => void; /** Optional answer-derived params; when they change, categories re-fetch. */ getCategoryParams?: () => CategoryParams; } /** * The effectful glue between the `` organism's events and the * pure upload reducer + transport. Both wizards instantiate one (in a field * initializer, like `createStore`). Holds the only state a reducer can't: the live * `File` blobs keyed by localId (needed to retry an upload). Loads categories, * reports background-sync availability, and polls on tab refocus. */ export function createUploadController(deps: UploadControllerDeps) { const adapter = inject(UploadAdapter); const shell = inject(UploadShellService); const files = new Map(); const categoriesRes = adapter.categoriesResource(deps.wizardId, deps.getCategoryParams); // Runs after the host's `Seed`/restore microtask (effects fire in CD, not field // init), so these dispatches aren't wiped by a state reseed. effect(() => { deps.dispatch({ type: 'BackgroundSyncAvailability', available: shell.backgroundSyncAvailable }); const status = categoriesRes.status(); if (status === 'resolved' || status === 'local') { deps.dispatch({ type: 'CategoriesLoaded', categories: categoriesRes.value() ?? [] }); } else if (status === 'error') { deps.dispatch({ type: 'CategoriesLoadFailed', reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED), }); } }); const onFocus = () => void shell.pollReturning(inFlight(deps.getUpload()), deps.dispatch); window.addEventListener('focus', onFocus); inject(DestroyRef).onDestroy(() => window.removeEventListener('focus', onFocus)); function start(categoryId: string, file: File) { const localId = crypto.randomUUID(); files.set(localId, file); deps.dispatch({ type: 'FileSelected', categoryId, localId, fileName: file.name, fileSizeMb: file.size / 1e6, }); shell.upload({ localId, categoryId, wizardId: deps.wizardId, file }, deps.dispatch); } return { /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids have no stored bytes, so they get no link. */ previewUrlFor(documentId: string): string | undefined { return documentId.startsWith('demo-') ? undefined : uploadContentUrl(documentId); }, onFileSelected(categoryId: string, selected: File[]) { const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId); if (!cat) return; if (!cat.multiple && selected.length > 1) { deps.dispatch({ type: 'FileRejected', categoryId, reason: 'multiple' }); return; } for (const file of selected) { const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 }); if (reason) deps.dispatch({ type: 'FileRejected', categoryId, reason }); else start(categoryId, file); } }, onRemove(localId: string) { shell.cancel([localId]); files.delete(localId); deps.dispatch({ type: 'UploadRemoved', localId }); }, onRetry(localId: string) { const file = files.get(localId); const up = deps.getUpload().uploads.find((u) => u.localId === localId); if (!file || !up) return; deps.dispatch({ type: 'UploadRetried', localId }); shell.upload( { localId, categoryId: up.categoryId, wizardId: deps.wizardId, file }, deps.dispatch, ); }, onDelete(e: { localId: string; documentId: string }) { shell.delete(e.localId, e.documentId, deps.dispatch); }, onChannelChange(categoryId: string, channel: DeliveryChannel) { const ids = deps .getUpload() .uploads.filter((u) => u.categoryId === categoryId) .map((u) => u.localId); shell.cancel(ids); deps.dispatch({ type: 'DeliveryChannelChanged', categoryId, channel }); }, }; }