// genesis-notify's event handlers and computed request servers, as the // product's scripts at the version the showcase pins run them // (genesis-notify v8.15.16, scripts/platform-preset/seeds/genesis-notify@8.15.16/ // scripts: genesis-notify-eventhandler.kts below as EH). Each DTO's fields come // from the product's messages jar (global.genesis.notify.message.event.*). // What happens outside the database (email, Teams, Slack, a log line) isn't // modelled: an event validates, writes its rows and acks. import type { Store } from '../db/store.ts'; import { NackError } from '../protocol/errors.ts'; import type { EventHandler, EventHandlerCtx, EventSchemaOverride, FieldDef, Row, } from '../types.ts'; const ENTITY_ID_TYPES = ['USER_NAME', 'PROFILE_NAME', 'ALL', 'SELF']; // The package the product's enums are generated in: their DTOs take // global.genesis.gen.dao.enums.EntityIdType, not a table's own. export const ENUMS = 'global.genesis.gen.dao.enums'; export const TTL_TIME_UNITS = ['NONE', 'SECONDS', 'MINUTES', 'HOURS', 'DAYS']; // A DTO property. `optional`: it has a Kotlin default (which metadata doesn't // show), so DETAILS may leave it out. export const dto = ( name: string, type: FieldDef['type'], more: Partial = {}, ): FieldDef => ({ name, type, nullable: false, ...more, }); export const nullable = { nullable: true, optional: true }; // GSF checks rights by user name in its rights cache (`userHasRight`), a live // copy of RIGHT_SUMMARY, for the event's user and for anyone else alike. export interface NotifyUsers { // Who an event runs as: its connection's login, else the session user. userOf(ctx: EventHandlerCtx): string; // Who a request runs as when its connection never logged in. fallback: string | undefined; hasRight(store: Store, userName: string | undefined, code: string): boolean; } // RouteCreate (NotifyRouteCreate.kt): every route DTO's own four. const ROUTE_FIELDS: FieldDef[] = [ dto('GATEWAY_ID', 'STRING'), dto('TOPIC_MATCH', 'STRING'), dto('ROUTE_ENABLED', 'BOOLEAN', { optional: true }), dto('DISPLAY_NAME', 'STRING', nullable), ]; const entityIdType = () => dto('ENTITY_ID_TYPE', 'ENUM', { values: ENTITY_ID_TYPES, enumClass: `${ENUMS}.EntityIdType` }); // A route family: its events' name part, its ext table, the view its grid // and its delete DTO (`.ById`) are named after, its DTOs' own fields, // and its ext row as a create or an update writes it from DETAILS. interface RouteFamily { events: string; ext: string; view: string; createClass: string; updateClass: string; createFields: FieldDef[]; updateFields?: FieldDef[]; extRow(details: Row): Row; } const or = (value: T | undefined | null, fallback: T) => value ?? fallback; // A Set joined as EH stores it: `set?.joinToString(",") ?: ""`. A // string is a one-element set (Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY). const joined = (value: unknown) => Array.isArray(value) ? value.join(',') : typeof value === 'string' ? value : ''; export const stringSet = { type: 'ARRAY' as const, items: { type: 'STRING' as const }, unique: true, }; export const EVENT_CLASS = 'global.genesis.notify.message.event'; const ROUTE_FAMILIES: RouteFamily[] = [ { // EH :45, :74, :106 events: 'SCREEN_NOTIFY_ROUTE', ext: 'SCREEN_NOTIFY_ROUTE_EXT', view: 'ScreenRoute', createClass: `${EVENT_CLASS}.ScreenRouteCreate`, updateClass: `${EVENT_CLASS}.ScreenRouteUpdate`, createFields: [ dto('ENTITY_ID', 'STRING', nullable), entityIdType(), dto('TTL', 'LONG', nullable), dto('TTL_TIME_UNIT', 'ENUM', { values: TTL_TIME_UNITS, enumClass: `${ENUMS}.TtlTimeUnit`, optional: true, }), dto('RIGHT_CODE', 'STRING', nullable), dto('AUTH_CACHE_NAME', 'STRING', nullable), dto('EXCLUDE_SENDER', 'BOOLEAN', { optional: true }), dto('SHOW_TOAST', 'BOOLEAN', { optional: true }), ], extRow: (d) => ({ ENTITY_ID: d.ENTITY_ID ?? null, ENTITY_ID_TYPE: d.ENTITY_ID_TYPE, TTL: d.TTL ?? null, TTL_TIME_UNIT: or(d.TTL_TIME_UNIT, 'NONE'), AUTH_CACHE_NAME: d.AUTH_CACHE_NAME ?? null, RIGHT_CODE: d.RIGHT_CODE ?? null, EXCLUDE_SENDER: or(d.EXCLUDE_SENDER, false), SHOW_TOAST: or(d.SHOW_TOAST, true), }), }, { // EH :242, :266, :293 events: 'EMAIL_DISTRIBUTION_ROUTE', ext: 'EMAIL_DIST_NOTIFY_ROUTE_EXT', view: 'EmailDistributionRoute', createClass: `${EVENT_CLASS}.EmailDistributionRouteCreate`, updateClass: `${EVENT_CLASS}.EmailDistributionRouteUpdate`, // val emailTo: Set?, with no default: nullable, but required. createFields: ['EMAIL_TO', 'EMAIL_CC', 'EMAIL_BCC'].map((name) => ({ name, ...stringSet, nullable: true, })), extRow: (d) => ({ EMAIL_TO: joined(d.EMAIL_TO), EMAIL_CC: joined(d.EMAIL_CC), EMAIL_BCC: joined(d.EMAIL_BCC), }), }, { // EH :307, :331, :358. The create DTO takes RIGHT_CODE and // AUTH_CACHE_NAME but never writes them; the update DTO has neither, and // its whole-row modify nulls them. events: 'EMAIL_USER_ROUTE', ext: 'EMAIL_USER_NOTIFY_ROUTE_EXT', view: 'EmailUserRoute', createClass: `${EVENT_CLASS}.EmailUserRouteCreate`, updateClass: `${EVENT_CLASS}.EmailUserRouteUpdate`, createFields: [ dto('ENTITY_ID', 'STRING', nullable), entityIdType(), dto('RIGHT_CODE', 'STRING', nullable), dto('AUTH_CACHE_NAME', 'STRING', nullable), dto('EXCLUDE_SENDER', 'BOOLEAN', { optional: true }), ], updateFields: [ dto('ENTITY_ID', 'STRING', nullable), entityIdType(), dto('EXCLUDE_SENDER', 'BOOLEAN', { optional: true }), ], extRow: (d) => ({ ENTITY_ID: d.ENTITY_ID ?? null, ENTITY_ID_TYPE: d.ENTITY_ID_TYPE, RIGHT_CODE: null, AUTH_CACHE_NAME: null, EXCLUDE_SENDER: or(d.EXCLUDE_SENDER, false), }), }, { // EH :120, :142, :167 events: 'MS_TEAMS_ROUTE', ext: 'MS_TEAMS_NOTIFY_ROUTE_EXT', view: 'MsTeamsRoute', createClass: `${EVENT_CLASS}.MsTeamsRouteCreate`, updateClass: `${EVENT_CLASS}.MsTeamsRouteUpdate`, createFields: [dto('URL', 'STRING')], extRow: (d) => ({ URL: d.URL }), }, { // EH :181, :203, :228 events: 'SLACK_ROUTE', ext: 'SLACK_NOTIFY_ROUTE_EXT', view: 'SlackRoute', createClass: `${EVENT_CLASS}.SlackRouteCreate`, updateClass: `${EVENT_CLASS}.SlackRouteUpdate`, createFields: [dto('URL', 'STRING')], extRow: (d) => ({ URL: d.URL }), }, { // EH :372, :393, :417 events: 'LOG_ROUTE', ext: 'LOG_NOTIFY_ROUTE_EXT', view: 'LogRoute', createClass: `${EVENT_CLASS}.LogRouteCreate`, updateClass: `${EVENT_CLASS}.LogRouteUpdate`, createFields: [], extRow: () => ({}), }, ]; // notifyRouteOf (EH :20-30): DISPLAY_NAME trimmed, null when blank. const displayName = (value: unknown) => { const trimmed = typeof value === 'string' ? value.trim() : ''; return trimmed === '' ? null : trimmed; }; const routeColumns = (d: Row): Row => ({ GATEWAY_ID: d.GATEWAY_ID, TOPIC_MATCH: d.TOPIC_MATCH, ROUTE_ENABLED: or(d.ROUTE_ENABLED, true), }); // Writes a row and pushes it live, as entityDb does through the update queue. function write( ctx: EventHandlerCtx, table: string, operation: 'INSERT' | 'MODIFY' | 'DELETE', key: string, row?: Row, ): Row | undefined { const { store } = ctx; let written: Row | undefined; if (operation === 'INSERT') written = store.insertRow(table, row!); else if (operation === 'MODIFY') written = store.updateRow(table, key, row!); else store.deleteRow(table, key); ctx.broadcastTableChange(table, operation, written ? store.rowKey(table, written) : key); return written; } function routeHandlers(family: RouteFamily): Record { const { ext } = family; return { // No onValidate: VALIDATE acks. Insert NOTIFY_ROUTE (its NR sequence // mints the key), then the ext row on that key; ack with GENERATED []. [`EVENT_${family.events}_CREATE`]: (details, ctx) => { if (ctx.validate) return {}; const route = write(ctx, 'NOTIFY_ROUTE', 'INSERT', '', { ...routeColumns(details), DISPLAY_NAME: displayName(details.DISPLAY_NAME), })!; write(ctx, ext, 'INSERT', '', { NOTIFY_ROUTE_ID: route.NOTIFY_ROUTE_ID, ...family.extRow(details), }); return {}; }, // applyRouteUpdate (EH :32-42): DISPLAY_NAME changes only when sent; the // ext row is replaced whole, and missing is RECORD_NOT_FOUND by its key. [`EVENT_${family.events}_UPDATE`]: (details, ctx) => { if (ctx.validate) return {}; const id = details.NOTIFY_ROUTE_ID; const route = ctx.store.getRow('NOTIFY_ROUTE', id); if (!route) throw new NackError(`NOTIFY_ROUTE ${id} not found`); write(ctx, 'NOTIFY_ROUTE', 'MODIFY', id, { ...routeColumns(details), ...(details.DISPLAY_NAME === undefined || details.DISPLAY_NAME === null ? {} : { DISPLAY_NAME: displayName(details.DISPLAY_NAME) }), }); if (!ctx.store.getRow(ext, id)) { throw NackError.recordNotFound(ext, `${ext}_BY_NOTIFY_ROUTE_ID`); } write(ctx, ext, 'MODIFY', id, family.extRow(details)); return {}; }, // The route, then its ext row; either missing is RECORD_NOT_FOUND. [`EVENT_${family.events}_DELETE`]: (details, ctx) => { if (ctx.validate) return {}; const id = details.NOTIFY_ROUTE_ID; for (const table of ['NOTIFY_ROUTE', ext]) { if (!ctx.store.getRow(table, id)) throw NackError.recordNotFound(table); write(ctx, table, 'DELETE', id); } return {}; }, }; } function routeSchemas(family: RouteFamily): Record { const id = dto('NOTIFY_ROUTE_ID', 'STRING'); return { // Its ENUMs are the ext table's columns' (enum class names). [`EVENT_${family.events}_CREATE`]: { table: family.ext, className: family.createClass, fields: [...ROUTE_FIELDS, ...family.createFields], }, [`EVENT_${family.events}_UPDATE`]: { table: family.ext, className: family.updateClass, fields: [id, ...ROUTE_FIELDS, ...(family.updateFields ?? family.createFields)], }, [`EVENT_${family.events}_DELETE`]: { table: 'NOTIFY_ROUTE', pkOnly: true, className: `global.genesis.gen.view.entity.${family.view}.ById`, }, }; } export interface NotifyBacking { eventHandlers: Record; eventSchemas: Record; } // genesis-notify's backing, for platformPreset to add when the app includes // the product. export function notifyBacking(): NotifyBacking { return { eventHandlers: Object.assign({}, ...ROUTE_FAMILIES.map(routeHandlers)), eventSchemas: Object.assign({}, ...ROUTE_FAMILIES.map(routeSchemas)), }; }