From 26a76ad8cd0c73517902d7a62426c49245af0e8a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 12:12:12 +0000 Subject: [PATCH 1/2] feat(objects): add clm_contract and its four review-time children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract domain of DESIGN.md §03: clm_contract (private, title as nameField, hook-generated contract_number per §13 Q5) plus the four master-detail children clm_contract_version, clm_review, clm_deviation and clm_signature (controlled_by_parent, stored display_name mirrors). Type-derived stamps, stage timestamps, routing flags, the ai_* fields and is_backfilled are readonly with no writer here; the hooks that stamp them land in the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- src/objects/contract-version.object.ts | 114 +++++++ src/objects/contract.object.ts | 436 +++++++++++++++++++++++++ src/objects/deviation.object.ts | 105 ++++++ src/objects/index.ts | 6 + src/objects/review.object.ts | 108 ++++++ src/objects/signature.object.ts | 131 ++++++++ 6 files changed, 900 insertions(+) create mode 100644 src/objects/contract-version.object.ts create mode 100644 src/objects/contract.object.ts create mode 100644 src/objects/deviation.object.ts create mode 100644 src/objects/review.object.ts create mode 100644 src/objects/signature.object.ts diff --git a/src/objects/contract-version.object.ts b/src/objects/contract-version.object.ts new file mode 100644 index 0000000..0096370 --- /dev/null +++ b/src/objects/contract-version.object.ts @@ -0,0 +1,114 @@ +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * One document version of a contract — our draft, an internal redline, the + * counterparty's redline, the clean copy, or the final signed copy. Versions + * are how negotiation rounds are recorded (DESIGN.md §03): there is no + * counterparty portal, so legal uploads the other side's markup as a + * `counterparty_redline` version. + * + * Two versions gate the contract's state machine: `approved → signing` needs + * a current `clean` version, and `signing → active` needs a `final_signed` + * one (`contract.hook.ts`). `display_name` is a stored mirror ("v · ") + * stamped by `mirror.hook.ts` — a stored title, never a formula, so the record + * is searchable and pickable. + */ +export const ContractVersion = ObjectSchema.create({ + name: 'clm_contract_version', + label: 'Contract Version', + pluralLabel: 'Contract Versions', + icon: 'file-stack', + description: 'A document version of a contract: draft, redline (ours or theirs), clean copy or final signed copy.', + + // Access derives from the contract (ADR-0055): whoever can read the contract + // reads its versions, whoever can edit it edits them. + sharingModel: 'controlled_by_parent', + nameField: 'display_name', + highlightFields: ['display_name', 'version_no', 'kind', 'turn', 'is_current'], + + fieldGroups: [ + { key: 'version', label: 'Version', icon: 'file-stack' }, + { key: 'document', label: 'Document', icon: 'file-text' }, + ], + + fields: { + display_name: Field.text({ + label: 'Version', + group: 'version', + readonly: true, + searchable: true, + maxLength: 80, + description: 'Stored mirror "v · ", stamped by mirror.hook.ts.', + }), + contract: Field.masterDetail('clm_contract', { + label: 'Contract', + group: 'version', + required: true, + deleteBehavior: 'cascade', + inlineEdit: 'grid', + inlineTitle: 'Versions', + }), + version_no: Field.number({ + label: 'Version No.', + group: 'version', + required: true, + scale: 0, + min: 1, + max: 9999, + }), + kind: Field.select({ + label: 'Kind', + group: 'version', + required: true, + options: [ + { label: 'Draft', value: 'draft', color: '#94A3B8', default: true }, + { label: 'Internal Redline', value: 'internal_redline', color: '#3B82F6' }, + { label: 'Counterparty Redline', value: 'counterparty_redline', color: '#F59E0B' }, + { label: 'Clean', value: 'clean', color: '#0B6E63' }, + { label: 'Final Signed', value: 'final_signed', color: '#2F7D5B' }, + ], + }), + turn: Field.select({ + label: 'Turn', + group: 'version', + description: 'Which side produced this version.', + options: [ + { label: 'Internal', value: 'internal', color: '#3B82F6' }, + { label: 'Counterparty', value: 'counterparty', color: '#F59E0B' }, + ], + }), + is_current: Field.boolean({ + label: 'Current', + group: 'version', + defaultValue: false, + description: 'The version negotiation is currently on. The clean-version guard before signing reads this flag.', + }), + + file: Field.file({ + label: 'File', + group: 'document', + required: true, + accept: ['application/pdf', '.docx'], + maxSize: 50 * 1024 * 1024, + }), + submitted_by: Field.user({ + label: 'Submitted By', + group: 'document', + }), + notes: Field.textarea({ + label: 'Notes', + group: 'document', + description: 'What changed in this version, for the reviewer.', + }), + }, + + indexes: [ + { fields: ['contract', 'version_no'], unique: 'organization' }, + ], + + enable: { + apiEnabled: true, + searchable: true, + files: true, + }, +}); diff --git a/src/objects/contract.object.ts b/src/objects/contract.object.ts new file mode 100644 index 0000000..2e9358f --- /dev/null +++ b/src/objects/contract.object.ts @@ -0,0 +1,436 @@ +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * The contract — legal state only (DESIGN.md §01: commercial terms are the + * CRM's `crm_contract`, legal state is ours). One record from intake to + * archive; the `status` state machine in `contract.hook.ts` is the write-layer + * truth, never a UI convention (DESIGN.md §03 状态机). + * + * Three families of fields are `readonly` on purpose, each with exactly one + * writer that is NOT the form: + * - type-derived stamps (`contract_number`, `category`, `direction`, + * `execution_formalities`) — `contract.hook.ts`, on insert and when the + * type changes (DESIGN.md §13 Q5: the number is `--<0000>`, + * which `autonumber` cannot express); + * - stage timestamps and routing flags (`submitted_at` … `closed_at`, + * `route_*`, `approval_status`, `is_expiring`, `executed_at`, + * `archived_at`) — the state machine and the F2/F5/F9–F14 flows; + * - the four `ai_*` fields — the "adopt suggestion" action only (DESIGN.md + * §07: AI proposes, a person adopts, the field is written on adoption); + * - `is_backfilled` — the F16 `executed_upload` action only (§13 Q8). + * + * Roll-ups (`version_count`, `open_deviation_count`, `planned_amount`, + * `actual_amount`, `overdue_obligation_count`) are card 03 and deliberately + * absent here. + */ +export const Contract = ObjectSchema.create({ + name: 'clm_contract', + label: 'Contract', + pluralLabel: 'Contracts', + icon: 'file-signature', + description: 'A contract from intake to archive: parties, commercial and legal terms, lifecycle status and the stamps each stage leaves.', + + // Legal documents are owner-only by default; legal, finance and management + // reach them through positions and sharing rules (DESIGN.md §04, card 04). + sharingModel: 'private', + nameField: 'title', + highlightFields: ['contract_number', 'title', 'party', 'status', 'amount', 'end_date'], + + fieldGroups: [ + { key: 'identity', label: 'Contract', icon: 'file-signature' }, + { key: 'parties', label: 'Parties & Owners', icon: 'users' }, + { key: 'commercial', label: 'Commercial Terms', icon: 'dollar-sign' }, + { key: 'term', label: 'Term & Renewal', icon: 'calendar' }, + { key: 'legal', label: 'Legal', icon: 'scale' }, + { key: 'routing', label: 'Routing & Approval', icon: 'route', defaultExpanded: false }, + { key: 'lifecycle', label: 'Lifecycle', icon: 'history', defaultExpanded: false }, + { key: 'ai', label: 'AI Review', icon: 'sparkles', defaultExpanded: false }, + ], + + fields: { + // ─── Identity ─────────────────────────────────────────────────────── + contract_number: Field.text({ + label: 'Contract Number', + group: 'identity', + readonly: true, + searchable: true, + maxLength: 32, + description: 'Generated on insert by contract.hook.ts as --<4-digit sequence>, one sequence per type per year (DESIGN.md §13 Q5). Regenerated only if the type changes while the contract is still a draft.', + }), + title: Field.text({ + label: 'Title', + group: 'identity', + required: true, + searchable: true, + maxLength: 200, + }), + contract_type: Field.lookup('clm_contract_type', { + label: 'Contract Type', + group: 'identity', + required: true, + // Inactive types are hidden from the picker; existing contracts keep + // them (clm_contract_type.is_active). + lookupFilters: [{ field: 'is_active', operator: 'eq', value: true }], + description: 'The workflow this contract runs: intake fields, review, execution method and formalities (DESIGN.md §02).', + }), + category: Field.select({ + label: 'Category', + group: 'identity', + readonly: true, + description: 'Stamped from the contract type; drives the approval matrix and the clause playbook.', + options: [ + { label: 'NDA', value: 'nda' }, + { label: 'Sales', value: 'sales' }, + { label: 'Purchase', value: 'purchase' }, + { label: 'Service', value: 'service' }, + { label: 'Lease', value: 'lease' }, + { label: 'Employment / Contractor', value: 'employment' }, + { label: 'Framework', value: 'framework' }, + { label: 'Data Processing (DPA)', value: 'dpa' }, + { label: 'Amendment', value: 'amendment' }, + { label: 'Other', value: 'other' }, + ], + }), + direction: Field.select({ + label: 'Direction', + group: 'identity', + readonly: true, + description: 'Stamped from the contract type.', + options: [ + { label: 'Sales', value: 'sales', color: '#0B6E63' }, + { label: 'Purchase', value: 'purchase', color: '#3B82F6' }, + { label: 'Other', value: 'other', color: '#94A3B8' }, + ], + }), + status: Field.select({ + label: 'Status', + group: 'identity', + required: true, + description: 'Lifecycle state. Transitions and their guards are enforced by contract.hook.ts (DESIGN.md §03 状态机); expired, terminated and cancelled are terminal.', + options: [ + { label: 'Draft', value: 'draft', color: '#94A3B8', default: true }, + { label: 'Submitted', value: 'submitted', color: '#3B82F6' }, + { label: 'In Review', value: 'in_review', color: '#F59E0B' }, + { label: 'In Approval', value: 'in_approval', color: '#8B5CF6' }, + { label: 'Approved', value: 'approved', color: '#0B6E63' }, + { label: 'Rejected', value: 'rejected', color: '#EF4444' }, + { label: 'Signing', value: 'signing', color: '#0EA5E9' }, + { label: 'Active', value: 'active', color: '#2F7D5B' }, + { label: 'Expired', value: 'expired', color: '#64748B' }, + { label: 'Terminated', value: 'terminated', color: '#7C2D12' }, + { label: 'Cancelled', value: 'cancelled', color: '#475569' }, + ], + }), + risk_level: Field.select({ + label: 'Risk Level', + group: 'identity', + description: 'Assessed by legal during review; empty until assessed.', + options: [ + { label: 'Low', value: 'low', color: '#94A3B8' }, + { label: 'Medium', value: 'medium', color: '#F59E0B' }, + { label: 'High', value: 'high', color: '#EF4444' }, + ], + }), + is_backfilled: Field.boolean({ + label: 'Backfilled', + group: 'identity', + readonly: true, + defaultValue: false, + description: 'An already-executed contract entered after the fact through the F16 executed_upload action (DESIGN.md §13 Q8) — the only writer. Such a contract starts active and skipped review and approval.', + }), + archive_no: Field.text({ + label: 'Archive Number', + group: 'identity', + searchable: true, + maxLength: 40, + description: 'Physical or records-management archive reference, assigned at archive time (F14).', + }), + + // ─── Parties & owners ─────────────────────────────────────────────── + party: Field.lookup('clm_party', { + label: 'Counterparty', + group: 'parties', + required: true, + // The picker hides blocked parties; the state machine refuses submission + // with one regardless (the picker is convenience, the hook is the rule). + lookupFilters: [{ field: 'risk_flag', operator: 'ne', value: 'blocked' }], + }), + our_entity: Field.select({ + label: 'Our Signing Entity', + group: 'parties', + description: 'Which of our legal entities signs. The shipped list is a single placeholder — a group with several legal entities replaces it with its own (DESIGN.md §01: signing entities are configuration, not schema).', + options: [ + { label: 'Head office', value: 'head_office', default: true }, + ], + }), + department: Field.select({ + label: 'Requesting Department', + group: 'parties', + description: 'The business unit that launched the contract. A redundant scalar on the contract because RLS cannot cross objects (DESIGN.md §04); department-level sharing is a customer overlay (§13 Q2).', + options: [ + { label: 'Sales', value: 'sales' }, + { label: 'Procurement', value: 'procurement' }, + { label: 'Legal', value: 'legal' }, + { label: 'Finance', value: 'finance' }, + { label: 'Operations', value: 'operations' }, + { label: 'People / HR', value: 'people' }, + { label: 'IT', value: 'it' }, + { label: 'Other', value: 'other' }, + ], + }), + // `owner_id` is the platform ownership anchor — the one column the private + // OWD, sharing rules and owner-scope widening read. Declared (rather than + // left to injection) so validate resolves it, the label survives and it + // can be grouped; `system: true` keeps the injected marker the clone path + // reads. No defaultValue: the security middleware stamps the acting user + // on any insert that leaves it empty (the HotCRM account.object.ts note). + owner_id: Field.lookup('sys_user', { + label: 'Business Owner', + group: 'parties', + system: true, + readonly: false, + description: 'The requester who owns the contract on the business side.', + }), + legal_owner: Field.user({ + label: 'Legal Owner', + group: 'parties', + description: 'The lawyer who accepted the review. Required before a submitted contract enters review.', + }), + current_turn: Field.select({ + label: 'Ball In Court', + group: 'parties', + description: 'Whose move it is during negotiation.', + options: [ + { label: 'None', value: 'none', color: '#94A3B8', default: true }, + { label: 'Internal', value: 'internal', color: '#3B82F6' }, + { label: 'Counterparty', value: 'counterparty', color: '#F59E0B' }, + ], + }), + turn_since: Field.datetime({ + label: 'Turn Since', + group: 'parties', + }), + + // ─── Commercial terms ─────────────────────────────────────────────── + amount: Field.currency({ + label: 'Contract Amount', + group: 'commercial', + scale: 2, + min: 0, + description: 'Total contract value in currency_code. The approval matrix bands on it (clm_approval_rule).', + }), + currency_code: Field.select({ + label: 'Currency', + group: 'commercial', + description: 'ISO 4217 code. The organization-level default is a setting, not schema; the factory default is USD (DESIGN.md §01).', + options: [ + { label: 'USD — US Dollar', value: 'usd', default: true }, + { label: 'EUR — Euro', value: 'eur' }, + { label: 'GBP — Pound Sterling', value: 'gbp' }, + { label: 'CNY — Renminbi', value: 'cny' }, + { label: 'JPY — Japanese Yen', value: 'jpy' }, + ], + }), + is_amount_estimated: Field.boolean({ + label: 'Amount Is Estimated', + group: 'commercial', + defaultValue: false, + description: 'On for framework agreements and rate cards whose value is a forecast, not a commitment.', + }), + payment_terms: Field.select({ + label: 'Payment Terms', + group: 'commercial', + description: 'Same value set as HotCRM crm_contract.payment_terms so the F15 hand-off maps 1:1.', + options: [ + { label: 'Net 15', value: 'net_15' }, + { label: 'Net 30', value: 'net_30' }, + { label: 'Net 60', value: 'net_60' }, + { label: 'Net 90', value: 'net_90' }, + { label: 'Due on Receipt', value: 'due_on_receipt' }, + ], + }), + liability_cap: Field.currency({ + label: 'Liability Cap', + group: 'commercial', + scale: 2, + min: 0, + description: 'Maximum aggregate liability in currency_code. Empty means uncapped or not negotiated.', + }), + + // ─── Term & renewal ───────────────────────────────────────────────── + start_date: Field.date({ + label: 'Start Date', + group: 'term', + }), + end_date: Field.date({ + label: 'End Date', + group: 'term', + description: 'The expiry job (F13) flags is_expiring renewal_notice_days before this date.', + }), + term_months: Field.number({ + label: 'Term (months)', + group: 'term', + scale: 0, + min: 0, + max: 600, + }), + auto_renew: Field.boolean({ + label: 'Auto-renews', + group: 'term', + defaultValue: false, + }), + renewal_notice_days: Field.number({ + label: 'Renewal Notice (days)', + group: 'term', + scale: 0, + min: 0, + max: 365, + description: 'Days before end_date by which a non-renewal notice must be given.', + }), + renewed_from: Field.lookup('clm_contract', { + label: 'Renewed From', + group: 'term', + description: 'Set by the "start renewal" action on the new draft; renewal is a new contract, not a transition (DESIGN.md §03).', + }), + parent_contract: Field.lookup('clm_contract', { + label: 'Parent Contract', + group: 'term', + description: 'The framework agreement this order sits under, or the main contract an amendment (category: amendment) modifies.', + }), + is_expiring: Field.boolean({ + label: 'Expiring Soon', + group: 'term', + readonly: true, + defaultValue: false, + description: 'Stamped daily by the expiry job (F13) when end_date is within the renewal notice window.', + }), + + // ─── Legal ────────────────────────────────────────────────────────── + governing_law: Field.text({ + label: 'Governing Law', + group: 'legal', + maxLength: 80, + description: 'ISO country or state, e.g. US-NY, DE, England and Wales.', + }), + jurisdiction: Field.text({ + label: 'Jurisdiction', + group: 'legal', + maxLength: 120, + description: 'Courts or arbitral seat with jurisdiction over disputes.', + }), + contract_language: Field.select({ + label: 'Contract Language', + group: 'legal', + description: 'ISO 639-1 code of the governing text.', + options: [ + { label: 'English', value: 'en', default: true }, + { label: 'Chinese', value: 'zh' }, + { label: 'Japanese', value: 'ja' }, + { label: 'German', value: 'de' }, + { label: 'French', value: 'fr' }, + { label: 'Spanish', value: 'es' }, + ], + }), + confidentiality_term_months: Field.number({ + label: 'Confidentiality Term (months)', + group: 'legal', + scale: 0, + min: 0, + max: 600, + description: 'How long confidentiality obligations survive. Empty means not negotiated.', + }), + execution_formalities: Field.select({ + label: 'Execution Formalities', + group: 'legal', + multiple: true, + readonly: true, + description: 'Stamped from the contract type. Activation waits for a completed signature whose formalities_done covers every value here.', + options: [ + { label: 'Countersigned copy returned', value: 'countersigned_copy' }, + { label: 'Company seal', value: 'company_seal' }, + { label: 'Notarized', value: 'notarized' }, + { label: 'Witnessed', value: 'witnessed' }, + ], + }), + summary: Field.richtext({ + label: 'Summary', + group: 'legal', + description: 'Human-written summary of the deal. The AI summary lives in ai_summary and is adopted separately.', + }), + + // ─── Routing & approval — stamped by F2 (route) and F5 (ladder) ──── + route_legal_head: Field.boolean({ label: 'Routes: Head of Legal', group: 'routing', readonly: true, defaultValue: false }), + route_finance: Field.boolean({ label: 'Routes: Finance Controller', group: 'routing', readonly: true, defaultValue: false }), + route_executive: Field.boolean({ label: 'Routes: Executive', group: 'routing', readonly: true, defaultValue: false }), + route_gm: Field.boolean({ label: 'Routes: General Manager', group: 'routing', readonly: true, defaultValue: false }), + approval_status: Field.select({ + label: 'Approval Status', + group: 'routing', + readonly: true, + description: 'Mirror of the approval ladder (F5) decision node; written by the flow, never by hand.', + options: [ + { label: 'Not Required', value: 'not_required', color: '#94A3B8', default: true }, + { label: 'Pending', value: 'pending', color: '#F59E0B' }, + { label: 'Approved', value: 'approved', color: '#2F7D5B' }, + { label: 'Rejected', value: 'rejected', color: '#EF4444' }, + ], + }), + + // ─── Lifecycle stamps — written on entry to each stage ───────────── + submitted_at: Field.datetime({ label: 'Submitted At', group: 'lifecycle', readonly: true }), + review_started_at: Field.datetime({ label: 'Review Started At', group: 'lifecycle', readonly: true }), + approved_at: Field.datetime({ label: 'Approved At', group: 'lifecycle', readonly: true }), + signed_at: Field.datetime({ label: 'Signed At', group: 'lifecycle', readonly: true }), + executed_at: Field.datetime({ + label: 'Executed At', + group: 'lifecycle', + readonly: true, + description: 'When execution completed — the signature round was completed and its formalities were done.', + }), + activated_at: Field.datetime({ label: 'Activated At', group: 'lifecycle', readonly: true }), + closed_at: Field.datetime({ + label: 'Closed At', + group: 'lifecycle', + readonly: true, + description: 'Stamped on termination.', + }), + archived_at: Field.datetime({ label: 'Archived At', group: 'lifecycle', readonly: true }), + + // ─── AI review — written only by the "adopt suggestion" action ───── + ai_summary: Field.richtext({ + label: 'AI Summary', + group: 'ai', + readonly: true, + description: 'Adopted from an S2/S6 suggestion (DESIGN.md §07). Empty when nothing has been adopted, or when the ai capability is off.', + }), + ai_risk_score: Field.number({ + label: 'AI Risk Score', + group: 'ai', + readonly: true, + scale: 0, + min: 0, + max: 100, + description: '0 (no concern) to 100 (do not sign). Adopted from the S6 approver memo after legal review; never written directly by the model.', + }), + ai_risk_rationale: Field.textarea({ + label: 'AI Risk Rationale', + group: 'ai', + readonly: true, + }), + ai_reviewed_at: Field.datetime({ + label: 'AI Reviewed At', + group: 'ai', + readonly: true, + }), + }, + + indexes: [ + { fields: ['contract_number'], unique: 'organization' }, + ], + + enable: { + apiEnabled: true, + searchable: true, + files: true, + }, +}); diff --git a/src/objects/deviation.object.ts b/src/objects/deviation.object.ts new file mode 100644 index 0000000..3444125 --- /dev/null +++ b/src/objects/deviation.object.ts @@ -0,0 +1,105 @@ +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * A departure from the clause playbook on one contract (DESIGN.md §02, + * Ironclad's Playbook): which clause, the wording actually on the table, the + * position it amounts to (our standard, our fallback, or something custom) + * and legal's decision on it. + * + * Deviations gate two things: a contract cannot enter approval with an + * `open` deviation, and an accepted deviation from a clause that + * `requires_legal_head` pulls the head of legal into the ladder (F6). + * `status` is a one-way state machine (`open` → `accepted` / `rejected` / + * `withdrawn`, enforced in `contract.hook.ts`). `display_name` is a stored + * mirror (" · ") stamped by `mirror.hook.ts`. + */ +export const Deviation = ObjectSchema.create({ + name: 'clm_deviation', + label: 'Deviation', + pluralLabel: 'Deviations', + icon: 'git-branch', + description: 'A departure from a playbook clause on a contract, and legal\'s decision on it.', + + sharingModel: 'controlled_by_parent', + nameField: 'display_name', + highlightFields: ['display_name', 'clause', 'requested_position', 'status', 'decided_at'], + + fieldGroups: [ + { key: 'deviation', label: 'Deviation', icon: 'git-branch' }, + { key: 'decision', label: 'Decision', icon: 'gavel' }, + ], + + fields: { + display_name: Field.text({ + label: 'Deviation', + group: 'deviation', + readonly: true, + searchable: true, + maxLength: 200, + description: 'Stored mirror " · ", stamped by mirror.hook.ts.', + }), + contract: Field.masterDetail('clm_contract', { + label: 'Contract', + group: 'deviation', + required: true, + deleteBehavior: 'cascade', + inlineEdit: 'grid', + inlineTitle: 'Deviations', + }), + clause: Field.lookup('clm_clause', { + label: 'Clause', + group: 'deviation', + required: true, + lookupFilters: [{ field: 'is_active', operator: 'eq', value: true }], + }), + deviation_text: Field.textarea({ + label: 'Proposed Wording', + group: 'deviation', + required: true, + description: 'The wording on the table, as it departs from the standard text.', + }), + requested_position: Field.select({ + label: 'Requested Position', + group: 'deviation', + description: 'Which playbook position the proposed wording amounts to.', + options: [ + { label: 'Standard', value: 'standard', color: '#2F7D5B' }, + { label: 'Fallback', value: 'fallback', color: '#F59E0B' }, + { label: 'Custom', value: 'custom', color: '#EF4444' }, + ], + }), + justification: Field.textarea({ + label: 'Justification', + group: 'deviation', + description: 'Why the business wants to accept it.', + }), + + status: Field.select({ + label: 'Status', + group: 'decision', + required: true, + description: 'open → accepted / rejected / withdrawn; the decided states are terminal (contract.hook.ts).', + options: [ + { label: 'Open', value: 'open', color: '#F59E0B', default: true }, + { label: 'Accepted', value: 'accepted', color: '#2F7D5B' }, + { label: 'Rejected', value: 'rejected', color: '#EF4444' }, + { label: 'Withdrawn', value: 'withdrawn', color: '#94A3B8' }, + ], + }), + decided_by: Field.user({ + label: 'Decided By', + group: 'decision', + description: 'Stamped with the acting user when the deviation is decided, unless set explicitly.', + }), + decided_at: Field.datetime({ + label: 'Decided At', + group: 'decision', + description: 'Stamped when the deviation is decided, unless set explicitly.', + }), + }, + + enable: { + apiEnabled: true, + searchable: true, + }, +}); diff --git a/src/objects/index.ts b/src/objects/index.ts index ebf28de..327dbd4 100644 --- a/src/objects/index.ts +++ b/src/objects/index.ts @@ -5,4 +5,10 @@ export { ApprovalRule } from './approval-rule.object.js'; export { Party } from './party.object.js'; // Contract domain — card 02. +export { Contract } from './contract.object.js'; +export { ContractVersion } from './contract-version.object.js'; +export { Review } from './review.object.js'; +export { Deviation } from './deviation.object.js'; +export { Signature } from './signature.object.js'; + // Post-signature domain — card 03. diff --git a/src/objects/review.object.ts b/src/objects/review.object.ts new file mode 100644 index 0000000..0e202c9 --- /dev/null +++ b/src/objects/review.object.ts @@ -0,0 +1,108 @@ +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * A review of a contract by one function — legal, finance, compliance or the + * business. The legal review is the one the state machine reads: a contract + * leaves `in_review` for `in_approval` only with a `legal` review whose + * decision is `approved` and no open deviation (`contract.hook.ts`). + * + * `comments` is what the requester sees; `internal_note` is legal's own + * working note and is withheld by field-level security (DESIGN.md §04, + * card 04). `display_name` is a stored mirror (" · ") + * stamped by `mirror.hook.ts`. + */ +export const Review = ObjectSchema.create({ + name: 'clm_review', + label: 'Review', + pluralLabel: 'Reviews', + icon: 'gavel', + description: 'One function\'s review of a contract: who reviewed, at which stage, the decision and the assessed risk.', + + sharingModel: 'controlled_by_parent', + nameField: 'display_name', + highlightFields: ['display_name', 'stage', 'reviewer', 'decision', 'decided_at'], + + fieldGroups: [ + { key: 'review', label: 'Review', icon: 'gavel' }, + { key: 'outcome', label: 'Outcome', icon: 'check-circle' }, + ], + + fields: { + display_name: Field.text({ + label: 'Review', + group: 'review', + readonly: true, + searchable: true, + maxLength: 160, + description: 'Stored mirror " · ", stamped by mirror.hook.ts.', + }), + contract: Field.masterDetail('clm_contract', { + label: 'Contract', + group: 'review', + required: true, + deleteBehavior: 'cascade', + inlineEdit: 'grid', + inlineTitle: 'Reviews', + }), + reviewer: Field.user({ + label: 'Reviewer', + group: 'review', + required: true, + }), + stage: Field.select({ + label: 'Stage', + group: 'review', + required: true, + options: [ + { label: 'Legal', value: 'legal', color: '#8B5CF6', default: true }, + { label: 'Finance', value: 'finance', color: '#0B6E63' }, + { label: 'Compliance', value: 'compliance', color: '#F59E0B' }, + { label: 'Business', value: 'business', color: '#3B82F6' }, + ], + }), + started_at: Field.datetime({ + label: 'Started At', + group: 'review', + }), + + decision: Field.select({ + label: 'Decision', + group: 'outcome', + options: [ + { label: 'Pending', value: 'pending', color: '#94A3B8', default: true }, + { label: 'Approved', value: 'approved', color: '#2F7D5B' }, + { label: 'Changes Requested', value: 'changes_requested', color: '#F59E0B' }, + { label: 'Rejected', value: 'rejected', color: '#EF4444' }, + ], + }), + risk_level_assessed: Field.select({ + label: 'Assessed Risk', + group: 'outcome', + options: [ + { label: 'Low', value: 'low', color: '#94A3B8' }, + { label: 'Medium', value: 'medium', color: '#F59E0B' }, + { label: 'High', value: 'high', color: '#EF4444' }, + ], + }), + comments: Field.richtext({ + label: 'Comments', + group: 'outcome', + description: 'Visible to the requester.', + }), + // Field-level security withholds this from everyone but legal (card 04). + internal_note: Field.richtext({ + label: 'Internal Note', + group: 'outcome', + description: 'Legal\'s own working note. Not shown to the requester.', + }), + decided_at: Field.datetime({ + label: 'Decided At', + group: 'outcome', + }), + }, + + enable: { + apiEnabled: true, + searchable: true, + }, +}); diff --git a/src/objects/signature.object.ts b/src/objects/signature.object.ts new file mode 100644 index 0000000..2356161 --- /dev/null +++ b/src/objects/signature.object.ts @@ -0,0 +1,131 @@ +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * One execution round of a contract: the e-signature envelope or the wet-ink + * round, who signs in what order, and which execution formalities were done. + * There is no `clm_signatory` object — signers are JSON rows on this record — + * and no seal module: a company seal is one value of `formalities_done`, + * next to notarization, witnessing and the countersigned copy (DESIGN.md §01, + * §03 五个刻意的取舍). + * + * Activation reads this record: `signing → active` needs a `completed` + * signature whose `formalities_done` covers the contract's + * `execution_formalities` (stamped from the type). `status` is a state + * machine (`draft` → `sent` / `completed` / `voided`, `sent` → `completed` / + * `declined` / `voided`, `declined` → `draft`; wet ink may complete straight + * from draft) enforced in `contract.hook.ts`. `display_name` is a stored + * mirror (" · ") stamped by `mirror.hook.ts`. + */ +export const Signature = ObjectSchema.create({ + name: 'clm_signature', + label: 'Signature', + pluralLabel: 'Signatures', + icon: 'pen-line', + description: 'One signing round of a contract: method, provider envelope, signers, status and the execution formalities completed.', + + sharingModel: 'controlled_by_parent', + nameField: 'display_name', + highlightFields: ['display_name', 'method', 'provider', 'status', 'completed_at'], + + fieldGroups: [ + { key: 'round', label: 'Signing Round', icon: 'pen-line' }, + { key: 'execution', label: 'Execution', icon: 'stamp' }, + ], + + fields: { + display_name: Field.text({ + label: 'Signature', + group: 'round', + readonly: true, + searchable: true, + maxLength: 80, + description: 'Stored mirror " · ", stamped by mirror.hook.ts.', + }), + contract: Field.masterDetail('clm_contract', { + label: 'Contract', + group: 'round', + required: true, + deleteBehavior: 'cascade', + inlineEdit: 'grid', + inlineTitle: 'Signatures', + }), + method: Field.select({ + label: 'Method', + group: 'round', + required: true, + options: [ + { label: 'E-signature', value: 'esign', color: '#3B82F6', default: true }, + { label: 'Wet ink', value: 'wet_ink', color: '#7C2D12' }, + ], + }), + provider: Field.select({ + label: 'Provider', + group: 'round', + description: 'E-signature provider the envelope was sent through. Regional packs append their own (DESIGN.md §13 Q7).', + options: [ + { label: 'DocuSign', value: 'docusign' }, + { label: 'Adobe Acrobat Sign', value: 'adobe_sign' }, + { label: 'Dropbox Sign', value: 'dropbox_sign' }, + ], + }), + envelope_id: Field.text({ + label: 'Envelope ID', + group: 'round', + searchable: true, + maxLength: 120, + description: 'The provider\'s envelope or agreement id, for status polling and audit (F8).', + }), + signers: Field.json({ + label: 'Signers', + group: 'round', + description: 'Array of { side: our | counterparty, name, email, order, status, signed_at } — one row per signer, in signing order.', + }), + status: Field.select({ + label: 'Status', + group: 'round', + required: true, + options: [ + { label: 'Draft', value: 'draft', color: '#94A3B8', default: true }, + { label: 'Sent', value: 'sent', color: '#3B82F6' }, + { label: 'Completed', value: 'completed', color: '#2F7D5B' }, + { label: 'Declined', value: 'declined', color: '#EF4444' }, + { label: 'Voided', value: 'voided', color: '#64748B' }, + ], + }), + + formalities_done: Field.select({ + label: 'Formalities Done', + group: 'execution', + multiple: true, + description: 'Same value set as clm_contract_type.execution_formalities. Activation waits until every formality the type requires is ticked here on a completed round.', + options: [ + { label: 'Countersigned copy returned', value: 'countersigned_copy' }, + { label: 'Company seal', value: 'company_seal' }, + { label: 'Notarized', value: 'notarized' }, + { label: 'Witnessed', value: 'witnessed' }, + ], + }), + executed_file: Field.file({ + label: 'Executed Copy', + group: 'execution', + accept: ['application/pdf'], + maxSize: 50 * 1024 * 1024, + description: 'The fully executed document — the provider\'s completed envelope, or the scanned wet-ink copy.', + }), + completed_at: Field.datetime({ + label: 'Completed At', + group: 'execution', + description: 'Stamped when the round completes, unless set explicitly (a wet-ink round records the actual signing date).', + }), + notes: Field.textarea({ + label: 'Notes', + group: 'execution', + }), + }, + + enable: { + apiEnabled: true, + searchable: true, + files: true, + }, +}); From 31a80f38d835380dd95206208b1a6c500bca0ddc Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 12:24:57 +0000 Subject: [PATCH 2/2] feat(objects): contract numbering, type stamps and state machines as hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contract.hook.ts: a contract is born draft (system writes exempt, for the F16 backfill and seed replay); category, direction and execution formalities are copied from the type; the number is TYPE_CODE-YYYY-0000 per type per year (max existing + 1, scoped to the organization, DESIGN.md §13 Q5). The status transition table of §03 and every guard — intake fields, blocked party, first version or template, legal owner, open deviations, approved legal review, current clean version, completed signature covering the type's formalities plus a final_signed version — refuse with code INVALID_STATE / status 422 and stamp the stage timestamp on entry. Deviation and signature machines in the same file. mirror.hook.ts: stored display_name mirrors for the four children. hooks.ts + objectstack.config.ts: hooks are a top-level stack key, so the barrel and the one wiring line are what registers them; every one of the 8 handlers lowers to a metadata-only body (no runtime bundle). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcrVDXSptwDukFsHPHPR1V --- objectstack.config.ts | 5 + src/objects/_hook-api.ts | 59 ++++++ src/objects/contract.hook.ts | 401 +++++++++++++++++++++++++++++++++++ src/objects/hooks.ts | 11 + src/objects/mirror.hook.ts | 144 +++++++++++++ 5 files changed, 620 insertions(+) create mode 100644 src/objects/_hook-api.ts create mode 100644 src/objects/contract.hook.ts create mode 100644 src/objects/hooks.ts create mode 100644 src/objects/mirror.hook.ts diff --git a/objectstack.config.ts b/objectstack.config.ts index c94c899..d3845de 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -1,5 +1,6 @@ import { defineStack } from '@objectstack/spec'; import * as objects from './src/objects/index.js'; +import { allHooks } from './src/objects/hooks.js'; /** * HotCLM — contract lifecycle management on ObjectStack. @@ -30,4 +31,8 @@ export default defineStack({ requires: ['ui'], objects: Object.values(objects), + // Lifecycle hooks (numbering, type-derived stamps, the state machines and + // the display_name mirrors). A metadata `Hook` is only registered from + // here — `hooks` is a top-level stack key, not an object key. + hooks: allHooks, }); diff --git a/src/objects/_hook-api.ts b/src/objects/_hook-api.ts new file mode 100644 index 0000000..6e450e5 --- /dev/null +++ b/src/objects/_hook-api.ts @@ -0,0 +1,59 @@ +/** + * Structural type of the ObjectQL data API the runtime injects as `ctx.api` + * inside a hook handler. The SDK types `HookContext.api` as `unknown`; every + * `*.hook.ts` casts `ctx.api as HookApi | undefined` against this one shape so + * the spellings cannot drift between hooks. + * + * Type-only: nothing here is a value, so importing it does not put a + * module-scope identifier into a lowered hook body (the CLI's + * `extractHookBody` refuses those — see `contract.hook.ts`). + * + * The legal key sets below are HotCRM's, measured against the pinned + * `@objectstack` 17.3.0 packages on the object the kernel injects as `ctx.api` + * (hotcrm `src/objects/_hook-api.ts`, pinned there by + * `test/hook-query-predicate.test.ts` against a real engine): + * - the predicate key is `where`, and only `where` — `filter` is an alias the + * engine folds, and mixing the two spellings throws; + * - `count` accepts `where` alone; `fields` / `top` on it throw; + * - `update` takes the document (with its `id` inside) and a `{ where }` + * options bag — there is no `(id, doc)` overload. + */ + +type Doc = Record; + +/** Options accepted by `find` / `findOne`. */ +export interface HookQuery { + where?: Doc; + fields?: string[]; + top?: number; +} + +/** Options accepted by `count` — the predicate, nothing else. */ +export interface HookCountQuery { + where?: Doc; +} + +/** The document handed to `update`; the target `id` travels inside it. */ +export type HookUpdateDoc = Doc & { id: string }; + +export interface HookUpdateOptions { + where: Doc; +} + +export interface HookDeleteOptions { + where: Doc; +} + +/** The methods present on BOTH surfaces the runtime can inject (in-process repository and sandbox facade). */ +export interface HookObjectApi { + count: (q: HookCountQuery) => Promise; + find: (q: HookQuery) => Promise>; + findOne: (q: HookQuery) => Promise; + insert: (doc: Doc) => Promise; + update: (doc: HookUpdateDoc, options: HookUpdateOptions) => Promise; + delete: (options: HookDeleteOptions) => Promise; +} + +export interface HookApi { + object: (name: string) => HookObjectApi; +} diff --git a/src/objects/contract.hook.ts b/src/objects/contract.hook.ts new file mode 100644 index 0000000..bc1c028 --- /dev/null +++ b/src/objects/contract.hook.ts @@ -0,0 +1,401 @@ +import type { Hook, HookContext } from '@objectstack/spec/data'; +import type { HookApi } from './_hook-api.js'; + +/** + * Contract lifecycle hooks — the write-layer truth for DESIGN.md §03. + * + * - `contract_type_stamp` (beforeInsert / beforeUpdate): a contract is born + * `draft` (the FSM entry point; a system write — F16 backfill, seed replay — + * is exempt), copies `category`, `direction` and `execution_formalities` + * from its type, and receives its number `--<0000>` — one + * sequence per type per year, next = max existing + 1 within the + * organization (§13 Q5). Changing the type is allowed only on a draft and + * re-stamps all four. + * - `contract_state_machine` (beforeUpdate on `status`): the transition table + * and every guard of §03 状态机, refusing with a structured error rather + * than coercing, and stamping the stage timestamp on each entry. + * - `deviation_state_machine`, `signature_state_machine`: the child machines. + * + * ## Why every helper lives INSIDE its handler + * + * `objectstack build` lowers each handler to a metadata-only body evaluated + * in a sandbox with no module scope; a handler that references a module-level + * helper or import cannot be lowered and is silently bundled instead + * (`os lint` reports it as `hook-body/not-lowerable`). So `refuse()` and the + * small tables are repeated per handler on purpose. + * + * ## The refusal envelope + * + * The REST layer maps a thrown error to its HTTP envelope from exactly two + * properties: `status` (a number) and `code` (a member of the platform's + * ErrorCode vocabulary — `INVALID_STATE` is the ledger's word for "understood, + * but the record is not in a state that allows this"). A code without a + * status is filed as a 500 server fault, which is why both are always set. + */ + +const contractTypeStamp: Hook = { + name: 'contract_type_stamp', + object: 'clm_contract', + events: ['beforeInsert', 'beforeUpdate'], + priority: 100, + // The sequence must count every contract of the organization, not the rows + // the acting user happens to own: `clm_contract` is `private`, so an + // inherited context would number per user and collide on the unique index. + // `runAs` elevates the hook's `ctx.api` reads only; `ctx.session` still + // describes the caller. + runAs: 'system', + description: 'Stamp category, direction, execution formalities and the contract number from the contract type; refuse a contract born in any state but draft.', + handler: async (ctx: HookContext) => { + function refuse(message: string, code: string, status: number): Error { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = code; + err.status = status; + return err; + } + const { event, input } = ctx; + const previous = ctx.previous ?? {}; + const api = ctx.api as HookApi | undefined; + + if (event === 'beforeInsert') { + const status = input.status; + if (status !== undefined && status !== null && status !== 'draft' && ctx.session?.isSystem !== true) { + throw refuse( + `A contract is created as a draft, not as ${String(status)}. Submit it after creation; an already-executed contract is entered through the backfill action.`, + 'INVALID_STATE', + 422, + ); + } + } + + const typeChanged = + event === 'beforeInsert' || + (typeof input.contract_type === 'string' && input.contract_type !== previous.contract_type); + if (!typeChanged) return; + + if (event === 'beforeUpdate' && previous.status !== 'draft') { + throw refuse( + `The contract type can only change while the contract is a draft (it is ${String(previous.status)}); the number and the routing derive from it.`, + 'INVALID_STATE', + 422, + ); + } + + const typeId = + (typeof input.contract_type === 'string' && input.contract_type) || + (typeof previous.contract_type === 'string' && previous.contract_type) || + ''; + if (!typeId) throw refuse('A contract type is required.', 'MISSING_REQUIRED_FIELD', 422); + if (!api) throw refuse('The data API is not available to the contract stamping hook.', 'INTERNAL_ERROR', 500); + + const type = await api.object('clm_contract_type').findOne({ + where: { id: typeId }, + fields: ['id', 'code', 'category', 'direction', 'execution_formalities'], + }); + if (!type) throw refuse(`Contract type ${typeId} does not exist.`, 'INVALID_REFERENCE', 422); + const code = typeof type.code === 'string' ? type.code.trim() : ''; + if (!code) throw refuse(`Contract type ${typeId} has no code; a code is what the contract number is built from.`, 'INVALID_STATE', 422); + + input.category = typeof type.category === 'string' ? type.category : null; + input.direction = typeof type.direction === 'string' ? type.direction : null; + input.execution_formalities = Array.isArray(type.execution_formalities) ? [...type.execution_formalities] : []; + + // Next number: max existing sequence for this type and year, plus one. + // Scoped to the organization the write belongs to, in the blessed order + // (acting user's org, session org, the row's own stamp); an unscoped read + // under an elevated context would otherwise meet other organizations' + // contracts. Max-plus-one rather than count-plus-one so a deleted draft + // leaves a gap instead of a duplicate. + const year = new Date().getUTCFullYear(); + const prefix = `${code}-${year}-`; + const organizationId = [ + ctx.user?.organizationId, + ctx.session?.organizationId, + input.organization_id, + previous.organization_id, + ].find((candidate): candidate is string => typeof candidate === 'string' && candidate !== ''); + const rows = await api.object('clm_contract').find({ + where: organizationId + ? { organization_id: organizationId, contract_number: { $startsWith: prefix } } + : { contract_number: { $startsWith: prefix } }, + fields: ['contract_number'], + top: 10000, + }); + let max = 0; + for (const row of rows) { + const number = typeof row.contract_number === 'string' ? row.contract_number : ''; + const seq = Number(number.slice(prefix.length)); + if (Number.isInteger(seq) && seq > max) max = seq; + } + input.contract_number = `${prefix}${String(max + 1).padStart(4, '0')}`; + }, +}; + +const contractStateMachine: Hook = { + name: 'contract_state_machine', + object: 'clm_contract', + events: ['beforeUpdate'], + priority: 200, + description: 'Enforce the contract status transition table and its guards; stamp the stage timestamp on each entry.', + handler: async (ctx: HookContext) => { + function refuse(message: string, code: string, status: number): Error { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = code; + err.status = status; + return err; + } + const { input } = ctx; + const previous = ctx.previous ?? {}; + const to = input.status; + if (typeof to !== 'string') return; + const from = typeof previous.status === 'string' ? previous.status : 'draft'; + if (to === from) return; + + // DESIGN.md §03 状态机 — from → allowed targets. expired, terminated and + // cancelled are terminal; renewal and amendment are new contracts. + const TRANSITIONS: Record = { + draft: ['submitted', 'cancelled'], + submitted: ['in_review', 'in_approval', 'draft', 'cancelled'], + in_review: ['in_approval', 'draft', 'cancelled'], + in_approval: ['approved', 'rejected', 'draft', 'cancelled'], + approved: ['signing', 'cancelled'], + signing: ['active', 'approved', 'cancelled'], + active: ['expired', 'terminated'], + rejected: ['draft'], + expired: [], + terminated: [], + cancelled: [], + }; + const allowed = TRANSITIONS[from] ?? []; + if (!allowed.includes(to)) { + throw refuse( + allowed.length === 0 + ? `A ${from} contract is closed; its status cannot change to ${to}. Start a renewal or an amendment instead.` + : `Contract status cannot go from ${from} to ${to}. Allowed from ${from}: ${allowed.join(', ')}.`, + 'INVALID_STATE', + 422, + ); + } + + const api = ctx.api as HookApi | undefined; + if (!api) throw refuse('The data API is not available to the contract state machine.', 'INTERNAL_ERROR', 500); + const id = typeof previous.id === 'string' ? previous.id : ''; + const get = (key: string): unknown => (input[key] !== undefined ? input[key] : previous[key]); + const isSet = (value: unknown): boolean => + !(value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)); + const now = new Date().toISOString(); + + async function loadType(): Promise> { + const typeId = get('contract_type'); + const type = isSet(typeId) + ? await api!.object('clm_contract_type').findOne({ + where: { id: typeId }, + fields: ['id', 'intake_fields', 'requires_legal_review', 'template_file'], + }) + : null; + if (!type) throw refuse('The contract has no contract type; one is required to move it forward.', 'INVALID_STATE', 422); + return type; + } + + if (from === 'draft' && to === 'submitted') { + const type = await loadType(); + const partyId = get('party'); + if (!isSet(partyId)) throw refuse('A counterparty is required before submission.', 'INVALID_STATE', 422); + const party = await api.object('clm_party').findOne({ where: { id: partyId }, fields: ['id', 'name', 'risk_flag'] }); + if (!party) throw refuse(`Counterparty ${String(partyId)} does not exist.`, 'INVALID_REFERENCE', 422); + if (party.risk_flag === 'blocked') { + throw refuse(`Counterparty ${String(party.name ?? partyId)} is blocked; a contract with a blocked party cannot be submitted.`, 'INVALID_STATE', 422); + } + const intake = Array.isArray(type.intake_fields) ? type.intake_fields : []; + const missing = intake.filter((field): field is string => typeof field === 'string' && !isSet(get(field))); + if (missing.length > 0) { + throw refuse(`Intake fields required by the contract type are missing: ${missing.join(', ')}.`, 'INVALID_STATE', 422); + } + const versions = await api.object('clm_contract_version').count({ where: { contract: id } }); + if (versions === 0 && !isSet(type.template_file)) { + throw refuse('Upload a first version, or choose a contract type that carries a template, before submitting.', 'INVALID_STATE', 422); + } + input.submitted_at = now; + } + + if (from === 'submitted' && (to === 'in_review' || to === 'in_approval')) { + const type = await loadType(); + // The field defaults to true; an unset value reads as the default. + const requiresLegalReview = type.requires_legal_review !== false; + if (to === 'in_review') { + if (!requiresLegalReview) { + throw refuse('This contract type does not require legal review; a submitted contract of this type goes straight to in_approval.', 'INVALID_STATE', 422); + } + if (!isSet(get('legal_owner'))) { + throw refuse('Assign a legal owner before the contract enters review.', 'INVALID_STATE', 422); + } + input.review_started_at = now; + } else if (requiresLegalReview) { + throw refuse('This contract type requires legal review; the next state after submitted is in_review, not in_approval.', 'INVALID_STATE', 422); + } + } + + if (from === 'in_review' && to === 'in_approval') { + const openDeviations = await api.object('clm_deviation').count({ where: { contract: id, status: 'open' } }); + if (openDeviations > 0) { + throw refuse(`${openDeviations} deviation(s) are still open; decide each one before the contract enters approval.`, 'INVALID_STATE', 422); + } + const legalApprovals = await api.object('clm_review').count({ where: { contract: id, stage: 'legal', decision: 'approved' } }); + if (legalApprovals === 0) { + throw refuse('An approved legal review is required before the contract enters approval.', 'INVALID_STATE', 422); + } + } + + if (from === 'in_review' && to === 'draft') { + const sendBacks = await api.object('clm_review').count({ + where: { contract: id, decision: { $in: ['changes_requested', 'rejected'] } }, + }); + if (sendBacks === 0) { + throw refuse('Returning a contract from review to draft needs a review with the decision changes_requested (or rejected) recorded.', 'INVALID_STATE', 422); + } + } + + if (from === 'in_approval' && to === 'approved') { + input.approved_at = now; + } + + if (from === 'approved' && to === 'signing') { + const cleanVersions = await api.object('clm_contract_version').count({ + where: { contract: id, kind: 'clean', is_current: true }, + }); + if (cleanVersions === 0) { + throw refuse('A current clean version is required before signing.', 'INVALID_STATE', 422); + } + } + + if (from === 'signing' && to === 'active') { + const required = Array.isArray(get('execution_formalities')) + ? (get('execution_formalities') as unknown[]).filter((f): f is string => typeof f === 'string') + : []; + const completed = await api.object('clm_signature').find({ + where: { contract: id, status: 'completed' }, + fields: ['id', 'formalities_done', 'completed_at'], + top: 50, + }); + const executed = completed.find((signature) => { + const done = Array.isArray(signature.formalities_done) ? signature.formalities_done : []; + return required.every((formality) => done.includes(formality)); + }); + if (!executed) { + throw refuse( + completed.length === 0 + ? 'A completed signature round is required before activation.' + : `A completed signature round must record every execution formality the contract type requires (${required.join(', ')}) before activation.`, + 'INVALID_STATE', + 422, + ); + } + const finalVersions = await api.object('clm_contract_version').count({ where: { contract: id, kind: 'final_signed' } }); + if (finalVersions === 0) { + throw refuse('A final_signed version is required before activation.', 'INVALID_STATE', 422); + } + const executedAt = typeof executed.completed_at === 'string' && executed.completed_at ? executed.completed_at : now; + if (!isSet(get('signed_at'))) input.signed_at = executedAt; + if (!isSet(get('executed_at'))) input.executed_at = executedAt; + input.activated_at = now; + } + + if (from === 'active' && to === 'expired' && ctx.session?.isSystem !== true) { + throw refuse('Only the expiry job marks a contract expired; terminate it to end it by hand.', 'INVALID_STATE', 422); + } + + if (to === 'terminated') { + input.closed_at = now; + } + }, +}; + +const deviationStateMachine: Hook = { + name: 'deviation_state_machine', + object: 'clm_deviation', + events: ['beforeUpdate'], + priority: 200, + description: 'A deviation is decided once: open → accepted / rejected / withdrawn; stamp decided_at and decided_by on decision.', + handler: async (ctx: HookContext) => { + function refuse(message: string, code: string, status: number): Error { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = code; + err.status = status; + return err; + } + const { input } = ctx; + const previous = ctx.previous ?? {}; + const to = input.status; + if (typeof to !== 'string') return; + const from = typeof previous.status === 'string' ? previous.status : 'open'; + if (to === from) return; + const DECIDED = ['accepted', 'rejected', 'withdrawn']; + if (from !== 'open' || !DECIDED.includes(to)) { + throw refuse( + from === 'open' + ? `Deviation status cannot go from open to ${to}. Allowed: ${DECIDED.join(', ')}.` + : `A ${from} deviation is final; its status cannot change to ${to}. Record a new deviation instead.`, + 'INVALID_STATE', + 422, + ); + } + const isSet = (value: unknown): boolean => !(value === undefined || value === null || value === ''); + if (!isSet(input.decided_at !== undefined ? input.decided_at : previous.decided_at)) { + input.decided_at = new Date().toISOString(); + } + if (!isSet(input.decided_by !== undefined ? input.decided_by : previous.decided_by)) { + const actor = ctx.user?.id ?? ctx.session?.userId; + if (typeof actor === 'string' && actor) input.decided_by = actor; + } + }, +}; + +const signatureStateMachine: Hook = { + name: 'signature_state_machine', + object: 'clm_signature', + events: ['beforeUpdate'], + priority: 200, + description: 'Signature round transitions: draft → sent / completed / voided, sent → completed / declined / voided, declined → draft; wet ink may complete straight from draft.', + handler: async (ctx: HookContext) => { + function refuse(message: string, code: string, status: number): Error { + const err = new Error(message) as Error & { code: string; status: number }; + err.code = code; + err.status = status; + return err; + } + const { input } = ctx; + const previous = ctx.previous ?? {}; + const to = input.status; + if (typeof to !== 'string') return; + const from = typeof previous.status === 'string' ? previous.status : 'draft'; + if (to === from) return; + const TRANSITIONS: Record = { + draft: ['sent', 'completed', 'voided'], + sent: ['completed', 'declined', 'voided'], + declined: ['draft'], + completed: [], + voided: [], + }; + const allowed = TRANSITIONS[from] ?? []; + if (!allowed.includes(to)) { + throw refuse( + allowed.length === 0 + ? `A ${from} signature round is final; its status cannot change to ${to}. Open a new round instead.` + : `Signature status cannot go from ${from} to ${to}. Allowed from ${from}: ${allowed.join(', ')}.`, + 'INVALID_STATE', + 422, + ); + } + const method = input.method !== undefined ? input.method : previous.method; + if (from === 'draft' && to === 'completed' && method !== 'wet_ink') { + throw refuse('An e-signature round completes from sent, not from draft; send the envelope first. Only a wet-ink round completes directly.', 'INVALID_STATE', 422); + } + if (to === 'completed') { + const completedAt = input.completed_at !== undefined ? input.completed_at : previous.completed_at; + if (completedAt === undefined || completedAt === null || completedAt === '') { + input.completed_at = new Date().toISOString(); + } + } + }, +}; + +export default [contractTypeStamp, contractStateMachine, deviationStateMachine, signatureStateMachine]; diff --git a/src/objects/hooks.ts b/src/objects/hooks.ts new file mode 100644 index 0000000..e152841 --- /dev/null +++ b/src/objects/hooks.ts @@ -0,0 +1,11 @@ +import type { Hook } from '@objectstack/spec/data'; + +import contractHooks from './contract.hook.js'; +import mirrorHooks from './mirror.hook.js'; + +/** + * Every lifecycle hook of the app, flat, for `defineStack({ hooks })`. Kept + * apart from `index.ts` on purpose: that barrel is spread into `objects` via + * `Object.values()`, and a hook array in it would be registered as an object. + */ +export const allHooks: Hook[] = [...contractHooks, ...mirrorHooks]; diff --git a/src/objects/mirror.hook.ts b/src/objects/mirror.hook.ts new file mode 100644 index 0000000..d1e27b1 --- /dev/null +++ b/src/objects/mirror.hook.ts @@ -0,0 +1,144 @@ +import type { Hook, HookContext } from '@objectstack/spec/data'; +import type { HookApi } from './_hook-api.js'; + +/** + * Stored `display_name` mirrors for the four contract children (DESIGN.md + * §03). Each child's title is a STORED text field — never a formula, because + * a formula is not searchable and cannot be a `nameField` — so something has + * to write it. These hooks do, on insert and whenever one of the inputs the + * title is built from changes: + * + * clm_contract_version "v · " + * clm_review " · " + * clm_deviation " · " + * clm_signature " · " + * + * The English option labels are repeated inline (a lowered hook body has no + * module scope, and cannot read the object's own option list). A stored + * title is untranslatable by nature; English is the source language + * (DESIGN.md §01). + * + * `display_name` is `readonly`: the engine keeps a key a before-hook assigned, + * so the stamp survives the read-only strip while a hand-typed value does not. + */ + +const contractVersionDisplayName: Hook = { + name: 'contract_version_display_name', + object: 'clm_contract_version', + events: ['beforeInsert', 'beforeUpdate'], + priority: 300, + description: 'Stamp display_name as "v · ".', + handler: async (ctx: HookContext) => { + const { event, input } = ctx; + const previous = ctx.previous ?? {}; + const stale = event === 'beforeInsert' || input.version_no !== undefined || input.kind !== undefined || !previous.display_name; + if (!stale) return; + const KIND: Record = { + draft: 'Draft', + internal_redline: 'Internal redline', + counterparty_redline: 'Counterparty redline', + clean: 'Clean', + final_signed: 'Final signed', + }; + const versionNo = input.version_no !== undefined ? input.version_no : previous.version_no; + const kind = input.kind !== undefined ? input.kind : previous.kind; + const kindLabel = typeof kind === 'string' ? (KIND[kind] ?? kind) : ''; + input.display_name = `v${versionNo ?? '?'} · ${kindLabel}`.trim(); + }, +}; + +const reviewDisplayName: Hook = { + name: 'review_display_name', + object: 'clm_review', + events: ['beforeInsert', 'beforeUpdate'], + priority: 300, + description: 'Stamp display_name as " · ".', + handler: async (ctx: HookContext) => { + const { event, input } = ctx; + const previous = ctx.previous ?? {}; + const stale = event === 'beforeInsert' || input.stage !== undefined || input.reviewer !== undefined || !previous.display_name; + if (!stale) return; + const STAGE: Record = { + legal: 'Legal', + finance: 'Finance', + compliance: 'Compliance', + business: 'Business', + }; + const stage = input.stage !== undefined ? input.stage : previous.stage; + const reviewer = input.reviewer !== undefined ? input.reviewer : previous.reviewer; + const stageLabel = typeof stage === 'string' ? (STAGE[stage] ?? stage) : ''; + let reviewerLabel = typeof reviewer === 'string' && reviewer ? reviewer : ''; + const api = ctx.api as HookApi | undefined; + if (reviewerLabel && api) { + // The title is cosmetic; a user row the caller cannot read must not + // block the review write. The id stands in for the name in that case. + try { + const user = await api.object('sys_user').findOne({ where: { id: reviewer }, fields: ['id', 'name', 'email'] }); + const name = typeof user?.name === 'string' && user.name ? user.name : (typeof user?.email === 'string' ? user.email : ''); + if (name) reviewerLabel = name; + } catch { + // keep the id + } + } + input.display_name = `${stageLabel} · ${reviewerLabel || 'Unassigned'}`; + }, +}; + +const deviationDisplayName: Hook = { + name: 'deviation_display_name', + object: 'clm_deviation', + events: ['beforeInsert', 'beforeUpdate'], + priority: 300, + description: 'Stamp display_name as " · ".', + handler: async (ctx: HookContext) => { + const { event, input } = ctx; + const previous = ctx.previous ?? {}; + const stale = event === 'beforeInsert' || input.clause !== undefined || input.status !== undefined || !previous.display_name; + if (!stale) return; + const STATUS: Record = { + open: 'Open', + accepted: 'Accepted', + rejected: 'Rejected', + withdrawn: 'Withdrawn', + }; + const clause = input.clause !== undefined ? input.clause : previous.clause; + const status = input.status !== undefined ? input.status : previous.status; + const statusLabel = typeof status === 'string' ? (STATUS[status] ?? status) : STATUS.open; + let clauseLabel = typeof clause === 'string' && clause ? clause : ''; + const api = ctx.api as HookApi | undefined; + if (clauseLabel && api) { + const row = await api.object('clm_clause').findOne({ where: { id: clause }, fields: ['id', 'title'] }); + if (typeof row?.title === 'string' && row.title) clauseLabel = row.title; + } + input.display_name = `${clauseLabel || 'Clause'} · ${statusLabel}`; + }, +}; + +const signatureDisplayName: Hook = { + name: 'signature_display_name', + object: 'clm_signature', + events: ['beforeInsert', 'beforeUpdate'], + priority: 300, + description: 'Stamp display_name as " · ".', + handler: async (ctx: HookContext) => { + const { event, input } = ctx; + const previous = ctx.previous ?? {}; + const stale = event === 'beforeInsert' || input.method !== undefined || input.status !== undefined || !previous.display_name; + if (!stale) return; + const METHOD: Record = { esign: 'E-signature', wet_ink: 'Wet ink' }; + const STATUS: Record = { + draft: 'Draft', + sent: 'Sent', + completed: 'Completed', + declined: 'Declined', + voided: 'Voided', + }; + const method = input.method !== undefined ? input.method : previous.method; + const status = input.status !== undefined ? input.status : previous.status; + const methodLabel = typeof method === 'string' ? (METHOD[method] ?? method) : METHOD.esign; + const statusLabel = typeof status === 'string' ? (STATUS[status] ?? status) : STATUS.draft; + input.display_name = `${methodLabel} · ${statusLabel}`; + }, +}; + +export default [contractVersionDisplayName, reviewDisplayName, deviationDisplayName, signatureDisplayName];