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
5 changes: 5 additions & 0 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
});
59 changes: 59 additions & 0 deletions src/objects/_hook-api.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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<number>;
find: (q: HookQuery) => Promise<Array<Doc>>;
findOne: (q: HookQuery) => Promise<Doc | null>;
insert: (doc: Doc) => Promise<unknown>;
update: (doc: HookUpdateDoc, options: HookUpdateOptions) => Promise<unknown>;
delete: (options: HookDeleteOptions) => Promise<unknown>;
}

export interface HookApi {
object: (name: string) => HookObjectApi;
}
114 changes: 114 additions & 0 deletions src/objects/contract-version.object.ts
Original file line number Diff line number Diff line change
@@ -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<n> · <kind>")
* 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<version_no> · <kind>", 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,
},
});
Loading