// genesis-notify's notification rules and rule templates: their ten events // (genesis-notify-rules-eventhandler.kts below as RULES-EH), the request // servers that read them back (genesis-notify-rules-reqrep.kts, RULES-RR), // and the rule and template reads with the filters the generated backing // leaves out (genesis-notify-rules-dataserver.kts), genesis-notify v8.15.16. // // A rule is stored in DYNAMIC_RULE as Groovy strings, written from the model // the rule dialog sends as GSF's EvaluatorExpressionWriter writes them. GSF // reads them back for the dialog with a Groovy-based parser; this answers // from the model it was sent instead, which differs only where parsing the // written string wouldn't give that model back (a BigDecimal's formatting, // say). Rules never fire here: GSF's DynamicRulesEngine, which raises an // EVENT_NOTIFY_INSERT when a matching record changes, isn't modelled. import type { Store } from '../db/store.ts'; import { NackError } from '../protocol/errors.ts'; import { schemaFieldErrors, validateSchema } from '../protocol/schemaValidation.ts'; import type { EventHandler, EventHandlerCtx, EventSchemaOverride, FieldDef, QueryDef, RequestingUser, RequestReplyDef, Row, } from '../types.ts'; import { dto, ENUMS, nullable, type NotifyUsers } from './notify.ts'; import criteriaModel from './platform/criteria-model.json' with { type: 'json' }; const ADMIN_RIGHT = 'NotificationAdminAction'; const ON_BEHALF_RIGHT = 'SendNotificationOnBehalfOfOthers'; // --- the expression model, written as GSF does --------------------------------- // global.genesis.criteria.model.Operation, MethodCall (genesis-criteria, GSF // v8.15.29 Expressions.kt). const OPERATIONS: Record = { GREATER_THAN: '>', LESS_THAN: '<', GREATER_THAN_OR_EQUAL: '>=', LESS_THAN_OR_EQUAL: '<=', EQUALS: '==', NOT_EQUALS: '!=', AND: '&&', OR: '||', PLUS: '+', MINUS: '-', MULTIPLY: '*', DIVIDE: '/', BITWISE_AND: '&', BITWISE_OR: '|', ASSIGNMENT: '=', }; const METHODS: Record = { CONTAINS_IGNORE_CASE: 'Expr.containsIgnoreCase', CONTAINS_WORDS_STARTING_WITH_IGNORE_CASE: 'Expr.containsWordsStartingWithIgnoreCase', IS_NULL_OR_BLANK: 'Expr.isNullOrBlank', NOT_NULL_OR_BLANK: 'Expr.notNullOrBlank', LONG_TO_DATE: 'Expr.longLocalDate', LONG_TO_DATE_TIME: 'Expr.longDateTime', DATE_TIME_IS_IN_RANGE: 'Expr.dateTimeIsInRange', }; const operation = (name: unknown) => OPERATIONS[String(name)] ?? String(name); // EvaluatorExpressionWriter.writeExpression (genesis-criteria util). function writeExpression(expression: Row): string { switch (expression?.TYPE) { case 'FIELD': return String(expression.NAME); case 'NUMBER': return String(expression.VALUE); case 'PLACEHOLDER': return `{{${expression.KEY}}}`; case 'STRING': return `"${expression.VALUE}"`; case 'BOOLEAN': return String(expression.VALUE); case 'NULL': return 'null'; case 'BINARY_EXPRESSION': return `(${writeExpression(expression.LEFT)} ${operation(expression.OPERATION)} ${writeExpression(expression.RIGHT)})`; case 'METHOD_EXPRESSION': return `${METHODS[expression.METHOD] ?? expression.METHOD}(${(expression.PARAMETERS ?? []).map(writeExpression).join(', ')})`; case 'PREDICATE_EXPRESSION': return `(${(expression.EXPRESSIONS ?? []) .map(writeExpression) .join(` ${operation(expression.OPERATION ?? 'AND')} `)})`; default: throw NackError.standard('INTERNAL_ERROR'); } } const writeRule = (rule: Row) => `(${(rule.CONDITIONS ?? []) .map( (condition: Row) => `(${writeExpression(condition.LEFT)} ${operation(condition.OPERATION)} ${writeExpression(condition.RIGHT)})`, ) .join(' && ')})`; const writeResult = (assignments: Row[]) => `(${assignments .map( (assignment) => `(${writeExpression(assignment.FIELD)} = ${writeExpression(assignment.VALUE)})`, ) .join(' && ')})`; // RULE_EXPRESSION and RESULT_EXPRESSION as GSF's inbound schema describes // them (platform/criteria-model.json: EventMeta's schema of // NotificationRuleInsert on genesis-criteria 8.15.29, its Expression tree in // $defs). GSF's decoder checks them before the handler, VALIDATE too: an // operation, method or value off the tree (a NUMBER's VALUE is a decimal // string) is a VALIDATION_ERROR, with every oneOf branch's complaint. const EXPRESSIONS_SCHEMA = { properties: { DETAILS: { properties: { RULE_EXPRESSION: criteriaModel.RULE_EXPRESSION, RESULT_EXPRESSION: criteriaModel.RESULT_EXPRESSION, }, }, }, $defs: criteriaModel.$defs, }; function checkExpressions(details: Row): void { const sent = Object.fromEntries( ['RULE_EXPRESSION', 'RESULT_EXPRESSION'] .filter((name) => Object.hasOwn(details, name)) .map((name) => [name, details[name]]), ); const errors = schemaFieldErrors(validateSchema(EXPRESSIONS_SCHEMA, { DETAILS: sent })); if (errors.length > 0) throw new NackError(errors); } const field = (NAME: string) => ({ TYPE: 'FIELD', NAME }); const assign = (name: string, VALUE: Row) => ({ FIELD: field(name), VALUE }); // The model a rule was sent, kept by its DYNAMIC_RULE ID for the details // request servers: RULE_EXPRESSION (or null, with the raw string), // RESULT_EXPRESSION with the assignments the handler adds. interface RuleModel { rule: Row | null; raw: string | null; result: Row | null; } // --- schemas --------------------------------------------------------------------- const MESSAGE_CLASS = 'global.genesis.notify.message.event'; const TABLE_OPERATIONS = ['INSERT', 'MODIFY', 'DELETE']; // RuleExpression and ResultExpression hold a sealed Expression tree, which // FieldDef can't describe: declared as objects, and checked against GSF's own // schema of them before the handler (checkExpressions). const RULE_FIELDS: FieldDef[] = [ dto('RULE_NAME', 'STRING'), dto('RULE_DESCRIPTION', 'STRING', { optional: true }), dto('RULE_TABLE', 'STRING'), dto('RULE_EXPRESSION', 'MAP', nullable), dto('RAW_RULE_EXPRESSION', 'STRING', nullable), dto('RESULT_EXPRESSION', 'MAP'), dto('TABLE_OPERATIONS', 'ARRAY', { items: { type: 'ENUM', values: TABLE_OPERATIONS, enumClass: 'global.genesis.db.updatequeue.UpdateType', }, unique: true, optional: true, }), dto('ALERT_SOUND_FILE_NAME', 'STRING', nullable), dto('ALERT_SOUND_MIME_TYPE', 'STRING', nullable), dto('ALERT_SOUND_DATA', 'STRING', nullable), ]; // NotificationRuleTemplateParameters. const TEMPLATE_PARAMETERS: FieldDef = dto('PARAMETER_DETAILS', 'MAP', { items: { type: 'OBJECT', className: 'global.genesis.notify.message.event.NotificationRuleTemplateParameters', nullable: false, fields: [ dto('PARAM_SOURCE', 'STRING'), dto('PARAM_SOURCE_TYPE', 'ENUM', { values: ['USER_TEXT', 'DEFINED_GROUP', 'REQ_REP'], enumClass: `${ENUMS}.ParamSourceType`, }), dto('PARAM_TYPE', 'ENUM', { values: ['FIELD', 'STRING', 'BOOLEAN', 'NUMBER'], enumClass: `${ENUMS}.ParamType`, }), dto('PARAM_LABEL', 'STRING'), dto('PARAM_OPERATOR', 'STRING'), ], }, }); const RULE_ID = dto('DYNAMIC_RULE_ID', 'STRING'); const CLEAR_ALERT_SOUND = dto('CLEAR_ALERT_SOUND', 'BOOLEAN', { optional: true }); const BY_ID: EventSchemaOverride = { table: 'DYNAMIC_RULE', pkOnly: true, className: 'global.genesis.gen.dao.DynamicRule.ById', }; const SCHEMAS: Record = { EVENT_NOTIFICATION_RULE_TEMPLATE_CREATE: { table: 'DYNAMIC_RULE_TEMPLATE_PARAMS', className: `${MESSAGE_CLASS}.NotificationRuleTemplateInsert`, fields: [...RULE_FIELDS, TEMPLATE_PARAMETERS], }, EVENT_NOTIFICATION_RULE_TEMPLATE_UPDATE: { table: 'DYNAMIC_RULE_TEMPLATE_PARAMS', className: `${MESSAGE_CLASS}.NotificationRuleTemplateUpdate`, fields: [RULE_ID, ...RULE_FIELDS, TEMPLATE_PARAMETERS, CLEAR_ALERT_SOUND], }, EVENT_NOTIFICATION_RULE_TEMPLATE_DELETE: BY_ID, EVENT_NOTIFICATION_RULE_CREATE: { className: `${MESSAGE_CLASS}.NotificationRuleInsert`, fields: RULE_FIELDS, }, EVENT_NOTIFICATION_RULE_UPDATE: { className: `${MESSAGE_CLASS}.NotificationRuleUpdate`, fields: [RULE_ID, ...RULE_FIELDS, CLEAR_ALERT_SOUND], }, EVENT_NOTIFICATION_RULE_DELETE: BY_ID, EVENT_NOTIFICATION_RULE_SUBSCRIBE: { className: `${MESSAGE_CLASS}.NotificationRuleSubscribe`, fields: [RULE_ID, dto('PARAMETER_DETAILS', 'MAP', { items: { type: 'STRING' } })], }, EVENT_NOTIFICATION_RULE_UNSUBSCRIBE: BY_ID, EVENT_NOTIFICATION_RULE_DISABLE: BY_ID, EVENT_NOTIFICATION_RULE_ENABLE: BY_ID, }; // --- alert sounds (RULES-EH :23-117) ------------------------------------------------ // 1536 * 1024, and the base64 length that holds it: ((bytes + 2) / 3) * 4. const MAX_ALERT_SOUND_BYTES = 1_572_864; const MAX_ALERT_SOUND_BASE64_CHARS = 2_097_152; // What java.util.Base64's decoder takes: whole 4-character units, the last of // which may be 2 or 3 characters, padded or not. const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/; const TOO_BIG = 'Alert sound exceeds maximum size of 1.5MB'; const isBlank = (value: unknown) => value === undefined || value === null || String(value).trim() === ''; // decodeAlertSoundData: the bytes, as canonical base64 (the RAW column's // form here), or null for none. function decodeAlertSound(data: unknown): string | null { if (isBlank(data)) return null; const text = String(data) .replace(/^data:[^;,]*;base64,/, '') .replace(/\s/g, ''); if (text.length > MAX_ALERT_SOUND_BASE64_CHARS) throw new NackError(TOO_BIG); if (!BASE64.test(text)) { throw new NackError('Alert sound data is not valid Base64'); } const bytes = Buffer.from(text, 'base64'); if (bytes.length > MAX_ALERT_SOUND_BYTES) throw new NackError(TOO_BIG); return bytes.toString('base64'); } function validateAlertSound(details: Row): void { if (isBlank(details.ALERT_SOUND_DATA)) return; if (isBlank(details.ALERT_SOUND_MIME_TYPE)) { throw new NackError('MIME type is required when an alert sound is provided'); } if (isBlank(details.ALERT_SOUND_FILE_NAME)) { throw new NackError('File name is required when an alert sound is provided'); } decodeAlertSound(details.ALERT_SOUND_DATA); } function validateAlertSoundUpdate(details: Row): void { if (details.CLEAR_ALERT_SOUND === true && !isBlank(details.ALERT_SOUND_DATA)) { throw new NackError('clearAlertSound cannot be combined with alertSoundData'); } validateAlertSound(details); } // --- the backing ------------------------------------------------------------------- const PARAMS = 'DYNAMIC_RULE_TEMPLATE_PARAMS'; // A template's params as GSF reads them, through the DYNAMIC_RULE_ID index: // in primary-key order, so by PARAM_NAME. const paramsOf = (store: EventHandlerCtx['store'], ruleId: string) => store .getAllRows(PARAMS) .filter((row) => row.DYNAMIC_RULE_ID === ruleId) .sort((a, b) => (a.PARAM_NAME < b.PARAM_NAME ? -1 : a.PARAM_NAME > b.PARAM_NAME ? 1 : 0)); // The rules' and the templates' `where` / `filter`: IS_TEMPLATE is false // unless set (its dictionary default). const where = (template: boolean) => (row: Row) => (row.IS_TEMPLATE ?? false) === template && row.RULE_TYPE === 'NOTIFY'; export interface RuleBacking { eventHandlers: Record; eventSchemas: Record; requestReplies: Record; queries: Record; } // The events whose DTO holds the expressions. const WITH_EXPRESSIONS = new Set([ 'EVENT_NOTIFICATION_RULE_TEMPLATE_CREATE', 'EVENT_NOTIFICATION_RULE_TEMPLATE_UPDATE', 'EVENT_NOTIFICATION_RULE_CREATE', 'EVENT_NOTIFICATION_RULE_UPDATE', ]); export function ruleBacking(users: NotifyUsers): RuleBacking { // Each rule's model, per server, with the strings it was stored as: a rule // whose strings have changed since, or one the mock didn't write, has none. // Kept and dropped once the event commits. const models = new WeakMap>(); const modelsOf = (store: Store) => { let kept = models.get(store); if (!kept) { kept = new Map(); models.set(store, kept); } return kept; }; const keep = (ctx: EventHandlerCtx, id: string, row: Row, model: RuleModel) => ctx.afterCommit(() => modelsOf(ctx.store).set(id, { model, stored: [row.RULE_EXPRESSION, row.RESULT_EXPRESSION] }), ); const forget = (ctx: EventHandlerCtx, id: string) => ctx.afterCommit(() => modelsOf(ctx.store).delete(id)); const keptFor = (store: Store, row: Row): RuleModel | undefined => { const kept = modelsOf(store).get(row.ID); const [rule, result] = kept?.stored ?? []; return rule === row.RULE_EXPRESSION && result === row.RESULT_EXPRESSION ? kept?.model : undefined; }; const isAdmin = (store: Store, user: string) => users.hasRight(store, user, ADMIN_RIGHT); // Writes a row and pushes it live. function write( ctx: EventHandlerCtx, table: string, op: 'INSERT' | 'MODIFY', row: Row, key?: string, ) { const { store } = ctx; const written = op === 'INSERT' ? store.insertRow(table, row) : store.updateRow(table, key!, row)!; ctx.broadcastTableChange(table, op, store.rowKey(table, written)); return written; } function remove(ctx: EventHandlerCtx, table: string, key: string) { if (ctx.store.deleteRow(table, key)) ctx.broadcastTableChange(table, 'DELETE', key); } const rowsWhere = (ctx: EventHandlerCtx, table: string, test: (row: Row) => boolean) => ctx.store.getAllRows(table).filter(test); // The rule's own record, or the 2.2 checks' NACK: it must exist, and be // the event user's or the user an admin. function ownedRule(ctx: EventHandlerCtx, id: string, user: string, missing: string): Row { const rule = ctx.store.getRow('DYNAMIC_RULE', id); if (!rule) throw new NackError(missing); if (rule.USER_NAME !== user && !isAdmin(ctx.store, user)) { throw new NackError(`User ${user} lacks sufficient permissions`); } return rule; } // The result assignments a rule is stored with (RULES-EH :128-174): the sent // ones, then TABLE_NAME, TABLE_ENTITY_ID (a table with a single-field key // only; what GSF does for a view isn't known, so none) and SENDER. function resultAssignments(ctx: EventHandlerCtx, details: Row, sender: Row): Row[] { const table = String(details.RULE_TABLE); const key = ctx.store.hasTable(table) ? ctx.store.getPkFields(table) : []; return [ ...((details.RESULT_EXPRESSION?.ASSIGNMENTS as Row[] | undefined) ?? []), assign('TABLE_NAME', { TYPE: 'STRING', VALUE: table }), ...(key.length === 1 ? [assign('TABLE_ENTITY_ID', field(key[0]))] : []), assign('SENDER', sender), ]; } // The DYNAMIC_RULE row 2.1 / 2.4 build, and the model it was built from. function ruleRow(ctx: EventHandlerCtx, details: Row, user: string, template: boolean) { const rule: Row | null = details.RULE_EXPRESSION ?? null; const raw: string | null = rule ? null : (details.RAW_RULE_EXPRESSION ?? null); // ruleExpressionString: the model written, else the raw string (both // null is a NullPointerException). if (!rule && raw === null) throw NackError.standard('INTERNAL_ERROR'); const sender = template ? { TYPE: 'PLACEHOLDER', KEY: 'USER' } : { TYPE: 'STRING', VALUE: user }; const assignments = resultAssignments(ctx, details, sender); const operations = (details.TABLE_OPERATIONS as string[] | undefined) ?? ['INSERT']; return { row: { USER_NAME: user, NAME: details.RULE_NAME, DESCRIPTION: details.RULE_DESCRIPTION ?? '', RULE_TABLE: details.RULE_TABLE, RULE_EXECUTION_STRATEGY: 'UNLIMITED', RULE_EXPRESSION: rule ? writeRule(rule) : raw, RESULT_EXPRESSION: writeResult(assignments), RULE_STATUS: 'ENABLED', MESSAGE_TYPE: 'EVENT_NOTIFY_INSERT', IS_TEMPLATE: template, RULE_TYPE: 'NOTIFY', TABLE_OPERATION: [...new Set(operations)].join('|'), PROCESS_NAME: null, }, model: { rule, raw, result: { ASSIGNMENTS: assignments } }, }; } // upsertAlertSound (:77-103): one sound per rule, kept by its id. function upsertSound(ctx: EventHandlerCtx, ruleId: string, details: Row, data: string) { const existing = rowsWhere( ctx, 'NOTIFY_ALERT_SOUND', (row) => row.DYNAMIC_RULE_ID === ruleId, )[0]; const row = { DYNAMIC_RULE_ID: ruleId, ALERT_SOUND_FILE_NAME: details.ALERT_SOUND_FILE_NAME, ALERT_SOUND_MIME_TYPE: details.ALERT_SOUND_MIME_TYPE, ALERT_SOUND_DATA: data, }; if (existing) write(ctx, 'NOTIFY_ALERT_SOUND', 'MODIFY', row, existing.ALERT_SOUND_ID); else write(ctx, 'NOTIFY_ALERT_SOUND', 'INSERT', row); } function deleteSound(ctx: EventHandlerCtx, ruleId: string) { for (const row of rowsWhere( ctx, 'NOTIFY_ALERT_SOUND', (each) => each.DYNAMIC_RULE_ID === ruleId, )) { remove(ctx, 'NOTIFY_ALERT_SOUND', row.ALERT_SOUND_ID); } } const soundOf = (ctx: EventHandlerCtx, ruleId: string) => rowsWhere(ctx, 'NOTIFY_ALERT_SOUND', (row) => row.DYNAMIC_RULE_ID === ruleId)[0]; const reservedUser = (details: Row) => { if (Object.hasOwn(details.PARAMETER_DETAILS ?? {}, 'USER')) { throw new NackError( 'USER is a reserved keyword for rule creation, please choose another key for your parameter', ); } }; // The template's parameters as DYNAMIC_RULE_TEMPLATE_PARAMS rows. const paramRows = (ruleId: string, details: Row): Row[] => Object.entries((details.PARAMETER_DETAILS ?? {}) as Record).map( ([name, param]) => ({ DYNAMIC_RULE_ID: ruleId, PARAM_NAME: name, PARAM_SOURCE: param.PARAM_SOURCE, PARAM_SOURCE_TYPE: param.PARAM_SOURCE_TYPE, PARAM_TYPE: param.PARAM_TYPE, PARAM_LABEL: param.PARAM_LABEL, PARAM_OPERATOR: param.PARAM_OPERATOR, }), ); // DELETE, UNSUBSCRIBE (:510-531, :583-603): the sound, the template link, // the rule. const deleteRule = (missing: (id: string) => string): EventHandler => (details, ctx) => { const user = users.userOf(ctx); const id = details.ID; ownedRule(ctx, id, user, missing(id)); if (ctx.validate) return {}; deleteSound(ctx, id); remove(ctx, 'NOTIFY_RULE_TEMPLATE_LINK', id); remove(ctx, 'DYNAMIC_RULE', id); forget(ctx, id); return {}; }; // DISABLE, ENABLE (:606-651). const setStatus = (status: string): EventHandler => (details, ctx) => { const user = users.userOf(ctx); const rule = ctx.store.getRow('DYNAMIC_RULE', details.ID); if (rule?.USER_NAME !== user && !isAdmin(ctx.store, user)) { throw new NackError(`User ${user} lacks sufficient permissions`); } if (ctx.validate) return {}; if (!rule) throw NackError.nack(`Cannot find Dynamic Rule for ID: ${details.ID}`); write(ctx, 'DYNAMIC_RULE', 'MODIFY', { RULE_STATUS: status }, details.ID); return {}; }; const notFound = (id: string) => `Dynamic Rule of ID ${id} doesn't exist!`; const eventHandlers: Record = { // :177-246 EVENT_NOTIFICATION_RULE_TEMPLATE_CREATE: (details, ctx) => { reservedUser(details); validateAlertSound(details); if (ctx.validate) return {}; const user = users.userOf(ctx); const { row, model } = ruleRow(ctx, details, user, true); const id = write(ctx, 'DYNAMIC_RULE', 'INSERT', row).ID; for (const param of paramRows(id, details)) { write(ctx, PARAMS, 'INSERT', param); } const sound = decodeAlertSound(details.ALERT_SOUND_DATA); if (sound) upsertSound(ctx, id, details, sound); keep(ctx, id, row, model); return {}; }, // :249-356: the whole row rebuilt, the editor its owner; the sent params // upserted and the others deleted; a sound change reaches every rule // subscribed to the template. EVENT_NOTIFICATION_RULE_TEMPLATE_UPDATE: (details, ctx) => { const user = users.userOf(ctx); const id = details.DYNAMIC_RULE_ID; ownedRule(ctx, id, user, notFound(id)); reservedUser(details); validateAlertSoundUpdate(details); if (ctx.validate) return {}; const { row, model } = ruleRow(ctx, details, user, true); write(ctx, 'DYNAMIC_RULE', 'MODIFY', row, id); const sent = paramRows(id, details); for (const old of paramsOf(ctx.store, id)) { if (!sent.some((param) => param.PARAM_NAME === old.PARAM_NAME)) { remove(ctx, PARAMS, ctx.store.rowKey(PARAMS, old)); } } for (const param of sent) { const key = ctx.store.rowKey(PARAMS, param); if (ctx.store.getRow(PARAMS, key)) write(ctx, PARAMS, 'MODIFY', param, key); else write(ctx, PARAMS, 'INSERT', param); } const linked = rowsWhere( ctx, 'NOTIFY_RULE_TEMPLATE_LINK', (link) => link.TEMPLATE_DYNAMIC_RULE_ID === id, ).map((link) => link.DYNAMIC_RULE_ID as string); if (details.CLEAR_ALERT_SOUND === true) { for (const ruleId of [id, ...linked]) deleteSound(ctx, ruleId); } else { const sound = decodeAlertSound(details.ALERT_SOUND_DATA); if (sound) for (const ruleId of [id, ...linked]) upsertSound(ctx, ruleId, details, sound); } keep(ctx, id, row, model); return {}; }, // :359-383: the sound, the links (the subscribed rules stay), the // template, its params. EVENT_NOTIFICATION_RULE_TEMPLATE_DELETE: (details, ctx) => { const user = users.userOf(ctx); const id = details.ID; ownedRule(ctx, id, user, notFound(id)); if (ctx.validate) return {}; deleteSound(ctx, id); for (const link of rowsWhere( ctx, 'NOTIFY_RULE_TEMPLATE_LINK', (each) => each.TEMPLATE_DYNAMIC_RULE_ID === id, )) { remove(ctx, 'NOTIFY_RULE_TEMPLATE_LINK', link.DYNAMIC_RULE_ID); } remove(ctx, 'DYNAMIC_RULE', id); for (const param of paramsOf(ctx.store, id)) remove(ctx, PARAMS, ctx.store.rowKey(PARAMS, param)); forget(ctx, id); return {}; }, // :386-439 EVENT_NOTIFICATION_RULE_CREATE: (details, ctx) => { validateAlertSound(details); if (ctx.validate) return {}; const user = users.userOf(ctx); const { row, model } = ruleRow(ctx, details, user, false); const id = write(ctx, 'DYNAMIC_RULE', 'INSERT', row).ID; const sound = decodeAlertSound(details.ALERT_SOUND_DATA); if (sound) upsertSound(ctx, id, details, sound); keep(ctx, id, row, model); return {}; }, // :442-507: rebuilt as a rule (IS_TEMPLATE false), the editor its owner. EVENT_NOTIFICATION_RULE_UPDATE: (details, ctx) => { const user = users.userOf(ctx); const id = details.DYNAMIC_RULE_ID; ownedRule(ctx, id, user, notFound(id)); validateAlertSoundUpdate(details); if (ctx.validate) return {}; const { row, model } = ruleRow(ctx, details, user, false); write(ctx, 'DYNAMIC_RULE', 'MODIFY', row, id); if (details.CLEAR_ALERT_SOUND === true) deleteSound(ctx, id); else { const sound = decodeAlertSound(details.ALERT_SOUND_DATA); if (sound) upsertSound(ctx, id, details, sound); } keep(ctx, id, row, model); return {}; }, EVENT_NOTIFICATION_RULE_DELETE: deleteRule(notFound), // :533-580: a rule from the template, its expressions' placeholders // filled as Mustache fills them (DynamicRuleFactory.kt:50-96). EVENT_NOTIFICATION_RULE_SUBSCRIBE: (details, ctx) => { const id = details.DYNAMIC_RULE_ID; const template = ctx.store.getRow('DYNAMIC_RULE', id); if (!template) throw NackError.nack(`Unable to find dynamic rule template with id ${id}`); const params = paramsOf(ctx.store, id); const required = params.map((param) => param.PARAM_NAME as string); const provided = Object.keys(details.PARAMETER_DETAILS ?? {}); if (required.includes('USER') || provided.includes('USER')) { throw new NackError('USER is a reserved keyword and cannot be used as a parameter key'); } if ( required.length !== provided.length || required.some((name) => !provided.includes(name)) ) { throw new NackError( `The required parameters ([${required.join(', ')}]) did not match the provided parameters ([${provided.join(', ')}]) for template ${id}`, ); } if (ctx.validate) return {}; const user = users.userOf(ctx); // transformInputs: the sent values, a STRING parameter's quoted, and // USER the quoted subscriber. Mustache HTML-escapes each value it puts // in (mustache.java 0.9.14), an unknown key gives "" (GSF looks it up // in the system definition first, which the mock doesn't model), and // then every " in the result goes back to ". const values = new Map(Object.entries(details.PARAMETER_DETAILS ?? {})); values.set('USER', user); const typeOf = (key: string) => key === 'USER' ? 'STRING' : params.find((param) => param.PARAM_NAME === key)?.PARAM_TYPE; const shown = (key: string) => escapeHtml(values.get(key) ?? '').replace(/"/g, '"'); const fill = (text: string | null) => (text ?? '') .replace(/\{\{([^}]*)\}\}/g, (_match, tag: string) => { const key = tag.trim(); if (!values.has(key)) return ''; return typeOf(key) === 'STRING' ? `"${shown(key)}"` : shown(key); }) .replace(/"/g, '"'); const created = write(ctx, 'DYNAMIC_RULE', 'INSERT', { NAME: template.NAME, DESCRIPTION: template.DESCRIPTION, RULE_TABLE: template.RULE_TABLE, RULE_STATUS: template.RULE_STATUS, RULE_EXECUTION_STRATEGY: template.RULE_EXECUTION_STRATEGY, PROCESS_NAME: template.PROCESS_NAME, MESSAGE_TYPE: template.MESSAGE_TYPE, TABLE_OPERATION: template.TABLE_OPERATION, USER_NAME: user, IS_TEMPLATE: false, RULE_TYPE: 'NOTIFY', RULE_EXPRESSION: fill(template.RULE_EXPRESSION), RESULT_EXPRESSION: fill(template.RESULT_EXPRESSION), }); write(ctx, 'NOTIFY_RULE_TEMPLATE_LINK', 'INSERT', { DYNAMIC_RULE_ID: created.ID, TEMPLATE_DYNAMIC_RULE_ID: id, }); const sound = soundOf(ctx, id); if (sound) upsertSound(ctx, created.ID, sound, sound.ALERT_SOUND_DATA); // The instance's model: the template's, each placeholder the value the // stored string got, as GSF would read it back. const templateModel = keptFor(ctx.store, template); if (templateModel) { const placeholder = (key: string): Row => { const value = shown(key); switch (typeOf(key)) { case 'STRING': return { TYPE: 'STRING', VALUE: value }; // NumericValue's VALUE is a BigDecimal, a string on the wire. case 'NUMBER': return { TYPE: 'NUMBER', VALUE: value }; case 'BOOLEAN': return { TYPE: 'BOOLEAN', VALUE: value === 'true' }; default: return { TYPE: 'FIELD', NAME: value }; } }; const filled = (expression: Row | null) => expression && fillModel(expression, placeholder); keep(ctx, created.ID, created, { rule: filled(templateModel.rule), raw: templateModel.raw === null ? null : fill(templateModel.raw), result: filled(templateModel.result), }); } return {}; }, EVENT_NOTIFICATION_RULE_UNSUBSCRIBE: deleteRule( (id) => `No record found for Dynamic Rule ID: ${id}`, ), EVENT_NOTIFICATION_RULE_DISABLE: setStatus('DISABLED'), EVENT_NOTIFICATION_RULE_ENABLE: setStatus('ENABLED'), }; // NOTIFICATION_RULE_DETAILS (:101) and _TEMPLATE_DETAILS (:56): the rule // as the dialog sent it. A rule the mock didn't write (a seeded one), or // whose strings have changed since, answers its stored string as the raw // expression, as GSF does for one too complex for the dialog's model, and // no result expression. const details = (template: boolean): RequestReplyDef => ({ requestFields: ['ID'], resolver: ({ request, store }) => { const rule = store.getRow('DYNAMIC_RULE', request.ID); // A failed `require`: GENERIC_ERROR with its text; the request is a // data class, so it prints as ById(id=…). if (!rule) throw new Error(`Could not find dynamic rule record for id: ById(id=${request.ID})`); const model = keptFor(store, rule) ?? { rule: null, raw: rule.RULE_EXPRESSION ?? null, result: null, }; return [ { DYNAMIC_RULE_ID: rule.ID, RULE_NAME: rule.NAME, RULE_DESCRIPTION: rule.DESCRIPTION ?? null, RULE_TABLE: rule.RULE_TABLE ?? '', RULE_EXECUTION_STRATEGY: rule.RULE_EXECUTION_STRATEGY, RULE_EXPRESSION: model.rule, RAW_RULE_EXPRESSION: model.raw, RESULT_EXPRESSION: model.result, ...(template ? { PARAMETERS: Object.fromEntries( paramsOf(store, rule.ID).map((param) => [param.PARAM_NAME, param]), ), } : {}), TABLE_OPERATIONS: tableOperations(rule), }, ]; }, metadataRows: [ { DYNAMIC_RULE_ID: '', RULE_NAME: '', RULE_DESCRIPTION: '', RULE_TABLE: '', RULE_EXECUTION_STRATEGY: 'UNLIMITED', }, ], }); // NOTIFY_ALERT_SOUND (:172-203): the rule's sound, to whoever may hear it. const alertSound: RequestReplyDef = { requestFields: ['DYNAMIC_RULE_ID'], resolver: ({ request, store, user }) => { const sound = store .getAllRows('NOTIFY_ALERT_SOUND') .find((row) => row.DYNAMIC_RULE_ID === request.DYNAMIC_RULE_ID); const rule = sound && store.getRow('DYNAMIC_RULE', sound.DYNAMIC_RULE_ID); if (!sound || !rule || !mayHear(store, rule, user)) return []; return [ { ALERT_SOUND_ID: sound.ALERT_SOUND_ID, DYNAMIC_RULE_ID: sound.DYNAMIC_RULE_ID, ALERT_SOUND_FILE_NAME: sound.ALERT_SOUND_FILE_NAME, ALERT_SOUND_MIME_TYPE: sound.ALERT_SOUND_MIME_TYPE, SOUND_DATA: sound.ALERT_SOUND_DATA, }, ]; }, metadataRows: [ { ALERT_SOUND_ID: '', DYNAMIC_RULE_ID: '', ALERT_SOUND_FILE_NAME: '', ALERT_SOUND_MIME_TYPE: '', SOUND_DATA: '', }, ], }; const mayHear = (store: Store, rule: Row, requester: RequestingUser) => { const name = requester.userName ?? users.fallback; return ( rule.IS_TEMPLATE === true || rule.USER_NAME === name || users.hasRight(store, name, ADMIN_RIGHT) || rule.USER_NAME === 'SYSTEM' || users.hasRight(store, rule.USER_NAME, ON_BEHALF_RIGHT) ); }; return { eventHandlers: Object.fromEntries( Object.entries(eventHandlers).map(([name, handler]): [string, EventHandler] => [ name, WITH_EXPRESSIONS.has(name) ? (details, ctx) => { checkExpressions(details); return handler(details, ctx); } : handler, ]), ), eventSchemas: SCHEMAS, requestReplies: { NOTIFICATION_RULE_DETAILS: details(false), NOTIFICATION_RULE_TEMPLATE_DETAILS: details(true), NOTIFY_ALERT_SOUND: alertSound, // The `filter` and the user filter the generated backing leaves out. NOTIFICATION_RULE: { source: 'DYNAMIC_RULE_VIEW', requestFields: ['ID'], filter: where(false), auth: { rowFilter: (row, user) => row.USER_NAME === (user.userName ?? users.fallback) }, }, NOTIFICATION_RULE_TEMPLATE: { source: 'DYNAMIC_RULE_VIEW', requestFields: ['ID'], filter: where(true), }, }, queries: { ALL_DYNAMIC_NOTIFY_RULES: { source: 'DYNAMIC_RULE', filter: where(false) }, ALL_RULE_TEMPLATES: { source: 'DYNAMIC_RULE', filter: where(true) }, }, }; } // RULES-RR :80/:121: TABLE_OPERATION split on '|', each UpdateType.valueOf, // whose failure (an empty or unknown one) fails the request. function tableOperations(rule: Row): string[] { return String(rule.TABLE_OPERATION ?? '') .split('|') .map((name) => { if (!TABLE_OPERATIONS.includes(name)) { throw new Error(`No enum constant global.genesis.db.updatequeue.UpdateType.${name}`); } return name; }); } // Mustache.java's HTML escaping of a value. function escapeHtml(text: string): string { return ( text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/`/g, '`') .replace(/=/g, '=') // HtmlEscaper (compiler 0.9.14) writes characters 0-13 as references. // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u000d]/g, (char) => `&#${char.charCodeAt(0)};`) ); } // A model with each PLACEHOLDER replaced. function fillModel(expression: unknown, placeholder: (key: string) => Row): Row { if (Array.isArray(expression)) return expression.map((item) => fillModel(item, placeholder)); if (typeof expression !== 'object' || expression === null) return expression as Row; const node = expression as Row; if (node.TYPE === 'PLACEHOLDER') return placeholder(String(node.KEY)); return Object.fromEntries( Object.entries(node).map(([key, value]) => [key, fillModel(value, placeholder)]), ); }