Discord webhooks
This commit is contained in:
parent
e24df1139b
commit
b4bae1bfc8
|
|
@ -1,73 +0,0 @@
|
|||
// Temporary test harness - deleted after verification. Points at a scratch copy of the DB so the
|
||||
// real database.db is never touched.
|
||||
require('dotenv').config({ quiet: true });
|
||||
const express = require('express');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const app = express();
|
||||
const session = require('express-session');
|
||||
const port = 3999;
|
||||
|
||||
const db = new sqlite3.Database('/tmp/database_full_test.db', (err) => {
|
||||
if (err) {
|
||||
console.error('Failed to connect to the database:', err.message);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('Connected to the SQLite database.');
|
||||
require('./migrations')(db).then(() => {
|
||||
app.listen(port, () => {
|
||||
console.log(`NOTXCS API is running on port ${port}`);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Failed to run migrations:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
app.use(express.static('public'));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(session({
|
||||
secret: 'test-secret',
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
cookie: { secure: false }
|
||||
}));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${req.method} ${req.originalUrl}`);
|
||||
next();
|
||||
});
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const routesPath = path.join(__dirname, 'routes');
|
||||
|
||||
function loadRoutes(dir, basePath = '/') {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
files.forEach((file) => {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
loadRoutes(fullPath, path.join(basePath, file));
|
||||
} else if (file.endsWith('.js')) {
|
||||
const routeName = path.basename(file, '.js');
|
||||
const routeUrl = path.join(basePath, routeName === 'index' ? '' : routeName).replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
const route = require(fullPath)(db);
|
||||
if (typeof route === 'function' || route.name === 'router') {
|
||||
app.use(routeUrl, route);
|
||||
console.log(`Loaded route: ${routeUrl}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error loading route ${fullPath}:`, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadRoutes(routesPath);
|
||||
145
lib/webhooks.js
Normal file
145
lib/webhooks.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// Sends Discord webhook notifications for place-level events (access granted/denied, reader
|
||||
// arm/disarm, reader enable/disable). Uses the runtime's built-in fetch() - no extra dependency.
|
||||
//
|
||||
// Each place has its own webhook config (see migrations/0009_discord_webhooks.sql):
|
||||
// webhookUrl - the Discord webhook URL to POST to. If falsy, notifications are skipped.
|
||||
// webhookContent - optional plain-text `content` sent alongside the embed (e.g. "@here" or
|
||||
// a role/user mention). Purely cosmetic - never affects delivery logic.
|
||||
// webhookAccessGranted - toggle for access_granted events
|
||||
// webhookAccessDenied - toggle for access_denied events
|
||||
// webhookReaderArm - toggle for both reader_armed and reader_disarmed events
|
||||
// webhookReaderEnable - toggle for both reader_enabled and reader_disabled events
|
||||
//
|
||||
// Sending is entirely fire-and-forget: failures are logged but never thrown, so a broken/rate-limited
|
||||
// webhook can never affect scan results or the dashboard's own API responses.
|
||||
|
||||
// Discord embed side colors (decimal), matching the dashboard's --success/--danger/--accent palette.
|
||||
const COLORS = {
|
||||
GREEN: 0x3ecf8e,
|
||||
RED: 0xe5484d,
|
||||
BLUE: 0x4f7cff,
|
||||
ORANGE: 0xf5a623
|
||||
};
|
||||
|
||||
// Maps each event type to the place column that gates it, plus how to render its embed.
|
||||
const EVENT_CONFIG = {
|
||||
access_granted: {
|
||||
toggleColumn: 'webhookAccessGranted',
|
||||
title: '✅ Access granted',
|
||||
color: COLORS.GREEN
|
||||
},
|
||||
access_denied: {
|
||||
toggleColumn: 'webhookAccessDenied',
|
||||
title: '⛔ Access denied',
|
||||
color: COLORS.RED
|
||||
},
|
||||
reader_armed: {
|
||||
toggleColumn: 'webhookReaderArm',
|
||||
title: '🔒 Reader armed',
|
||||
color: COLORS.BLUE
|
||||
},
|
||||
reader_disarmed: {
|
||||
toggleColumn: 'webhookReaderArm',
|
||||
title: '🔓 Reader disarmed',
|
||||
color: COLORS.ORANGE
|
||||
},
|
||||
reader_enabled: {
|
||||
toggleColumn: 'webhookReaderEnable',
|
||||
title: '🟢 Reader enabled',
|
||||
color: COLORS.GREEN
|
||||
},
|
||||
reader_disabled: {
|
||||
toggleColumn: 'webhookReaderEnable',
|
||||
title: '🔴 Reader disabled',
|
||||
color: COLORS.RED
|
||||
}
|
||||
};
|
||||
|
||||
// Builds the Discord embed `fields` array from a plain object, skipping any nullish values.
|
||||
// Values are stringified and truncated to Discord's 1024-char field value limit.
|
||||
function buildFields(data) {
|
||||
return Object.entries(data)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
value: String(value).slice(0, 1024),
|
||||
inline: true
|
||||
}));
|
||||
}
|
||||
|
||||
// POSTs a raw payload to a Discord webhook URL using the runtime's built-in fetch(). Returns
|
||||
// `{ ok: true }` on a 2xx response, or `{ ok: false, message }` otherwise. Never throws.
|
||||
async function postToDiscord(webhookUrl, payload) {
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { ok: false, message: `Discord responded with ${res.status}${body ? `: ${body}` : ''}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Fires a Discord webhook for `eventType` on behalf of `place`, if it has a webhook URL configured
|
||||
// and has that event type's toggle enabled. `fields` is a plain object of embed field name -> value
|
||||
// (e.g. { Reader: 'Front Door', User: '123456' }). Never throws - logs and resolves on any failure.
|
||||
async function sendPlaceWebhook(place, eventType, fields = {}) {
|
||||
const config = EVENT_CONFIG[eventType];
|
||||
if (!config) {
|
||||
console.error(`Unknown webhook event type: ${eventType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!place || !place.webhookUrl || !place[config.toggleColumn]) {
|
||||
return; // No webhook configured, or this event type isn't enabled for this place.
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: config.title,
|
||||
color: config.color,
|
||||
fields: buildFields(fields),
|
||||
footer: { text: place.name || place.id },
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const payload = { embeds: [embed] };
|
||||
if (place.webhookContent) {
|
||||
payload.content = String(place.webhookContent);
|
||||
}
|
||||
|
||||
const result = await postToDiscord(place.webhookUrl, payload);
|
||||
if (!result.ok) {
|
||||
console.error(`Discord webhook for place ${place.id} failed: ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sends a one-off test embed straight to `webhookUrl` (bypassing the place's toggles, since the
|
||||
// user is explicitly asking to test it), optionally including `content`. Used by the dashboard's
|
||||
// "Send test" button. Returns `{ ok, message }` so the caller can surface any failure to the user.
|
||||
async function sendTestWebhook(webhookUrl, content) {
|
||||
if (!webhookUrl) {
|
||||
return { ok: false, message: 'No webhook URL configured' };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
embeds: [{
|
||||
title: '🔔 Test notification',
|
||||
description: 'This is a test alert from NOTXCS. If you can see this, your webhook is configured correctly.',
|
||||
color: COLORS.BLUE,
|
||||
timestamp: new Date().toISOString()
|
||||
}]
|
||||
};
|
||||
if (content) {
|
||||
payload.content = String(content);
|
||||
}
|
||||
|
||||
return postToDiscord(webhookUrl, payload);
|
||||
}
|
||||
|
||||
module.exports = { sendPlaceWebhook, sendTestWebhook, EVENT_CONFIG };
|
||||
9
migrations/0009_discord_webhooks.sql
Normal file
9
migrations/0009_discord_webhooks.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- Per-place Discord webhook configuration: a webhook URL, optional custom message content (e.g.
|
||||
-- to ping a role/user), and one on/off toggle per notification type so a place can opt into just
|
||||
-- the events it cares about. All toggles default to off until explicitly enabled by the user.
|
||||
ALTER TABLE places ADD COLUMN webhookUrl TEXT;
|
||||
ALTER TABLE places ADD COLUMN webhookContent TEXT;
|
||||
ALTER TABLE places ADD COLUMN webhookAccessGranted INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE places ADD COLUMN webhookAccessDenied INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE places ADD COLUMN webhookReaderArm INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE places ADD COLUMN webhookReaderEnable INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -416,6 +416,24 @@ button.danger {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-row label {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,41 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Discord webhook</h3>
|
||||
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">Post an alert to a Discord channel when readers at this place grant/deny access or get armed/disarmed/enabled/disabled. The alert itself is sent as an embed; the optional message below is sent as plain content alongside it (e.g. to ping a role or user).</p>
|
||||
<form id="webhook-form">
|
||||
<div class="field-group">
|
||||
<label for="webhook-url">Webhook URL</label>
|
||||
<input type="text" id="webhook-url" placeholder="https://discord.com/api/webhooks/...">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="webhook-content">Custom message content <span style="color:var(--muted);font-weight:normal;">(optional, e.g. @here or a role mention)</span></label>
|
||||
<input type="text" id="webhook-content" placeholder="@here">
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="webhook-access-granted">
|
||||
<label for="webhook-access-granted">Access granted</label>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="webhook-access-denied">
|
||||
<label for="webhook-access-denied">Access denied</label>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="webhook-reader-arm">
|
||||
<label for="webhook-reader-arm">Reader armed / disarmed</label>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="webhook-reader-enable">
|
||||
<label for="webhook-reader-enable">Reader enabled / disabled</label>
|
||||
</div>
|
||||
<div class="inline-form" style="margin-top:12px;">
|
||||
<button type="submit" class="primary">Save</button>
|
||||
<button type="button" class="secondary" id="webhook-test-btn">Send test</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Readers</h3>
|
||||
|
|
|
|||
|
|
@ -367,6 +367,8 @@ async function selectPlace(placeId) {
|
|||
document.getElementById('toggle-api-key-btn').textContent = 'Show';
|
||||
document.getElementById('place-api-key-custom').value = '';
|
||||
|
||||
populateWebhookForm(data.place);
|
||||
|
||||
currentPlaceCanEdit = currentOrgRole >= 2;
|
||||
const canEdit = currentPlaceCanEdit;
|
||||
document.getElementById('delete-place-btn').classList.toggle('hidden', !canEdit);
|
||||
|
|
@ -375,6 +377,7 @@ async function selectPlace(placeId) {
|
|||
document.getElementById('regenerate-api-key-btn').disabled = !canEdit;
|
||||
document.getElementById('add-reader-form').querySelector('button').disabled = !canEdit;
|
||||
document.getElementById('add-access-group-form').querySelector('button').disabled = !canEdit;
|
||||
setWebhookFormEditable(canEdit);
|
||||
|
||||
if (canEdit) {
|
||||
populateMoveOrgSelect();
|
||||
|
|
@ -711,6 +714,78 @@ document.getElementById('set-api-key-form').addEventListener('submit', async (e)
|
|||
}
|
||||
});
|
||||
|
||||
// --- Discord webhook settings ---
|
||||
|
||||
function populateWebhookForm(place) {
|
||||
document.getElementById('webhook-url').value = place.webhookUrl || '';
|
||||
document.getElementById('webhook-content').value = place.webhookContent || '';
|
||||
document.getElementById('webhook-access-granted').checked = !!place.webhookAccessGranted;
|
||||
document.getElementById('webhook-access-denied').checked = !!place.webhookAccessDenied;
|
||||
document.getElementById('webhook-reader-arm').checked = !!place.webhookReaderArm;
|
||||
document.getElementById('webhook-reader-enable').checked = !!place.webhookReaderEnable;
|
||||
}
|
||||
|
||||
function setWebhookFormEditable(canEdit) {
|
||||
const form = document.getElementById('webhook-form');
|
||||
form.querySelectorAll('input').forEach((input) => { input.disabled = !canEdit; });
|
||||
form.querySelector('button[type="submit"]').disabled = !canEdit;
|
||||
document.getElementById('webhook-test-btn').disabled = !canEdit;
|
||||
}
|
||||
|
||||
document.getElementById('webhook-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentPlaceId) return;
|
||||
|
||||
const body = {
|
||||
webhookUrl: document.getElementById('webhook-url').value.trim(),
|
||||
webhookContent: document.getElementById('webhook-content').value.trim(),
|
||||
webhookAccessGranted: document.getElementById('webhook-access-granted').checked,
|
||||
webhookAccessDenied: document.getElementById('webhook-access-denied').checked,
|
||||
webhookReaderArm: document.getElementById('webhook-reader-arm').checked,
|
||||
webhookReaderEnable: document.getElementById('webhook-reader-enable').checked
|
||||
};
|
||||
|
||||
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/webhook`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await loadPlaces();
|
||||
} else {
|
||||
alert(result.message || 'Failed to save webhook settings');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('webhook-test-btn').addEventListener('click', async () => {
|
||||
if (!currentPlaceId) return;
|
||||
|
||||
const btn = document.getElementById('webhook-test-btn');
|
||||
const originalLabel = btn.textContent;
|
||||
btn.textContent = 'Sending...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/webhook/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
webhookUrl: document.getElementById('webhook-url').value.trim(),
|
||||
webhookContent: document.getElementById('webhook-content').value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
btn.textContent = 'Sent!';
|
||||
} else {
|
||||
alert(result.message || 'Failed to send test webhook');
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = !currentPlaceCanEdit;
|
||||
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||||
await api('/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
let db;
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { sendPlaceWebhook } = require('../../../lib/webhooks');
|
||||
|
||||
module.exports = (dbInit) => {
|
||||
db = dbInit;
|
||||
|
|
@ -139,6 +140,12 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
|
|||
if (err) console.error('Failed to record scan log:', err.message);
|
||||
}
|
||||
);
|
||||
sendPlaceWebhook(place, 'access_denied', {
|
||||
Reader: ap.name,
|
||||
'User ID': userId || 'unknown',
|
||||
'Card(s)': cardNumbers.join(', ') || undefined,
|
||||
Reason: globalLockdownActive ? 'Site-wide lockdown active' : 'Place lockdown active'
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
grant_type: 'user_scan',
|
||||
|
|
@ -225,6 +232,11 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
|
|||
if (err) console.error('Failed to record scan log:', err.message);
|
||||
}
|
||||
);
|
||||
sendPlaceWebhook(place, responseCode, {
|
||||
Reader: ap.name,
|
||||
'User ID': userId || 'unknown',
|
||||
'Card(s)': cardNumbers.join(', ') || undefined
|
||||
});
|
||||
|
||||
const responseData = granted ? {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const path = require('path');
|
|||
const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth');
|
||||
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
|
||||
const { getEffectiveOrgRole } = require('../lib/orgs');
|
||||
const { sendPlaceWebhook, sendTestWebhook } = require('../lib/webhooks');
|
||||
|
||||
const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx');
|
||||
|
||||
|
|
@ -474,6 +475,52 @@ router.put('/places/:placeId', loadPlace, requireOrgEdit, (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Update a place's Discord webhook configuration: the webhook URL, an optional custom message
|
||||
// `content` (e.g. to ping a role/user), and which event types to notify for. Requires Edit access
|
||||
// in the place's organization, same as other place settings.
|
||||
router.put('/places/:placeId/webhook', loadPlace, requireOrgEdit, (req, res) => {
|
||||
const body = req.body || {};
|
||||
|
||||
const webhookUrl = body.webhookUrl !== undefined ? (String(body.webhookUrl).trim() || null) : req.place.webhookUrl;
|
||||
if (webhookUrl && !/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\//.test(webhookUrl)) {
|
||||
return res.status(400).json({ success: false, message: "That doesn't look like a valid Discord webhook URL" });
|
||||
}
|
||||
|
||||
const webhookContent = body.webhookContent !== undefined ? (String(body.webhookContent).trim() || null) : req.place.webhookContent;
|
||||
const webhookAccessGranted = body.webhookAccessGranted !== undefined ? (body.webhookAccessGranted ? 1 : 0) : req.place.webhookAccessGranted;
|
||||
const webhookAccessDenied = body.webhookAccessDenied !== undefined ? (body.webhookAccessDenied ? 1 : 0) : req.place.webhookAccessDenied;
|
||||
const webhookReaderArm = body.webhookReaderArm !== undefined ? (body.webhookReaderArm ? 1 : 0) : req.place.webhookReaderArm;
|
||||
const webhookReaderEnable = body.webhookReaderEnable !== undefined ? (body.webhookReaderEnable ? 1 : 0) : req.place.webhookReaderEnable;
|
||||
|
||||
db.run(
|
||||
`UPDATE places SET webhookUrl = ?, webhookContent = ?, webhookAccessGranted = ?, webhookAccessDenied = ?, webhookReaderArm = ?, webhookReaderEnable = ? WHERE id = ?`,
|
||||
[webhookUrl, webhookContent, webhookAccessGranted, webhookAccessDenied, webhookReaderArm, webhookReaderEnable, req.place.id],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Failed to update place webhook settings:', err.message);
|
||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
webhook: { webhookUrl, webhookContent, webhookAccessGranted, webhookAccessDenied, webhookReaderArm, webhookReaderEnable }
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Send a one-off test embed to the place's currently-configured (or just-entered, via body.webhookUrl)
|
||||
// Discord webhook, so the user can confirm it's set up correctly before relying on it.
|
||||
router.post('/places/:placeId/webhook/test', loadPlace, requireOrgEdit, async (req, res) => {
|
||||
const webhookUrl = (req.body && req.body.webhookUrl !== undefined ? req.body.webhookUrl : req.place.webhookUrl);
|
||||
const content = (req.body && req.body.webhookContent !== undefined ? req.body.webhookContent : req.place.webhookContent);
|
||||
|
||||
const result = await sendTestWebhook(webhookUrl, content);
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ success: false, message: result.message || 'Failed to send test webhook' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Move a place to a different organization. Requires Edit access in the place's current
|
||||
// organization, and at least Edit access in the destination organization.
|
||||
router.post('/places/:placeId/move', loadPlace, requireOrgEdit, (req, res) => {
|
||||
|
|
@ -707,6 +754,22 @@ router.put('/places/:placeId/access-points/:accessPointId', loadPlace, requireOr
|
|||
console.error('Failed to update reader:', err.message);
|
||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||
}
|
||||
|
||||
// Fire webhooks for any state actually toggled by this request, named after who did it.
|
||||
const actor = req.user ? req.user.username : 'someone';
|
||||
if (!!enabled !== !!ap.enabled) {
|
||||
sendPlaceWebhook(req.place, enabled ? 'reader_enabled' : 'reader_disabled', {
|
||||
Reader: name,
|
||||
By: actor
|
||||
});
|
||||
}
|
||||
if (!!armState !== !!ap.armState) {
|
||||
sendPlaceWebhook(req.place, armState ? 'reader_armed' : 'reader_disarmed', {
|
||||
Reader: name,
|
||||
By: actor
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
}
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue