Upload feature (e): wire inline upload (registratie beroep) + documenten step (herregistratie)

- Fold UploadState into both wizard machines; route via { tag: 'Upload', msg }
- Gate step validation on requiredCategoriesSatisfied; include deliveryRefs in submit
- Shared createUploadController (effectful glue: categories, transport, focus-poll, File map)
- rejectReason pure format validator + specs; bump registratie storage key to v2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-06-30 08:38:37 +02:00
co-authored by Claude Opus 4.8
parent 9521739ac1
commit bfd957a6d4
13 changed files with 341 additions and 66 deletions
@@ -0,0 +1,88 @@
import { DestroyRef, effect, inject } from '@angular/core';
import { UploadAdapter } from './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 './upload.machine';
export interface UploadControllerDeps {
wizardId: string;
getUpload: () => UploadState;
dispatch: (m: UploadMsg) => void;
}
/**
* The effectful glue between the `<app-document-upload>` 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<string, File>();
const categoriesRes = adapter.categoriesResource(deps.wizardId);
// 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 {
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 });
},
};
}
@@ -9,6 +9,7 @@ import {
requiredCategoriesSatisfied,
deliveryRefs,
inFlight,
rejectReason,
} from './upload.machine';
const cat = (over: Partial<DocumentCategory> = {}): DocumentCategory => ({
@@ -217,6 +218,21 @@ describe('deliveryRefs', () => {
});
});
describe('rejectReason', () => {
it('rejects a disallowed type', () => {
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 })).toBe('type');
});
it('rejects an oversized file', () => {
expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe('size');
});
it('accepts a valid file', () => {
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 1 })).toBeNull();
});
it('allows any type when the category lists none', () => {
expect(rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 })).toBeNull();
});
});
describe('inFlight', () => {
it('returns only queued/uploading uploads', () => {
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
+10
View File
@@ -96,6 +96,16 @@ export function requiredCategoriesSatisfied(s: UploadState): boolean {
return s.categories.filter((c) => c.required).every((c) => categorySatisfied(s, c.categoryId));
}
/**
* FE format-validation (never authority — the server re-validates). Returns the
* rejection reason for one file, or null if it passes the category's type/size.
*/
export function rejectReason(cat: DocumentCategory, file: { type: string; sizeMb: number }): 'type' | 'size' | null {
if (cat.acceptedTypes.length > 0 && !cat.acceptedTypes.includes(file.type)) return 'type';
if (cat.maxSizeMb > 0 && file.sizeMb > cat.maxSizeMb) return 'size';
return null;
}
/** Map one upload's status, leaving the rest of the list untouched. */
function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState {
return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) };