diff --git a/src/mothership-app/pb_hooks/src/lib.js b/src/mothership-app/pb_hooks/src/lib.js new file mode 100644 index 00000000..a67f3309 --- /dev/null +++ b/src/mothership-app/pb_hooks/src/lib.js @@ -0,0 +1,181 @@ +/// + +/** @type {Lib['audit']} */ +const audit = (event, note, extra) => { + $app.dao().saveRecord( + new Record($app.dao().findCollectionByNameOrId('audit'), { + event, + note, + ...extra, + }), + ) +} + +/** @type {Lib['mkLog']} */ +const mkLog = + (namespace) => + /** + * @param {...any} s + * @returns + */ + (...s) => + console.log( + `*** [${namespace}]`, + ...s.map((p) => { + if (typeof p === 'object') return JSON.stringify(p, null, 2) + return p + }), + ) + +/** + * @param {...any} args + * @returns + */ +const dbg = (...args) => console.log(args) + +/** + * @param {string} template + * @param {{ [_: string]: string }} dict + * @returns + */ +function interpolateString(template, dict) { + return template.replace(/\{\$(\w+)\}/g, (match, key) => { + dbg({ match, key }) + const lowerKey = key.toLowerCase() + return dict.hasOwnProperty(lowerKey) ? dict[lowerKey] || '' : match + }) +} + +/** @type {Lib['enqueueNotification']} */ +const enqueueNotification = ( + channel, + template, + user_id, + message_template_vars = {}, + dao = $app.dao(), +) => { + const emailTemplate = $app + .dao() + ?.findFirstRecordByData('message_templates', `slug`, template) + if (!emailTemplate) throw new Error(`Template ${template} not found`) + const emailNotification = new Record( + $app.dao().findCollectionByNameOrId('notifications'), + { + user: user_id, + channel, + message_template: emailTemplate.getId(), + message_template_vars, + }, + ) + dao.saveRecord(emailNotification) +} + +/** @type {Lib['processNotification']} */ +const processNotification = (notificationRec, { log, test = false }) => { + log({ notification: notificationRec }) + + try { + const channel = notificationRec.getString(`channel`) + $app.dao().expandRecord(notificationRec, ['message_template', 'user'], null) + + const messageTemplateRec = notificationRec.expandedOne('message_template') + if (!messageTemplateRec) { + throw new Error(`Missing message template`) + } + const userRec = notificationRec.expandedOne('user') + if (!userRec) { + throw new Error(`Missing user record`) + } + const vars = JSON.parse(notificationRec.getString(`message_template_vars`)) + const to = userRec.email() + const subject = interpolateString( + messageTemplateRec.getString(`subject`), + vars, + ) + const html = interpolateString( + messageTemplateRec.getString(`body_html`), + vars, + ) + + log({ channel, messageTemplateRec, userRec, vars, to, subject, html }) + switch (channel) { + case `email`: + /** @type {Partial} */ + const msgArgs = { + from: { + address: $app.settings().meta.senderAddress, + name: $app.settings().meta.senderName, + }, + to: [{ address: to }], + subject, + html, + } + if (test) { + msgArgs.to = [{ address: `ben@benallfree.com` }] + msgArgs.bcc = [{ address: `pockethost+notifications@benallfree.com` }] + } + log({ msgArgs }) + // @ts-ignore + const msg = new MailerMessage(msgArgs) + $app.newMailClient().send(msg) + log(`email sent`) + notificationRec.set(`delivered`, true) + break + + case `lemonbot`: + const res = $http.send({ + url: test + ? `https://discord.com/api/webhooks/1194056858516869213/pwT-ymSxiPmLN5-M2a7FsesvrbtdmwUsKXWIKqebROJEfyP0E3-aSseUjDg4ojBkWV4D` // Test channel + : `https://discord.com/api/webhooks/1193619183594901575/JVDfdUz2HPEUk-nG1RfI3BK2Czyx5vw1YmeH7cNfgvXbHNGPH0oJncOYqxMA_u5b2u57`, // Stream channel + method: 'POST', + body: JSON.stringify({ + content: subject, + }), + headers: { 'content-type': 'application/json' }, + timeout: 5, // in seconds + }) + log(`discord sent`) + break + default: + throw new Error(`Unsupported channel: ${channel}`) + } + if (!test) { + notificationRec.set(`delivered`, new Date().toISOString()) + } + } catch (e) { + log(`${e}`) + notificationRec.set(`error`, `${e}`) + audit(`NOTIFICATION_ERR`, `${e}`, { + notification: notificationRec.id, + }) + throw e + } finally { + if (!test) { + $app.dao().saveRecord(notificationRec) + } + } + log(notificationRec) +} + +/** + * @template T + * @param {T} obj + * @returns T + */ +function removeEmptyKeys(obj) { + const sanitized = Object.entries(obj).reduce((acc, [key, value]) => { + if (value !== null && value !== undefined) { + acc[key] = value + } + return acc + }, /** @type {T} */ ({})) + return sanitized +} + +module.exports = { + audit, + processNotification, + mkLog, + enqueueNotification, + removeEmptyKeys, +} diff --git a/src/mothership-app/pb_hooks/types/lib.d.ts b/src/mothership-app/pb_hooks/types/lib.d.ts new file mode 100644 index 00000000..48e6365f --- /dev/null +++ b/src/mothership-app/pb_hooks/types/lib.d.ts @@ -0,0 +1,41 @@ +type Logger = (...args: any[]) => void + +type StringKvLookup = { [_: string]: string } + +type AuditEvents = + | 'NOTIFICATION_ERR' + | 'LS' + | 'LS_ERR' + | 'PBOUNCE_ERR' + | 'PBOUNCE' + | 'SNS' + | 'COMPLAINT' + | 'UNSUBSCRIBE' + | 'UNSUBSCRIBE_ERR' + +interface Lib { + mkLog: (namespace: string) => Logger + processNotification: ( + notificationRec: models.Record, + context: { log: Logger; test: boolean }, + ) => void + enqueueNotification: ( + channel: 'email' | 'lemonbot', + template: string, + user_id: string, + message_template_vars: { [_: string]: string }, + dao: daos.Dao, + ) => void + + audit: ( + event: AuditEvents, + note: string, + extra?: Partial<{ + notification: string + email: string + user: string + raw_payload: string + }>, + ) => void + removeEmptyKeys: (obj: T) => T +}