Some checks failed
CI / frontend (push) Failing after 57s
CI / storybook-a11y (push) Successful in 4m27s
CI / backend (push) Successful in 1m15s
CI / codeql (csharp) (push) Failing after 1m42s
CI / codeql (javascript-typescript) (push) Failing after 1m19s
CI / api-client-drift (push) Successful in 1m40s
CI / e2e (push) Failing after 3h11m33s
Edit the letter's org identity in place on the same canvas the drafter composes on (editableRegions='template'): letterhead/signature/footer become inline controls, content a read-only sample. Margins (bounded), logo upload (reuses the shared upload transport + single-upload), version history + rollback, proefbrief, and publish-with-impact-confirmation. House form-machine idiom (org-template.machine.ts) + root store with debounced save. Capability-gated (orgtemplate:edit) with a deny-by-default alert; route /brief/huisstijl. Backend + generated client were already in place (WP-23). Also fixes a pre-existing red check:tokens (WP-24 canvas hex fallbacks) and threads the published logo through to the drafter's canvas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
8.6 KiB
TypeScript
218 lines
8.6 KiB
TypeScript
import { Injectable, computed, inject, signal } from '@angular/core';
|
|
import { Result } from '@shared/kernel/fp';
|
|
import { RemoteData } from '@shared/application/remote-data';
|
|
import { createStore } from '@shared/application/store';
|
|
import {
|
|
Brief,
|
|
allDiagnostics,
|
|
canSubmit,
|
|
hasBlockingErrors,
|
|
unresolvedPlaceholders,
|
|
} from '@brief/domain/brief';
|
|
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
|
import { OrgTemplate } from '@brief/domain/org-template';
|
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
|
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
|
|
|
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
|
instead of a busy boolean + a nullable error sitting side by side. */
|
|
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
|
|
|
/** Debounced-autosave indicator, shown in a small status line near the toolbar —
|
|
a separate concern from ActionState (a stale autosave error doesn't block
|
|
submit/approve/reject), but tag-aligned with it for one consistent idiom. */
|
|
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
|
|
|
type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
|
|
|
|
/**
|
|
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
|
* read-model, and the commands (effects) that call the adapter and dispatch the
|
|
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
|
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
|
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
|
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class BriefStore {
|
|
private adapter = inject(BriefAdapter);
|
|
private previewAdapter = inject(LetterPreviewAdapter);
|
|
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
|
|
|
readonly model = this.store.model;
|
|
|
|
private actionState = signal<ActionState>({ tag: 'Idle' });
|
|
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
|
readonly lastError = computed(() => {
|
|
const s = this.actionState();
|
|
return s.tag === 'Failed' ? s.error : null;
|
|
});
|
|
|
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
|
|
|
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
|
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
|
stays untouched by design). Set from every server view that carries it. */
|
|
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
|
|
|
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
|
readonly logoUrl = computed<string | null>(() => {
|
|
const id = this.orgTemplate()?.logoDocumentId;
|
|
return id ? uploadContentUrl(id) : null;
|
|
});
|
|
|
|
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
|
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
|
purely a projection of its loading/failed tags onto the shared async seam. */
|
|
readonly remoteData = computed<RemoteData<Error | undefined, LoadedBriefState>>(() => {
|
|
const s = this.model();
|
|
switch (s.tag) {
|
|
case 'loading':
|
|
return { tag: 'Loading' };
|
|
case 'failed':
|
|
return { tag: 'Failure', error: new Error(s.reason) };
|
|
case 'loaded':
|
|
return { tag: 'Success', value: s };
|
|
}
|
|
});
|
|
|
|
private brief = computed<Brief | null>(() => {
|
|
const s = this.model();
|
|
return s.tag === 'loaded' ? s.brief : null;
|
|
});
|
|
|
|
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
|
readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);
|
|
readonly canReject = computed(() => this.decisions()?.canReject ?? false);
|
|
readonly canSend = computed(() => this.decisions()?.canSend ?? false);
|
|
|
|
private decisions = computed(() => {
|
|
const s = this.model();
|
|
return s.tag === 'loaded' ? s.decisions : null;
|
|
});
|
|
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
|
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
|
/** Submit is allowed only when required sections are filled AND no blocking errors. */
|
|
readonly canSubmit = computed(() => {
|
|
const b = this.brief();
|
|
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
|
|
});
|
|
|
|
async load() {
|
|
const r = await this.adapter.load();
|
|
if (r.ok) {
|
|
this.orgTemplate.set(r.value.orgTemplate);
|
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
|
} else {
|
|
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
|
}
|
|
}
|
|
|
|
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
|
edit(msg: BriefMsg) {
|
|
this.store.dispatch(msg);
|
|
this.scheduleSave();
|
|
}
|
|
|
|
private saveTimer?: ReturnType<typeof setTimeout>;
|
|
private scheduleSave() {
|
|
if (!this.canEdit()) return;
|
|
clearTimeout(this.saveTimer);
|
|
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
|
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
|
}
|
|
private async flushSave() {
|
|
const b = this.brief();
|
|
if (!b) return;
|
|
this.saveState.set({ tag: 'Saving' });
|
|
const r = await this.adapter.save(b.sections);
|
|
if (r.ok) {
|
|
this.saveState.set({ tag: 'Saved' });
|
|
} else {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
this.saveState.set({ tag: 'Error' });
|
|
}
|
|
}
|
|
|
|
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
|
async resetDemo() {
|
|
this.actionState.set({ tag: 'Busy' });
|
|
clearTimeout(this.saveTimer);
|
|
const r = await this.adapter.reset();
|
|
this.saveState.set({ tag: 'Idle' });
|
|
if (r.ok) {
|
|
this.actionState.set({ tag: 'Idle' });
|
|
this.orgTemplate.set(r.value.orgTemplate);
|
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
|
} else {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
}
|
|
}
|
|
|
|
submit = () => this.transition(() => this.adapter.submit());
|
|
approve = () => this.transition(() => this.adapter.approve());
|
|
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
|
send = () => this.transition(() => this.adapter.send());
|
|
|
|
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
|
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
|
the tab outlives this call; not worth a teardown hook for a POC. */
|
|
async previewLetter() {
|
|
this.actionState.set({ tag: 'Busy' });
|
|
const r = await this.previewAdapter.preview();
|
|
if (!r.ok) {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
return;
|
|
}
|
|
this.actionState.set({ tag: 'Idle' });
|
|
window.open(URL.createObjectURL(r.value), '_blank');
|
|
}
|
|
|
|
// A transition: flush any pending save, call the server (authoritative), then mirror
|
|
// the returned status through the pure reducer's guarded transition.
|
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
|
this.actionState.set({ tag: 'Busy' });
|
|
clearTimeout(this.saveTimer);
|
|
await this.flushSave();
|
|
const r = await action();
|
|
if (!r.ok) {
|
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
|
return;
|
|
}
|
|
this.actionState.set({ tag: 'Idle' });
|
|
this.applyServerStatus(r.value);
|
|
}
|
|
|
|
private applyServerStatus(view: BriefView) {
|
|
// `send` pins the org-template version server-side — mirror whatever came back.
|
|
this.orgTemplate.set(view.orgTemplate);
|
|
const { brief, decisions } = view;
|
|
const s = brief.status;
|
|
switch (s.tag) {
|
|
case 'submitted':
|
|
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });
|
|
break;
|
|
case 'approved':
|
|
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
|
break;
|
|
case 'rejected':
|
|
this.store.dispatch({
|
|
tag: 'Rejected',
|
|
by: s.rejectedBy,
|
|
at: s.rejectedAt,
|
|
comments: s.comments,
|
|
decisions,
|
|
});
|
|
break;
|
|
case 'sent':
|
|
this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });
|
|
break;
|
|
case 'draft':
|
|
// reopened by a save on a rejected letter — reducer already handled it locally.
|
|
break;
|
|
}
|
|
}
|
|
}
|