import { generateId, type TriggerType, type UpdateTriggerType } from './_helpers'; import type { CustomObject } from './custom-object'; import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import type { Field } from './field'; import type { ManifestComponentDeletion } from './manifest-builder'; import { optionalStringComponentField, requireStringComponentField } from './_existing-component-fields'; export interface RuleDeletionIdentifier { flowId: string; modelRqlName: string; } /** * A single action performed when a {@link Rule} fires. */ export interface RuleAction { /** * The field to set when this action runs. * * Required. */ targetField: Field; /** * Set the target field to a fixed value. Mutually exclusive with `rqlFormula`. * * Optional. For `SelectField` targets, pass one option value or an array of option * values; a scalar is serialized as a single-element array. For `RadioField` targets, * pass exactly one option value as a scalar; arrays are rejected. */ fixedValue?: any; /** * Set the target field to the result of an RQL formula. Mutually exclusive with `fixedValue`. * * Optional. */ rqlFormula?: string; } /** * Initialization properties for {@link Rule}. */ export interface RuleProps { /** * Display name of this automation rule shown in the UI. * * Required. */ flowName: string; /** * When this rule fires. One of `'CREATE'` | `'UPDATE'` | `'CREATE_OR_UPDATE'`. * * Required. The constructor throws if an invalid value is passed. */ triggerType: TriggerType; /** * Stable identifier for this rule. Pass a human-readable slug * (e.g. `'gym_default_enrollment_status'`) to keep it stable across regenerations. * * Optional. Auto-generated when omitted. @default generateId('flow') */ flowId?: string; /** * RQL formula guard — the rule fires only when this expression is truthy. * * Optional. @default null */ rqlCondition?: string; /** * Controls when an `UPDATE` trigger fires relative to the condition. * One of `'MEETS_CONDITION_AFTER_ONLY'` | `'MEETS_CONDITION_BEFORE_OR_AFTER'`. * * Conditional. Required when `triggerType` is `'UPDATE'` or `'CREATE_OR_UPDATE'`. * Must be omitted when `triggerType` is `'CREATE'`. */ updateTriggerType?: UpdateTriggerType; /** * Internal description of this rule (for developer reference only). * * Required. */ description: string; /** * Actions to run when the rule fires. Each action sets one field to a fixed value * or RQL formula result. * * Optional. @default `[]` */ actions?: RuleAction[]; } export interface RuleLoadFromExistingOptions extends ExistingManifestContextOptions { customObjectApiName?: string; } const VALID_TRIGGERS: TriggerType[] = ['CREATE', 'UPDATE', 'CREATE_OR_UPDATE']; const VALID_UPDATE_TRIGGER_TYPES: UpdateTriggerType[] = [ 'MEETS_CONDITION_AFTER_ONLY', 'MEETS_CONDITION_BEFORE_OR_AFTER', ]; function fixedValueToWire(targetField: Field, value: any): any { const ft = targetField.getFieldType(); if (ft === 'SELECT') { return Array.isArray(value) ? value : [value]; } if (ft === 'RADIO') { if (Array.isArray(value)) { throw new TypeError( `Rule.fixedValue for RADIO field ${targetField.getApiName()} must be a scalar value.`, ); } return value; } return value; } function actionToDict(action: RuleAction): Record { const out: Record = { target_field: action.targetField.getApiName(), }; if (action.fixedValue != null) { out['fixed_value'] = fixedValueToWire(action.targetField, action.fixedValue); } else if (action.rqlFormula != null) { out['rql_formula'] = action.rqlFormula; } return out; } function hasOwn(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } function validateTriggerConfig(props: Pick): void { if (!VALID_TRIGGERS.includes(props.triggerType)) { throw new Error( `Invalid trigger_type: ${props.triggerType}. Must be one of: ${VALID_TRIGGERS.join(', ')}`, ); } if (props.triggerType === 'CREATE') { if (props.updateTriggerType != null) { throw new Error('updateTriggerType must be omitted when triggerType is CREATE.'); } return; } if (props.updateTriggerType == null) { throw new Error( `updateTriggerType is required when triggerType is ${ props.triggerType }. Must be one of: ${VALID_UPDATE_TRIGGER_TYPES.join(', ')}`, ); } if (!VALID_UPDATE_TRIGGER_TYPES.includes(props.updateTriggerType)) { throw new Error( `Invalid update_trigger_type: ${ props.updateTriggerType }. Must be one of: ${VALID_UPDATE_TRIGGER_TYPES.join(', ')}`, ); } } /** * Defines a record-triggered automation rule and registers it with the manifest. * * A rule fires automatically when a record on the parent custom object is created, * updated, or both — depending on `triggerType`. Each rule runs a list of actions * that set field values, optionally guarded by an RQL condition. * * @example * ```ts * new Rule(enrollmentObj, { * flowId: 'gym_default_enrollment_status', * flowName: 'Default attendance to Registered', * triggerType: 'CREATE', * description: 'New roster rows start as Registered unless changed', * actions: [{ targetField: enrollmentAttendanceField, fixedValue: ['Registered'] }], * }); * ``` */ export class Rule { static readonly componentType = 'CUSTOM_OBJECT_RECORD_TRIGGERED_FLOW' as const; static toDeletionIdentifier(identifier: RuleDeletionIdentifier): ManifestComponentDeletion { return { type: Rule.componentType, flow_id: identifier.flowId, model_rql_name: identifier.modelRqlName, }; } private readonly _flowId: string; private readonly _customObjectApiName: string; private readonly _flowName: string; private readonly _triggerType: TriggerType; private readonly _rqlCondition: string | undefined; private readonly _updateTriggerType: UpdateTriggerType | undefined; private readonly _description: string; private readonly _actions: RuleAction[]; /** * @param customObject - The custom object this rule is attached to. * @param props - Initialization properties. * @throws {Error} If `props.triggerType` is not one of `'CREATE'`, `'UPDATE'`, `'CREATE_OR_UPDATE'`. * @throws {Error} If `props.updateTriggerType` is missing or invalid for update-style triggers. */ constructor(customObject: CustomObject, props: RuleProps) { validateTriggerConfig(props); this._flowId = props.flowId ?? generateId('flow'); this._customObjectApiName = customObject.getApiName(); this._flowName = props.flowName; this._triggerType = props.triggerType; this._rqlCondition = props.rqlCondition; this._updateTriggerType = props.updateTriggerType; this._description = props.description; this._actions = [...(props.actions ?? [])]; customObject._register(this); } /** * Loads this record-triggered rule from the active existing-manifest JSON context. */ static loadFromExisting(flowId: string, options: RuleLoadFromExistingOptions = {}): Rule { return getExistingManifestContext(options).loadRule(flowId, options); } /** @internal Hydrates a record-triggered rule from existing manifest wire JSON. */ static _fromExistingComponent( customObject: CustomObject, component: Record, resolveField: (apiName: string) => Field, ): Rule { const props: RuleProps = { flowId: requireStringComponentField(component, 'flow_id', Rule.componentType), flowName: requireStringComponentField(component, 'flow_name', Rule.componentType), triggerType: requireStringComponentField(component, 'trigger_type', Rule.componentType) as TriggerType, description: optionalStringComponentField(component, 'description'), actions: (component['actions'] ?? []).map((action: Record): RuleAction => { const out: RuleAction = { targetField: resolveField(action['target_field']), }; if (hasOwn(action, 'fixed_value')) out.fixedValue = action['fixed_value']; if (hasOwn(action, 'rql_formula')) out.rqlFormula = action['rql_formula']; return out; }), }; if (component['rql_condition'] !== undefined) props.rqlCondition = component['rql_condition']; if (component['update_trigger_type'] !== undefined) { props.updateTriggerType = component['update_trigger_type']; } return new Rule(customObject, props); } /** * Returns the stable identifier for this rule (e.g. `'gym_default_enrollment_status'`). */ getFlowId(): string { return this._flowId; } /** * Returns the api_name of the custom object this rule is attached to. */ getCustomObjectApiName(): string { return this._customObjectApiName; } /** * Serializes this rule to the wire format consumed by the manifest install endpoint. * * @returns A plain object with `type: 'CUSTOM_OBJECT_RECORD_TRIGGERED_FLOW'`. * Optional fields are omitted when unset; `actions` is omitted when empty. */ toDict(): Record { const component: Record = { type: Rule.componentType, model_rql_name: this._customObjectApiName, flow_id: this._flowId, flow_name: this._flowName, trigger_type: this._triggerType, description: this._description, }; if (this._updateTriggerType != null) component['update_trigger_type'] = this._updateTriggerType; if (this._rqlCondition != null) component['rql_condition'] = this._rqlCondition; if (this._actions.length > 0) component['actions'] = this._actions.map(actionToDict); return component; } }