Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/objects/contract-version.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Hook, HookContext } from '@objectstack/spec/data';
import type { HookApi } from './_hook-api.js';

/**
* At most one current version per contract (DESIGN.md §03 `clm_contract_version`,
* ruled 1A + 2A on #96).
*
* `is_current` is "the version negotiation is currently on" — singular, and
* what the `approved → signing` guard in `contract.hook.ts` reads. A new
* version is current by default (`defaultValue: true` on the field), so this
* hook is what keeps the flag exclusive, on every door alike — the version
* form, REST, flows, MCP:
*
* - a row that is current after the write un-marks every other current row of
* the same contract;
* - a `final_signed` row does not take the flag from a negotiation version: it
* keeps `is_current: true` only while it is the contract's ONLY version (the
* F16 backfill files exactly that), and is otherwise stored `false`, leaving
* its siblings alone. That is what keeps `signing → approved` re-enterable
* after a failed signing round: the clean version stays current whoever
* files the executed copy — F7 writes it `false` explicitly, and a records
* clerk adding one through the version form gets the same answer.
*
* Nothing is promoted when a row is un-ticked: "no current version" is a state
* the signing guard already refuses with its own message.
*
* `afterInsert` / `afterUpdate`, so the caller's own write is judged first and
* a refused write never un-marks anything; the decision is taken on the
* COMMITTED row, re-read here. `runAs: 'system'`: requesters and records hold
* create but not edit on versions (DESIGN.md §04), so un-marking a sibling — or
* storing `false` on the row just created — must be a system write. Same
* precedent as `deviation_gate` in `contract.hook.ts`. The hook's own writes
* set `is_current: false` and therefore return at the first check, so it does
* not recurse.
*
* As in `contract.hook.ts`, everything the handler uses lives inside it: the
* build lowers the body into a sandbox with no module scope.
*/
const contractVersionCurrent: Hook = {
name: 'contract_version_current',
object: 'clm_contract_version',
events: ['afterInsert', 'afterUpdate'],
priority: 300,
runAs: 'system',
description: 'Keep at most one current version per contract: a version that becomes current un-marks the others; a final signed copy keeps the flag only when it is the contract\'s only version.',
handler: async (ctx: HookContext) => {
const { event, input } = ctx;
const previous = ctx.previous ?? {};
// An update that touches none of the three inputs cannot change who is
// current; skip the reads.
if (event === 'afterUpdate' && input.is_current === undefined && input.kind === undefined && input.contract === undefined) return;

const result = (ctx.result ?? {}) as Record<string, unknown>;
const id = event === 'afterInsert' ? result.id : previous.id;
if (typeof id !== 'string' || !id) return;
const api = ctx.api as HookApi | undefined;
if (!api) return;

const row = await api.object('clm_contract_version').findOne({
where: { id },
fields: ['id', 'contract', 'kind', 'is_current'],
});
if (!row || row.is_current !== true) return;
const contractId = row.contract;
if (typeof contractId !== 'string' || !contractId) return;

if (row.kind === 'final_signed') {
const versions = await api.object('clm_contract_version').count({ where: { contract: contractId } });
if (versions > 1) {
await api.object('clm_contract_version').update({ id, is_current: false }, { where: { id } });
}
return;
}

const others = await api.object('clm_contract_version').find({
where: { contract: contractId, is_current: true, id: { $ne: id } },
fields: ['id'],
top: 200,
});
for (const other of others) {
if (typeof other.id !== 'string' || other.id === id) continue;
await api.object('clm_contract_version').update({ id: other.id, is_current: false }, { where: { id: other.id } });
}
},
};

export default [contractVersionCurrent];
10 changes: 7 additions & 3 deletions src/objects/contract-version.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
*
* 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<n> · <kind>")
* one (`contract.hook.ts`). At most one version of a contract is current
* (`contract-version.hook.ts`). `display_name` is a stored mirror ("v<n> · <kind>")
* stamped by `mirror.hook.ts` — a stored title, never a formula, so the record
* is searchable and pickable.
*/
Expand Down Expand Up @@ -83,8 +84,11 @@ export const ContractVersion = ObjectSchema.create({
is_current: Field.boolean({
label: 'Current',
group: 'version',
defaultValue: false,
// The approved → signing guard in contract.hook.ts reads this flag.
// A new version is current by default; `contract-version.hook.ts` keeps
// the flag to one row per contract and never moves it onto a
// `final_signed` copy while other versions exist. The approved → signing
// guard in contract.hook.ts reads this flag.
defaultValue: true,
description: 'The version negotiation is currently on. A contract can go to signing only once its current version is a clean copy.',
}),

Expand Down
3 changes: 2 additions & 1 deletion src/objects/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Hook } from '@objectstack/spec/data';

import contractHooks from './contract.hook.js';
import contractVersionHooks from './contract-version.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];
export const allHooks: Hook[] = [...contractHooks, ...contractVersionHooks, ...mirrorHooks];
Loading