import type { SavedSuperGroup } from './saved-super-group'; export interface GroupProps { name: string; description?: string | null; coreRqlRule?: GroupCoreRqlRule | null; } export interface GroupCoreRqlRule { rqlRules?: GroupRuleNode | null; rqlRulesForException?: GroupRuleNode | null; } /** Authored rules are discriminated; extracted wire rules remain opaque during hydration. */ export type GroupRuleNode = { id?: string | null } & ( | { nodeType: 'Operator'; operator: 'AND' | 'OR'; rules: GroupRuleNode[]; value?: never; } | { nodeType: 'Value'; value: GroupRuleValue; operator?: never; rules?: never; } | ({ nodeType: 'Alias'; value: GroupRuleValue } & ( | { operator?: never; rules?: never } | { operator: 'AND' | 'OR'; rules: GroupRuleNode[] } )) ); export interface GroupRuleValue { ruleType: string; rqlCondition?: string | null; orgRql?: GroupOrgRql | null; inclusion?: string[] | null; label?: string | null; comment?: string | null; id?: string | null; visitorPath?: string | null; conditionTree?: string | null; conditionsTreeMap?: Record | null; aliasGroup?: SavedSuperGroup | string | null; } export interface GroupOrgRql { roleIdForRelativeRelation?: string | null; orgRelationId?: string | null; orgRelation?: string | null; baseObject?: string | null; pathToAttribute?: string | null; } const COMPONENT_TYPE = 'SUPERGROUP' as const; export type GroupConfig = Record & { type: typeof COMPONENT_TYPE; name: string }; /** @internal Logical owner identity, separate from persisted destination IDs in extracted config. */ export type GroupIdentity = { savedApiName: string } | { meObjId: string; spokeOwnerHandle: string }; function groupOwnerKey(identity: GroupIdentity): string { if ('savedApiName' in identity) return `SUPERGROUP:${identity.savedApiName}`; return identity.meObjId; } function validateName(name: string): void { if (typeof name !== 'string' || name.trim().length === 0) { throw new Error('Group name must not be blank.'); } } function serializeOrgRql(orgRql: GroupOrgRql | null | undefined): Record | null { if (orgRql == null) return null; return { type: COMPONENT_TYPE, role_id_for_relative_relation: orgRql.roleIdForRelativeRelation ?? null, org_relation_id: orgRql.orgRelationId ?? null, org_relation: orgRql.orgRelation ?? null, base_object: orgRql.baseObject ?? null, path_to_attribute: orgRql.pathToAttribute ?? null, }; } function serializeRuleValue(value: GroupRuleValue | null | undefined): Record | null { if (value == null) return null; const aliasGroup = value.aliasGroup; return { type: COMPONENT_TYPE, rql_condition: value.rqlCondition ?? null, org_rql: serializeOrgRql(value.orgRql), rule_type: value.ruleType, inclusion: value.inclusion == null ? null : [...value.inclusion], label: value.label ?? null, comment: value.comment ?? null, id: value.id ?? null, visitor_path: value.visitorPath ?? null, conditions_tree_map: value.conditionsTreeMap == null ? null : Object.fromEntries( Object.entries(value.conditionsTreeMap).map(([key, conditions]) => [ key, conditions == null ? null : [...conditions], ]), ), condition_tree: value.conditionTree ?? null, alias_group: aliasGroup == null ? null : typeof aliasGroup === 'string' ? aliasGroup : aliasGroup.getApiName(), }; } function serializeRuleNode(node: GroupRuleNode): Record { const { nodeType, operator, rules, value } = node; const hasOperator = operator !== undefined; const hasRules = rules !== undefined; if (nodeType === 'Operator') { if ((operator !== 'AND' && operator !== 'OR') || !Array.isArray(rules) || value !== undefined) { throw new TypeError('Operator rules require an operator and child rules, without a value.'); } } else if (nodeType === 'Value' || nodeType === 'Alias') { if (value == null || typeof value !== 'object' || typeof value.ruleType !== 'string' || !value.ruleType) { throw new TypeError('Value and Alias rules require a rule value.'); } if (nodeType === 'Value' && (hasOperator || hasRules)) { throw new TypeError('Value rules cannot contain operators or child rules.'); } if ( nodeType === 'Alias' && (hasOperator !== hasRules || (hasOperator && ((operator !== 'AND' && operator !== 'OR') || !Array.isArray(rules)))) ) { throw new TypeError('Compound Alias rules require both an operator and child rules.'); } } else { throw new TypeError('Unknown group rule node type.'); } return { type: COMPONENT_TYPE, id: node.id ?? null, node_type: node.nodeType, operator: node.operator ?? null, rules: node.rules == null ? null : node.rules.map(serializeRuleNode), value: serializeRuleValue(node.value), }; } function serializeCoreRqlRule(rule: GroupCoreRqlRule | null | undefined): Record | null { if (rule == null) return null; return { type: COMPONENT_TYPE, rql_rules: rule.rqlRules == null ? null : serializeRuleNode(rule.rqlRules), rql_rules_for_exception: rule.rqlRulesForException == null ? null : serializeRuleNode(rule.rqlRulesForException), }; } function isSavedSuperGroup(value: unknown): value is SavedSuperGroup { if (typeof value !== 'object' || value === null || !('getApiName' in value)) return false; return typeof value.getApiName === 'function'; } function referencedGroups(rule: GroupCoreRqlRule | null | undefined): SavedSuperGroup[] { const groups: SavedSuperGroup[] = []; const seen = new Set(); const visit = (node: GroupRuleNode | null | undefined): void => { if (node == null) return; const aliasGroup = node.value?.aliasGroup; if (isSavedSuperGroup(aliasGroup) && !seen.has(aliasGroup)) { seen.add(aliasGroup); groups.push(aliasGroup); } node.rules?.forEach(visit); }; visit(rule?.rqlRules); visit(rule?.rqlRulesForException); return groups; } /** An unregistered SUPERGROUP configuration that can be embedded or saved. */ export class Group { private _component: GroupConfig; private _referencedGroups: SavedSuperGroup[]; private _sourceOwner: string | undefined; constructor(props: GroupProps) { validateName(props.name); this._component = { type: COMPONENT_TYPE, spoke_id: null, name: props.name, display_name: null, spoke_owner_handle: null, group_type: null, sub_group_type: null, read_only: false, description: props.description ?? null, mutually_exclusive_key: null, cumulatively_exhaustive_default: false, core_rql_rule: serializeCoreRqlRule(props.coreRqlRule), provision_role_state: null, include_terminated: false, allow_non_employees: false, can_over_ride_role_states: false, over_ride_role_states: null, dynamic_role_inclusion_fields: null, priority: null, sync_enabled: false, group_number: null, is_invisible: false, is_migrated_to_new_custom_fields: false, ignore_prov_group_matching: false, sharing_grant: null, alias_access_type: null, alias_type: null, grant_type: null, }; this._referencedGroups = referencedGroups(props.coreRqlRule); } /** @internal Hydrates a shared group configuration without registering it. */ static _fromExistingConfig(config: Record, identity: GroupIdentity): Group { if (config['type'] !== COMPONENT_TYPE) { throw new Error(`Group config type must be "${COMPONENT_TYPE}".`); } const name = config['name']; if (typeof name !== 'string') { throw new Error('SUPERGROUP component field "name" must be a string.'); } const group = new Group({ name }); group._component = JSON.parse(JSON.stringify(config)) as GroupConfig; group._referencedGroups = []; group._sourceOwner = groupOwnerKey(identity); return group; } getName(): string { return this._component['name']; } setName(name: string): this { validateName(name); this._component['name'] = name; return this; } setDescription(description: string | null): this { this._component['description'] = description; return this; } setCoreRqlRule(rule: GroupCoreRqlRule | null): this { this._component['core_rql_rule'] = serializeCoreRqlRule(rule); this._referencedGroups = referencedGroups(rule); return this; } getReferencedGroups(): SavedSuperGroup[] { return [...this._referencedGroups]; } /** @internal Hydrated identity is preserved only for updates to its original logical owner. */ _toDict(identity: GroupIdentity): GroupConfig { if (this._sourceOwner !== undefined && this._sourceOwner !== groupOwnerKey(identity)) { throw new TypeError( 'A hydrated group cannot be reused under another owner or slot. Create a fresh Group instead.', ); } const config = JSON.parse(JSON.stringify(this._component)) as GroupConfig; if ('meObjId' in identity) { if (!Object.prototype.hasOwnProperty.call(config, 'me_obj_id')) { config['me_obj_id'] = identity.meObjId; } if (config['spoke_owner_handle'] == null) { config['spoke_owner_handle'] = identity.spokeOwnerHandle; } } return config; } }