diff --git a/backend/actions/Alert/createAlert.js b/backend/actions/Alert/createAlert.js new file mode 100644 index 00000000..e469c3fc --- /dev/null +++ b/backend/actions/Alert/createAlert.js @@ -0,0 +1,67 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const CreateAlertParams = new Archetype({ + workspaceId: { + $type: 'string' + }, + name: { + $type: 'string' + }, + eventType: { + $type: 'string', + $required: true + }, + database: { + $type: 'string' + }, + collection: { + $type: 'string' + }, + slackChannel: { + $type: 'string', + $required: true + }, + templateText: { + $type: 'string', + $required: true + }, + enabled: { + $type: 'boolean' + }, + roles: { + $type: ['string'] + } +}).compile('CreateAlertParams'); + +module.exports = ({ studioConnection }) => async function createAlert(params) { + const { + workspaceId, + name, + eventType, + database, + collection, + slackChannel, + templateText, + enabled, + roles + } = new CreateAlertParams(params); + + await authorize('Alert.createAlert', roles); + + const Alert = studioConnection.model('__Studio_Alert'); + const alert = await Alert.create({ + workspaceId, + name, + eventType, + database, + collection, + slackChannel, + templateText, + enabled: !!enabled + }); + + return { alert }; +}; diff --git a/backend/actions/Alert/deleteAlert.js b/backend/actions/Alert/deleteAlert.js new file mode 100644 index 00000000..f6dcff7a --- /dev/null +++ b/backend/actions/Alert/deleteAlert.js @@ -0,0 +1,25 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const DeleteAlertParams = new Archetype({ + alertId: { + $type: 'string', + $required: true + }, + roles: { + $type: ['string'] + } +}).compile('DeleteAlertParams'); + +module.exports = ({ studioConnection }) => async function deleteAlert(params) { + const { alertId, roles } = new DeleteAlertParams(params); + + await authorize('Alert.deleteAlert', roles); + + const Alert = studioConnection.model('__Studio_Alert'); + await Alert.findByIdAndDelete(alertId); + + return { success: true }; +}; diff --git a/backend/actions/Alert/index.js b/backend/actions/Alert/index.js new file mode 100644 index 00000000..4ec7c7c8 --- /dev/null +++ b/backend/actions/Alert/index.js @@ -0,0 +1,7 @@ +'use strict'; + +exports.createAlert = require('./createAlert'); +exports.deleteAlert = require('./deleteAlert'); +exports.listAlerts = require('./listAlerts'); +exports.sendTestAlert = require('./sendTestAlert'); +exports.updateAlert = require('./updateAlert'); diff --git a/backend/actions/Alert/listAlerts.js b/backend/actions/Alert/listAlerts.js new file mode 100644 index 00000000..e21693b7 --- /dev/null +++ b/backend/actions/Alert/listAlerts.js @@ -0,0 +1,25 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const ListAlertsParams = new Archetype({ + workspaceId: { + $type: 'string' + }, + roles: { + $type: ['string'] + } +}).compile('ListAlertsParams'); + +module.exports = ({ studioConnection }) => async function listAlerts(params = {}) { + const { workspaceId, roles } = new ListAlertsParams(params); + + await authorize('Alert.listAlerts', roles); + + const Alert = studioConnection.model('__Studio_Alert'); + const query = workspaceId ? { workspaceId } : {}; + const alerts = await Alert.find(query).sort({ createdAt: -1 }).lean(); + + return { alerts }; +}; diff --git a/backend/actions/Alert/sendTestAlert.js b/backend/actions/Alert/sendTestAlert.js new file mode 100644 index 00000000..aa1aa20e --- /dev/null +++ b/backend/actions/Alert/sendTestAlert.js @@ -0,0 +1,47 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); +const { renderTemplate, notifySlack } = require('../../alerts/alertUtils'); + +const SendTestAlertParams = new Archetype({ + workspaceId: { + $type: 'string' + }, + slackChannel: { + $type: 'string', + $required: true + }, + templateText: { + $type: 'string', + $required: true + }, + sampleDocument: { + $type: 'object', + $required: true + }, + roles: { + $type: ['string'] + } +}).compile('SendTestAlertParams'); + +module.exports = ({ options }) => async function sendTestAlert(params) { + const { workspaceId, slackChannel, templateText, sampleDocument, roles } = new SendTestAlertParams(params); + + await authorize('Alert.sendTestAlert', roles); + + const mothershipUrl = options?._mothershipUrl || 'https://mongoose-js.netlify.app/.netlify/functions'; + const text = renderTemplate(templateText, sampleDocument); + await notifySlack({ + mothershipUrl, + payload: { + workspaceId, + channel: slackChannel, + template: templateText, + text, + sampleDocument + } + }); + + return { success: true }; +}; diff --git a/backend/actions/Alert/updateAlert.js b/backend/actions/Alert/updateAlert.js new file mode 100644 index 00000000..4c1c855c --- /dev/null +++ b/backend/actions/Alert/updateAlert.js @@ -0,0 +1,73 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const UpdateAlertParams = new Archetype({ + alertId: { + $type: 'string', + $required: true + }, + workspaceId: { + $type: 'string' + }, + name: { + $type: 'string' + }, + eventType: { + $type: 'string' + }, + database: { + $type: 'string' + }, + collection: { + $type: 'string' + }, + slackChannel: { + $type: 'string' + }, + templateText: { + $type: 'string' + }, + enabled: { + $type: 'boolean' + }, + roles: { + $type: ['string'] + } +}).compile('UpdateAlertParams'); + +module.exports = ({ studioConnection }) => async function updateAlert(params) { + const { + alertId, + workspaceId, + name, + eventType, + database, + collection, + slackChannel, + templateText, + enabled, + roles + } = new UpdateAlertParams(params); + + await authorize('Alert.updateAlert', roles); + + const Alert = studioConnection.model('__Studio_Alert'); + const alert = await Alert.findByIdAndUpdate( + alertId, + { + workspaceId, + name, + eventType, + database, + collection, + slackChannel, + templateText, + enabled + }, + { new: true } + ); + + return { alert }; +}; diff --git a/backend/actions/index.js b/backend/actions/index.js index b5eb0679..04954c76 100644 --- a/backend/actions/index.js +++ b/backend/actions/index.js @@ -3,6 +3,7 @@ exports.ChatMessage = require('./ChatMessage'); exports.ChatThread = require('./ChatThread'); exports.Dashboard = require('./Dashboard'); +exports.Alert = require('./Alert'); exports.Model = require('./Model'); exports.Script = require('./Script'); exports.status = require('./status'); diff --git a/backend/alerts/alertUtils.js b/backend/alerts/alertUtils.js new file mode 100644 index 00000000..1f2c93f1 --- /dev/null +++ b/backend/alerts/alertUtils.js @@ -0,0 +1,36 @@ +'use strict'; + +function getValueByPath(object, path) { + return path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : null), object); +} + +function renderTemplate(template, doc) { + if (!template) { + return ''; + } + return template.replace(/{{\s*([^}]+)\s*}}/g, (_match, path) => { + const value = getValueByPath(doc, path.trim()); + return value === null ? '—' : String(value); + }); +} + +async function notifySlack({ mothershipUrl, payload }) { + const response = await fetch(`${mothershipUrl}/notifySlack`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Slack notify failed (${response.status}): ${text}`); + } + + return response.json().catch(() => ({})); +} + +module.exports = { + getValueByPath, + renderTemplate, + notifySlack +}; diff --git a/backend/alerts/startAlertService.js b/backend/alerts/startAlertService.js new file mode 100644 index 00000000..cdda510a --- /dev/null +++ b/backend/alerts/startAlertService.js @@ -0,0 +1,148 @@ +'use strict'; + +const crypto = require('crypto'); +const { renderTemplate, notifySlack } = require('./alertUtils'); + +module.exports = function startAlertService({ db, studioConnection, options, changeStream }) { + if (!changeStream) { + return null; + } + + const mothershipUrl = options?._mothershipUrl || 'https://mongoose-js.netlify.app/.netlify/functions'; + const Alert = studioConnection.model('__Studio_Alert'); + const leaseCollection = studioConnection.collection('studio__alertLeases'); + const ownerId = crypto.randomUUID(); + const leaseKey = 'change-stream-alerts'; + const leaseDurationMs = 60000; + const leaseRefreshMs = 20000; + const alertRefreshMs = 30000; + let isLeader = false; + let alertsCache = []; + const queue = []; + let processing = false; + + async function refreshLease() { + const now = new Date(); + const expiresAt = new Date(now.getTime() + leaseDurationMs); + const result = await leaseCollection.findOneAndUpdate( + { + key: leaseKey, + $or: [{ expiresAt: { $lt: now } }, { ownerId }] + }, + { + $set: { + key: leaseKey, + ownerId, + expiresAt + } + }, + { upsert: true, returnDocument: 'after' } + ); + + isLeader = result?.value?.ownerId === ownerId; + } + + async function refreshAlerts() { + alertsCache = await Alert.find({ enabled: true }).lean(); + } + + function isAlertMatch(alert, change) { + const namespace = change.ns || {}; + if (alert.database && alert.database !== namespace.db) { + return false; + } + if (alert.collection && alert.collection !== namespace.coll) { + return false; + } + + const operation = change.operationType; + if (alert.eventType === 'upsert') { + return ['insert', 'update', 'replace'].includes(operation); + } + if (alert.eventType === 'update') { + return ['update', 'replace'].includes(operation); + } + return alert.eventType === operation; + } + + async function processQueue() { + if (processing) { + return; + } + processing = true; + try { + while (queue.length > 0) { + const change = queue.shift(); + if (!isLeader) { + continue; + } + const matchingAlerts = alertsCache.filter(alert => isAlertMatch(alert, change)); + if (matchingAlerts.length === 0) { + continue; + } + + const doc = change.fullDocument || { _id: change.documentKey?._id }; + const payloadDoc = { + ...doc, + _id: doc?._id ? String(doc._id) : doc?._id, + studioLink: options?.studioBaseUrl ? `${options.studioBaseUrl}` : '' + }; + + for (const alert of matchingAlerts) { + const text = renderTemplate(alert.templateText, payloadDoc); + await notifySlack({ + mothershipUrl, + payload: { + workspaceId: alert.workspaceId, + channel: alert.slackChannel, + template: alert.templateText, + text, + sampleDocument: payloadDoc, + eventType: change.operationType, + database: change.ns?.db, + collection: change.ns?.coll + } + }); + } + } + } catch (error) { + console.warn('[alerts] error processing change stream', error); + } finally { + processing = false; + } + } + + function handleChange(change) { + queue.push(change); + processQueue(); + } + + async function bootstrap() { + await refreshLease(); + await refreshAlerts(); + setInterval(async () => { + try { + await refreshLease(); + } catch (error) { + isLeader = false; + } + }, leaseRefreshMs); + setInterval(async () => { + try { + await refreshAlerts(); + } catch (error) { + // ignore refresh errors + } + }, alertRefreshMs); + } + + bootstrap().catch(err => console.warn('[alerts] failed to start alert service', err)); + + changeStream.on('change', handleChange); + + return { + stop() { + changeStream.off('change', handleChange); + } + }; +}; diff --git a/backend/authorize.js b/backend/authorize.js index 919524f1..29559130 100644 --- a/backend/authorize.js +++ b/backend/authorize.js @@ -1,6 +1,11 @@ 'use strict'; const actionsToRequiredRoles = { + 'Alert.createAlert': ['owner', 'admin', 'member'], + 'Alert.deleteAlert': ['owner', 'admin', 'member'], + 'Alert.listAlerts': ['owner', 'admin', 'member'], + 'Alert.sendTestAlert': ['owner', 'admin', 'member'], + 'Alert.updateAlert': ['owner', 'admin', 'member'], 'ChatMessage.executeScript': ['owner', 'admin', 'member'], 'ChatThread.createChatMessage': ['owner', 'admin', 'member'], 'ChatThread.createChatThread': ['owner', 'admin', 'member'], diff --git a/backend/db/alertSchema.js b/backend/db/alertSchema.js new file mode 100644 index 00000000..b3817698 --- /dev/null +++ b/backend/db/alertSchema.js @@ -0,0 +1,37 @@ +'use strict'; + +const mongoose = require('mongoose'); + +const alertSchema = new mongoose.Schema({ + workspaceId: { + type: String + }, + name: { + type: String + }, + eventType: { + type: String, + required: true, + enum: ['insert', 'update', 'delete', 'upsert'] + }, + database: { + type: String + }, + collection: { + type: String + }, + slackChannel: { + type: String, + required: true + }, + templateText: { + type: String, + required: true + }, + enabled: { + type: Boolean, + default: false + } +}, { timestamps: true }); + +module.exports = alertSchema; diff --git a/backend/index.js b/backend/index.js index a584bfc2..4113068a 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,6 +7,8 @@ const mongoose = require('mongoose'); const chatMessageSchema = require('./db/chatMessageSchema'); const chatThreadSchema = require('./db/chatThreadSchema'); const dashboardSchema = require('./db/dashboardSchema'); +const alertSchema = require('./db/alertSchema'); +const startAlertService = require('./alerts/startAlertService'); module.exports = function backend(db, studioConnection, options) { db = db || mongoose.connection; @@ -15,13 +17,18 @@ module.exports = function backend(db, studioConnection, options) { const Dashboard = studioConnection.model('__Studio_Dashboard', dashboardSchema, 'studio__dashboards'); const ChatMessage = studioConnection.model('__Studio_ChatMessage', chatMessageSchema, 'studio__chatMessages'); const ChatThread = studioConnection.model('__Studio_ChatThread', chatThreadSchema, 'studio__chatThreads'); + studioConnection.model('__Studio_Alert', alertSchema, 'studio__alerts'); let changeStream = null; if (options?.changeStream) { - changeStream = db.watch(); + changeStream = db.watch([], { fullDocument: 'updateLookup' }); } const actions = applySpec(Actions, { db, studioConnection, options, changeStream }); actions.services = { changeStream }; + + if (changeStream) { + actions.services.alertService = startAlertService({ db, studioConnection, options, changeStream }); + } return actions; }; diff --git a/frontend/src/api.js b/frontend/src/api.js index 46280695..5d5a72be 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -46,6 +46,23 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('', { action: 'Dashboard.updateDashboard', ...params }).then(res => res.data); } }; + exports.Alert = { + createAlert(params) { + return client.post('', { action: 'Alert.createAlert', ...params }).then(res => res.data); + }, + deleteAlert(params) { + return client.post('', { action: 'Alert.deleteAlert', ...params }).then(res => res.data); + }, + listAlerts(params) { + return client.post('', { action: 'Alert.listAlerts', ...params }).then(res => res.data); + }, + sendTestAlert(params) { + return client.post('', { action: 'Alert.sendTestAlert', ...params }).then(res => res.data); + }, + updateAlert(params) { + return client.post('', { action: 'Alert.updateAlert', ...params }).then(res => res.data); + } + }; exports.ChatThread = { createChatMessage(params) { return client.post('', { action: 'ChatThread.createChatMessage', ...params }).then(res => res.data); @@ -190,6 +207,23 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { return client.post('/Dashboard/updateDashboard', params).then(res => res.data); } }; + exports.Alert = { + createAlert(params) { + return client.post('/Alert/createAlert', params).then(res => res.data); + }, + deleteAlert(params) { + return client.post('/Alert/deleteAlert', params).then(res => res.data); + }, + listAlerts(params) { + return client.post('/Alert/listAlerts', params).then(res => res.data); + }, + sendTestAlert(params) { + return client.post('/Alert/sendTestAlert', params).then(res => res.data); + }, + updateAlert(params) { + return client.post('/Alert/updateAlert', params).then(res => res.data); + } + }; exports.ChatThread = { createChatMessage: function createChatMessage(params) { return client.post('/ChatThread/createChatMessage', params).then(res => res.data); diff --git a/frontend/src/team/team.html b/frontend/src/team/team.html index c4e01d3e..0f905cce 100644 --- a/frontend/src/team/team.html +++ b/frontend/src/team/team.html @@ -42,6 +42,155 @@ +
{{formattedSampleDocument}}
+