feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
111
src/app/brief/application/brief.store.ts
Normal file
111
src/app/brief/application/brief.store.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { Role, currentRole } from '@shared/infrastructure/role';
|
||||
import { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief';
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
||||
|
||||
/**
|
||||
* 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 `editable`, `diagnostics`,
|
||||
* `unresolved`, `canSubmit` are DERIVED here — never stored.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
readonly role: Role = currentRole();
|
||||
readonly busy = signal(false);
|
||||
readonly lastError = signal<string | null>(null);
|
||||
|
||||
private brief = computed<Brief | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.brief : null;
|
||||
});
|
||||
|
||||
/** The single source of truth for edit permission: drafter, and a status the reducer
|
||||
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
|
||||
readonly editable = computed(() => {
|
||||
const b = this.brief();
|
||||
return !!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter';
|
||||
});
|
||||
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.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
|
||||
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.editable()) 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;
|
||||
const r = await this.adapter.save(b.sections);
|
||||
if (!r.ok) this.lastError.set(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());
|
||||
|
||||
// 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, Brief>>) {
|
||||
this.busy.set(true);
|
||||
this.lastError.set(null);
|
||||
clearTimeout(this.saveTimer);
|
||||
await this.flushSave();
|
||||
const r = await action();
|
||||
this.busy.set(false);
|
||||
if (!r.ok) {
|
||||
this.lastError.set(r.error);
|
||||
return;
|
||||
}
|
||||
this.applyServerStatus(r.value);
|
||||
}
|
||||
|
||||
private applyServerStatus(brief: Brief) {
|
||||
const s = brief.status;
|
||||
switch (s.tag) {
|
||||
case 'submitted':
|
||||
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt });
|
||||
break;
|
||||
case 'approved':
|
||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
|
||||
break;
|
||||
case 'rejected':
|
||||
this.store.dispatch({ tag: 'Rejected', by: s.rejectedBy, at: s.rejectedAt, comments: s.comments });
|
||||
break;
|
||||
case 'sent':
|
||||
this.store.dispatch({ tag: 'Sent', at: s.sentAt });
|
||||
break;
|
||||
case 'draft':
|
||||
// reopened by a save on a rejected letter — reducer already handled it locally.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user