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 @@ +
+
+ Change-stream Alerts +
+
+ Configure real-time Slack alerts powered by database change streams. +
+
+
+
+
+ {{index + 1}} +
+ {{step.label}} + +
+
+ +
+
+
Choose event
+
+ +
+
+ Choose the change stream operation that should trigger a Slack alert. +
+
+ +
+
Scope
+
+
+ + +
+
+ + +
+
+
+ Scope to a database and collection to keep alerts targeted. +
+
+ +
+
Actions: Slack
+
+
+ + +
+ Alerts will be delivered through the workspace Slack integration. +
+
+
+ + +
+ Start from a template and customize the message. +
+
+
+ +
+
+ + +
+ Variable picker + +
+
+
+
Preview
+
{{alertTemplatePreview}}
+
Sample document
+
{{formattedSampleDocument}}
+
+
+
+ +
+
Test and enable
+
+ Send a test Slack alert before enabling the rule. The payload will be sent to + /.netlify/functions/notifySlack. +
+
+ + {{isSendingTest ? 'Sending…' : 'Send test alert'}} + + +
+
+ {{testAlertStatus.message}} +
+
+
+ +
+ + +
+
+ {{alertSaveStatus.message}} +
+
+
Current Members diff --git a/frontend/src/team/team.js b/frontend/src/team/team.js index 11a4c6f5..c17e4592 100644 --- a/frontend/src/team/team.js +++ b/frontend/src/team/team.js @@ -1,5 +1,6 @@ 'use strict'; +const api = require('../api'); const mothership = require('../mothership'); const template = require('./team.html'); @@ -12,7 +13,75 @@ module.exports = app => app.component('team', { showNewInvitationModal: false, showRemoveModal: null, showEditModal: null, - status: 'loading' + status: 'loading', + alertWizardStep: 0, + alertSteps: [ + { key: 'event', label: 'Choose event' }, + { key: 'scope', label: 'Scope' }, + { key: 'action', label: 'Actions' }, + { key: 'test', label: 'Test & enable' } + ], + alertEventOptions: [ + { value: 'insert', label: 'Insert', description: 'Alert on new documents.' }, + { value: 'update', label: 'Update', description: 'Alert when documents are updated.' }, + { value: 'delete', label: 'Delete', description: 'Alert when documents are removed.' }, + { value: 'upsert', label: 'Insert or Update', description: 'Alert when documents are inserted or updated.' } + ], + alertTemplates: [ + { + id: 'high-value-order', + name: '🚨 High-value order', + body: [ + '🚨 New high-value order', + '', + 'Order ID: {{_id}}', + 'User: {{user.email}}', + 'Total: ${{total}}', + 'Created at: {{createdAt}}', + '', + 'View in Studio → {{studioLink}}' + ].join('\n') + }, + { + id: 'inventory', + name: '📦 Inventory change', + body: [ + '📦 Inventory updated', + '', + 'SKU: {{sku}}', + 'Name: {{name}}', + 'Quantity: {{quantity}}', + 'Updated: {{updatedAt}}', + '', + 'View in Studio → {{studioLink}}' + ].join('\n') + } + ], + alertConfig: { + _id: null, + eventType: 'insert', + database: '', + collection: '', + slackChannel: '', + templateId: 'high-value-order', + templateText: '', + enabled: false + }, + alertSampleDocument: { + _id: 'ord_84b2d1', + user: { email: 'ava@acme.co' }, + total: 1284.5, + createdAt: '2024-03-12T09:41:22Z', + sku: 'ACME-STEEL-42', + name: 'Steel Widget', + quantity: 34, + updatedAt: '2024-03-13T11:05:11Z', + studioLink: 'https://studio.mongoosejs.io/#/model/orders/document/ord_84b2d1' + }, + isSendingTest: false, + testAlertStatus: null, + isSavingAlert: false, + alertSaveStatus: null }), async mounted() { window.pageState = this; @@ -22,11 +91,32 @@ module.exports = app => app.component('team', { this.users = users; this.invitations = invitations; this.status = 'loaded'; + await this.loadAlerts(); + this.applyTemplatePreset(); }, computed: { paymentLink() { return 'https://buy.stripe.com/3csaFg8XTdd0d6U7sy?client_reference_id=' + this.workspace?._id; // return 'https://buy.stripe.com/test_eVaeYa2jC7565Lq7ss?client_reference_id=' + this.workspace?._id; + }, + alertVariables() { + return [ + '_id', + 'user.email', + 'total', + 'createdAt', + 'sku', + 'name', + 'quantity', + 'updatedAt', + 'studioLink' + ]; + }, + alertTemplatePreview() { + return this.renderTemplate(this.alertConfig.templateText, this.alertSampleDocument); + }, + formattedSampleDocument() { + return JSON.stringify(this.alertSampleDocument, null, 2); } }, methods: { @@ -87,6 +177,110 @@ module.exports = app => app.component('team', { } return option !== 'dashboards'; + }, + async loadAlerts() { + const { alerts } = await api.Alert.listAlerts({ workspaceId: this.workspace?._id }); + if (alerts?.length) { + const alert = alerts[0]; + this.alertConfig = { + _id: alert._id, + eventType: alert.eventType ?? 'insert', + database: alert.database ?? '', + collection: alert.collection ?? '', + slackChannel: alert.slackChannel ?? '', + templateId: this.alertConfig.templateId, + templateText: alert.templateText ?? '', + enabled: !!alert.enabled + }; + } + }, + applyTemplatePreset() { + const selected = this.alertTemplates.find(template => template.id === this.alertConfig.templateId); + if (selected && !this.alertConfig.templateText) { + this.alertConfig.templateText = selected.body; + } + }, + async advanceAlertStep() { + this.alertSaveStatus = null; + if (this.alertWizardStep < this.alertSteps.length - 1) { + this.alertWizardStep += 1; + } else { + await this.saveAlert(); + } + }, + async saveAlert() { + this.isSavingAlert = true; + this.alertSaveStatus = null; + try { + const payload = { + alertId: this.alertConfig._id, + workspaceId: this.workspace?._id, + eventType: this.alertConfig.eventType, + database: this.alertConfig.database, + collection: this.alertConfig.collection, + slackChannel: this.alertConfig.slackChannel, + templateText: this.alertConfig.templateText, + enabled: this.alertConfig.enabled + }; + + let response; + if (this.alertConfig._id) { + response = await api.Alert.updateAlert(payload); + } else { + response = await api.Alert.createAlert(payload); + this.alertConfig._id = response.alert?._id || this.alertConfig._id; + } + this.alertSaveStatus = { type: 'success', message: 'Alert saved.' }; + this.alertWizardStep = 0; + } catch (error) { + this.alertSaveStatus = { type: 'error', message: error.message || 'Unable to save alert.' }; + } finally { + this.isSavingAlert = false; + } + }, + insertVariable(variable) { + const token = `{{${variable}}}`; + const input = this.$refs.alertTemplateInput; + if (!input || typeof input.selectionStart !== 'number') { + this.alertConfig.templateText = `${this.alertConfig.templateText} ${token}`.trim(); + return; + } + + const start = input.selectionStart; + const end = input.selectionEnd; + const current = this.alertConfig.templateText; + this.alertConfig.templateText = `${current.slice(0, start)}${token}${current.slice(end)}`; + this.$nextTick(() => { + input.focus(); + const cursor = start + token.length; + input.setSelectionRange(cursor, cursor); + }); + }, + getValueByPath(object, path) { + return path.split('.').reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : null), object); + }, + renderTemplate(template, sample) { + return template.replace(/{{\s*([^}]+)\s*}}/g, (_match, path) => { + const value = this.getValueByPath(sample, path.trim()); + return value === null ? '—' : String(value); + }); + }, + async sendTestAlert() { + this.isSendingTest = true; + this.testAlertStatus = null; + try { + await api.Alert.sendTestAlert({ + workspaceId: this.workspace?._id, + slackChannel: this.alertConfig.slackChannel, + templateText: this.alertConfig.templateText, + sampleDocument: this.alertSampleDocument + }); + this.testAlertStatus = { type: 'success', message: 'Test alert sent successfully.' }; + } catch (error) { + this.testAlertStatus = { type: 'error', message: error.message || 'Unable to send test alert.' }; + } finally { + this.isSendingTest = false; + } } } });