Files
atomic-design-poc/libs/shared/src/application/upload-controller.ts
T
ehoandClaude Opus 5 9520d6c24e refactor(shared): move upload/ into infrastructure/domain/application (RB-24)
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.

Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.

Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.

npm run ci passes; dep:check is clean for both apps with the carve-out gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:41 +02:00

118 lines
4.4 KiB
TypeScript

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 `<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, 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 });
},
};
}