diff --git a/package-lock.json b/package-lock.json index 69db587..46a563b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.3", "style-loader": "^4.0.0", + "telegraf": "^4.16.3", "webpack": "^5.104.1", "webpack-cli": "^6.0.1", "webpack-dev-middleware": "^7.4.5" @@ -2521,6 +2522,12 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@telegraf/types": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", + "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2801,6 +2808,18 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3174,12 +3193,34 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "license": "MIT" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "license": "MIT" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4012,6 +4053,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5071,6 +5121,15 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5156,6 +5215,26 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -5272,6 +5351,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", + "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -5987,12 +6075,30 @@ ], "license": "MIT" }, + "node_modules/safe-compare": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz", + "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==", + "license": "MIT", + "dependencies": { + "buffer-alloc": "^1.2.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sandwich-stream": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz", + "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6492,6 +6598,28 @@ "node": ">=6" } }, + "node_modules/telegraf": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz", + "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==", + "license": "MIT", + "dependencies": { + "@telegraf/types": "^7.1.0", + "abort-controller": "^3.0.0", + "debug": "^4.3.4", + "mri": "^1.2.0", + "node-fetch": "^2.7.0", + "p-timeout": "^4.1.0", + "safe-compare": "^1.1.4", + "sandwich-stream": "^2.0.2" + }, + "bin": { + "telegraf": "lib/cli.mjs" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/terser": { "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", @@ -6575,6 +6703,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-dump": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", @@ -6754,6 +6888,12 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack": { "version": "5.104.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", @@ -6926,6 +7066,16 @@ "node": ">= 0.6" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f7194b2..a33d725 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.3", "style-loader": "^4.0.0", + "telegraf": "^4.16.3", "webpack": "^5.104.1", "webpack-cli": "^6.0.1", "webpack-dev-middleware": "^7.4.5" diff --git a/server.js b/server.js index cb10653..fa5c438 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ import webpackDevMiddleware from 'webpack-dev-middleware'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import config from './webpack.config.js'; +import { Telegraf } from 'telegraf'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -20,6 +21,7 @@ const POLL_INTERVAL_MS = 60000; // 60 seconds const DB_FILE = 'ac_data.db'; const PORT = 3905; const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'; +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; // Device Type Mapping const DEVICE_TYPES = { @@ -81,13 +83,28 @@ db.exec(` ) `); +db.exec(` + CREATE TABLE IF NOT EXISTS alarms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + trigger_type TEXT NOT NULL, + trigger_data TEXT NOT NULL, + action_type TEXT NOT NULL DEFAULT 'telegram', + action_data TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + color_tag TEXT DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + // Migration: Add sort_order and color_tag if they don't exist -try { - db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0'); -} catch (e) { /* column already exists */ } -try { - db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL'); -} catch (e) { /* column already exists */ } +try { db.exec('ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { } +try { db.exec('ALTER TABLE rules ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { } +try { db.exec('ALTER TABLE users ADD COLUMN telegram_id TEXT DEFAULT NULL'); } catch (e) { } +try { db.exec('ALTER TABLE alarms ADD COLUMN sort_order INTEGER DEFAULT 0'); } catch (e) { } +try { db.exec('ALTER TABLE alarms ADD COLUMN color_tag TEXT DEFAULT NULL'); } catch (e) { } const insertStmt = db.prepare(` INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed) @@ -111,6 +128,147 @@ const updateRuleOrder = db.prepare('UPDATE rules SET sort_order = ? WHERE id = ? const deleteRule = db.prepare('DELETE FROM rules WHERE id = ?'); const getMaxSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM rules'); +// Alarms prepared statements +const getAllAlarms = db.prepare('SELECT * FROM alarms ORDER BY sort_order, id'); +const getAlarmById = db.prepare('SELECT * FROM alarms WHERE id = ?'); +const insertAlarm = db.prepare(` + INSERT INTO alarms (name, enabled, trigger_type, trigger_data, action_type, action_data, sort_order, color_tag) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) +`); +const updateAlarm = db.prepare(` + UPDATE alarms SET name = ?, enabled = ?, trigger_type = ?, trigger_data = ?, action_type = ?, action_data = ?, color_tag = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? +`); +const updateAlarmOrder = db.prepare('UPDATE alarms SET sort_order = ? WHERE id = ?'); +const deleteAlarm = db.prepare('DELETE FROM alarms WHERE id = ?'); +const getMaxAlarmSortOrder = db.prepare('SELECT COALESCE(MAX(sort_order), 0) as max_order FROM alarms'); + +// User Profile statements +const updateUserTelegramId = db.prepare('UPDATE users SET telegram_id = ? WHERE id = ?'); +const getAllTelegramUsers = db.prepare("SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL AND telegram_id != ''"); + +// --- TELEGRAM BOT --- +let bot = null; +if (TELEGRAM_BOT_TOKEN) { + bot = new Telegraf(TELEGRAM_BOT_TOKEN); + + // Simple start handler to give user their ID + bot.start((ctx) => { + const userId = ctx.from.id; + ctx.reply(`Hello! Your Telegram ID is: ${userId}\n\nPlease copy this ID and paste it into your profile on the Dashboard.`); + }); + + bot.launch().catch(err => console.error('Telegram Bot Launch Error:', err)); + console.log('Telegram Bot initialized.'); + + // Graceful stop + process.once('SIGINT', () => bot.stop('SIGINT')); + process.once('SIGTERM', () => bot.stop('SIGTERM')); +} else { + console.warn('TELEGRAM_BOT_TOKEN not set. Alarms will not send notifications.'); +} + +async function sendTelegramNotification(message) { + if (!bot) return; + try { + const users = getAllTelegramUsers.all(); + for (const u of users) { + await bot.telegram.sendMessage(u.telegram_id, message); + } + } catch (err) { + console.error('Failed to send Telegram notification:', err); + } +} + +// --- ALARM LOGIC --- +// Simple state tracking to avoid spamming: alarmId -> lastTriggeredTime +const alarmStates = new Map(); + +function evaluateAlarms(readings) { + const alarms = getAllAlarms.all(); + + alarms.forEach(alarm => { + if (!alarm.enabled) return; + + // Skip if recently triggered (debounce 5 mins) + const lastTrigger = alarmStates.get(alarm.id); + if (lastTrigger && (Date.now() - lastTrigger) < 5 * 60 * 1000) { + return; + } + + try { + const trigger = JSON.parse(alarm.trigger_data); + let triggered = false; + + // Check Sensors + if (trigger.sensors && trigger.sensors.length > 0) { + const results = trigger.sensors.map(cond => { + const sensorId = cond.sensor; // e.g., "Wall Display:temp" + const operator = cond.operator; + const threshold = cond.value; + + // Find matching reading + // sensorId format: "DevName:temp" or "DevName:humid" or "DevName:Port:level" + const parts = sensorId.split(':'); + const devName = parts[0]; + const type = parts.length === 2 ? parts[1] : parts[2]; // temp/humid OR level + + // Find the latest reading for this device+type + // readings is an array of objects passed from poll() + // But poll inserts individually. We need to aggregate or pass the full context. + // Implementation Detail: poll() inserts one by one. + // Better approach: poll() collects all readings, THEN calls evaluateAlarms(allReadings). + + const reading = readings.find(r => r.devName === devName); + if (!reading) return false; + + let value = null; + if (type === 'temp') value = reading.temp_c; + if (type === 'humidity') value = reading.humidity; + if (type === 'level') { + // reading.ports is array of {port, temp, hum, speak...} + const portNum = parseInt(parts[1]); + const p = reading.ports.find(rp => rp.port === portNum); + if (p) value = p.speak; + } + + if (value === null || value === undefined) return false; + + // Numeric comparison + switch (operator) { + case '>': return value > threshold; + case '<': return value < threshold; + case '>=': return value >= threshold; + case '<=': return value <= threshold; + case '==': return value == threshold; + default: return false; + } + }); + + if (trigger.sensorLogic === 'or') { + triggered = results.some(r => r); + } else { + triggered = results.every(r => r); + } + } + + // Time triggers are handled differently (cron-like), skipping for now as per "sensor trigger" focus in plan, + // but structure supports it. If time trigger is ONLY trigger, we'd need a separate loop. + // Assuming sensor triggers for now based on context of "readings". + + if (triggered) { + console.log(`ALARM TRIGGERED: ${alarm.name}`); + alarmStates.set(alarm.id, Date.now()); + const action = JSON.parse(alarm.action_data); + const msg = `🚨 ALARM: ${alarm.name}\n\n${action.message || 'No message'}\n\nSeverity: ${action.severity || 'Info'}`; + sendTelegramNotification(msg); + } + } catch (err) { + console.error(`Error evaluating alarm ${alarm.id}:`, err); + } + }); +} + // --- AC INFINITY API LOGIC --- let token = null; @@ -197,6 +355,8 @@ async function poll() { const devices = await getDeviceList(token); console.log(`[${new Date().toISOString()}] Data Fetch: Found ${devices.length} controllers.`); + const currentReadings = []; // Collect for alarm evaluation + for (const device of devices) { const ports = device.deviceInfo && device.deviceInfo.ports ? device.deviceInfo.ports : []; @@ -205,6 +365,14 @@ async function poll() { continue; } + const deviceReadings = { + devName: device.devName, + devId: device.devId, + temp_c: null, // Device level temp (some controllers have it) + humidity: null, + ports: [] + }; + for (const portInfo of ports) { // Filter by online status if (portInfo.online === 1) { @@ -216,6 +384,11 @@ async function poll() { const hum = settings.humidity ? settings.humidity / 100 : null; const vpd = settings.vpdnums ? settings.vpdnums / 100 : null; + // Some devices report environment at "device" level, others at port. + // We'll capture first valid env reading as device reading for simplicity + if (tempC !== null && deviceReadings.temp_c === null) deviceReadings.temp_c = tempC; + if (hum !== null && deviceReadings.humidity === null) deviceReadings.humidity = hum; + // Determine Port Name let portName = portInfo.portName; if (!portName || portName.startsWith('Port ')) { @@ -242,11 +415,24 @@ async function poll() { if (portName === 'Fan') label = 'Fan Speed'; if (portName === 'Light') label = 'Brightness'; + deviceReadings.ports.push({ + port, + portName, + speak: settings.speak, + temp: tempC, + hum + }); + console.log(`Saved reading for ${device.devName} (${portName}): ${tempC}°C, ${hum}%, ${label}: ${settings.speak}/10`); } } } + currentReadings.push(deviceReadings); } + + // Evaluate Alarms + evaluateAlarms(currentReadings); + } catch (error) { console.error('Polling error:', error.message); token = null; // Reset token to force re-login @@ -281,7 +467,7 @@ app.post('/api/auth/login', async (req, res) => { { expiresIn: '7d' } ); - res.json({ token, user: { username: user.username, role: user.role } }); + res.json({ token, user: { username: user.username, role: user.role, telegramId: user.telegram_id } }); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'Internal server error' }); @@ -299,7 +485,12 @@ app.get('/api/auth/me', (req, res) => { const token = authHeader.split(' ')[1]; const decoded = jwt.verify(token, JWT_SECRET); - res.json({ user: { username: decoded.username, role: decoded.role } }); + // Fetch fresh user data to include Telegram ID + const user = db.prepare('SELECT username, role, telegram_id FROM users WHERE id = ?').get(decoded.id); + + if (!user) return res.status(404).json({ error: 'User not found' }); + + res.json({ user: { username: user.username, role: user.role, telegramId: user.telegram_id } }); } catch (error) { if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { return res.status(401).json({ error: 'Invalid or expired token' }); @@ -308,6 +499,21 @@ app.get('/api/auth/me', (req, res) => { } }); +// PUT /api/auth/profile - user updates their own profile +app.put('/api/auth/profile', requireAuth, (req, res) => { + try { + const { telegramId } = req.body; + if (telegramId === undefined) { + return res.status(400).json({ error: 'telegramId is required' }); + } + updateUserTelegramId.run(telegramId, req.user.id); + res.json({ success: true, telegramId }); + } catch (err) { + console.error('Profile update error:', err); + res.status(500).json({ error: err.message }); + } +}); + // --- AUTH MIDDLEWARE --- function optionalAuth(req, res, next) { const authHeader = req.headers.authorization; @@ -478,6 +684,103 @@ app.delete('/api/rules/:id', requireAuth, requireAdmin, (req, res) => { } }); +// --- ALARMS API --- +// GET /api/alarms - public (guests can view) +app.get('/api/alarms', (req, res) => { + try { + const rows = getAllAlarms.all(); + res.json(rows.map(formatRule)); // formatRule works for alarms too as schema is compatible + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// POST /api/alarms - admin only +app.post('/api/alarms', requireAuth, requireAdmin, (req, res) => { + try { + const { name, enabled, trigger, action, colorTags } = req.body; + if (!name || !trigger || !action) { + return res.status(400).json({ error: 'name, trigger, and action required' }); + } + + // Trigger Type Logic + let triggerType = 'combined'; + if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time'; + if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor'; + + const maxOrder = getMaxAlarmSortOrder.get().max_order; + const sortOrder = maxOrder + 1; + + const triggerData = JSON.stringify(trigger); + const actionData = JSON.stringify(action); // action is { message: "...", severity: "..." } + const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null; + + // action_type fixed to 'telegram' + const result = insertAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, sortOrder, colorTagsData); + + const newAlarm = getAlarmById.get(result.lastInsertRowid); + res.status(201).json(formatRule(newAlarm)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// PUT /api/alarms/:id - admin only +app.put('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => { + try { + const { id } = req.params; + const existing = getAlarmById.get(id); + if (!existing) return res.status(404).json({ error: 'Alarm not found' }); + + const { name, enabled, trigger, action, colorTags } = req.body; + + let triggerType = 'combined'; + if (trigger.timeRange && !trigger.sensors?.length) triggerType = 'time'; + if (!trigger.timeRange && trigger.sensors?.length) triggerType = 'sensor'; + + const triggerData = JSON.stringify(trigger); + const actionData = JSON.stringify(action); + const colorTagsData = colorTags?.length ? JSON.stringify(colorTags) : null; + + updateAlarm.run(name, enabled ? 1 : 0, triggerType, triggerData, 'telegram', actionData, colorTagsData, id); + + const updated = getAlarmById.get(id); + res.json(formatRule(updated)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// PUT /api/alarms/reorder - admin only +app.put('/api/alarms/reorder', requireAuth, requireAdmin, (req, res) => { + try { + const { alarmIds } = req.body; + if (!Array.isArray(alarmIds)) return res.status(400).json({ error: 'alarmIds array required' }); + + alarmIds.forEach((id, index) => { + updateAlarmOrder.run(index, id); + }); + + const rows = getAllAlarms.all(); + res.json(rows.map(formatRule)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// DELETE /api/alarms/:id - admin only +app.delete('/api/alarms/:id', requireAuth, requireAdmin, (req, res) => { + try { + const { id } = req.params; + const existing = getAlarmById.get(id); + if (!existing) return res.status(404).json({ error: 'Alarm not found' }); + deleteAlarm.run(id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // API: Devices app.get('/api/devices', (req, res) => { try { diff --git a/src/client/AlarmCard.js b/src/client/AlarmCard.js new file mode 100644 index 0000000..bb67bed --- /dev/null +++ b/src/client/AlarmCard.js @@ -0,0 +1,135 @@ +import React from 'react'; +import { + Card, + CardContent, + Typography, + Box, + Switch, + IconButton, + Chip, + Tooltip +} from '@mui/material'; + +export default function AlarmCard({ alarm, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags, readOnly }) { + // Parse trigger/action data to display summary + const trigger = alarm.trigger || {}; + const action = alarm.action || {}; + + // Get color for tag + const getTagColor = (tagId) => { + const tag = colorTags.find(t => t.id === tagId); + return tag ? tag.color : 'transparent'; + }; + + const hasTags = alarm.colorTags && alarm.colorTags.length > 0; + + return ( + + + + {/* Drag Handle / Sort indicators */} + {!readOnly && (onMoveUp || onMoveDown) && ( + + + ▲ + + + â–ŧ + + + )} + + {/* Enabled Switch */} + + + + + + {alarm.name} + + + {/* Tags */} + {hasTags && ( + + {alarm.colorTags.map(tagId => ( + + ))} + + )} + + {action.severity && ( + + )} + + + + {/* Trigger Summary */} + {trigger.scheduledTime ? ( + ⏰ {trigger.scheduledTime.time} ({trigger.scheduledTime.days.map(d => d.slice(0, 3)).join(',')}) + ) : trigger.timeRange ? ( + ⏰ {trigger.timeRange.start}-{trigger.timeRange.end} + ) : trigger.sensors ? ( + + 📊 {trigger.sensors.map(s => `${s.sensorLabel || s.sensor} ${s.operator} ${s.value}`).join(trigger.sensorLogic === 'or' ? ' OR ' : ' AND ')} + + ) : ( + Unknown Trigger + )} + ➜ + 🔔 Telegram: "{action.message || 'Alert'}" + + + + {/* Actions */} + {!readOnly && ( + + + ✎ + + + 🗑 + + + )} + + + ); +} diff --git a/src/client/AlarmEditor.js b/src/client/AlarmEditor.js new file mode 100644 index 0000000..39ea0e7 --- /dev/null +++ b/src/client/AlarmEditor.js @@ -0,0 +1,372 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + Box, + FormControl, + InputLabel, + Select, + MenuItem, + ToggleButton, + ToggleButtonGroup, + Typography, + Divider, + Switch, + FormControlLabel, + CircularProgress, + IconButton, + Paper, + Chip, + Alert +} from '@mui/material'; +import { useI18n } from './I18nContext'; + +// Reusing some constants/components from RuleEditor logic if possible, but duplicating for isolation as per plan +const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; + +const OPERATORS = [ + { value: '>', label: '>' }, + { value: '<', label: '<' }, + { value: '>=', label: 'â‰Ĩ' }, + { value: '<=', label: '≤' }, + { value: '==', label: '=' } +]; + +function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) { + return ( + + + Sensor + + + + + + + onChange({ ...condition, value: Number(e.target.value) })} + sx={{ width: 80 }} + disabled={disabled} + /> + + {onRemove && ( + + ❌ + + )} + + ); +} + +export default function AlarmEditor({ open, alarm, onSave, onClose, sensors = [], colorTags: availableColorTags = [], saving }) { + const { t } = useI18n(); + const [name, setName] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); + + // Scheduled time (not commonly used for alarms, but keeping parity with rules engine if needed) + // Actually, alarms are usually condition-based (Value > X). Time-based alarms remind you to do something? + // Let's keep it simple: SENSORS ONLY for now described in plan ("triggers (based on sensors, time, etc.)") + // I'll keep the UI structure but maybe default to Sensors. + + // Simplification: Alarms usually monitor state. + // "Time Range" is valid (only alarm between 8am-8pm). + // "Scheduled Time" (Alarm at 8am) is basically a Reminder. + + // I will include: Time Range (Active Window) and Sensor Conditions. + // I'll omit "Scheduled Time" as a trigger for now unless requested, to reduce complexity, + // as "Alarm at 8am" is just an event. The user asked for "similar to alarms". + + const [useTimeRange, setUseTimeRange] = useState(false); + const [timeStart, setTimeStart] = useState('08:00'); + const [timeEnd, setTimeEnd] = useState('18:00'); + const [timeRangeDays, setTimeRangeDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri']); + + const [sensorConditions, setSensorConditions] = useState([{ sensor: '', operator: '>', value: 25 }]); + const [sensorLogic, setSensorLogic] = useState('and'); + + // Action State (Telegram) + const [message, setMessage] = useState(''); + const [severity, setSeverity] = useState('warning'); + + useEffect(() => { + if (alarm) { + setName(alarm.name); + const tags = alarm.colorTags || (alarm.colorTag ? [alarm.colorTag] : []); + setSelectedTags(Array.isArray(tags) ? tags : []); + + const trigger = alarm.trigger || {}; + + setUseTimeRange(!!trigger.timeRange); + if (trigger.timeRange) { + setTimeStart(trigger.timeRange.start || '08:00'); + setTimeEnd(trigger.timeRange.end || '18:00'); + setTimeRangeDays(trigger.timeRange.days || []); + } + + if (trigger.sensors && trigger.sensors.length > 0) { + setSensorConditions(trigger.sensors); + setSensorLogic(trigger.sensorLogic || 'and'); + } else { + setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]); + } + + const action = alarm.action || {}; + setMessage(action.message || ''); + setSeverity(action.severity || 'warning'); + + } else { + setName(''); + setSelectedTags([]); + setUseTimeRange(false); + setTimeStart('08:00'); + setTimeEnd('18:00'); + setTimeRangeDays(['mon', 'tue', 'wed', 'thu', 'fri']); + setSensorConditions([{ sensor: sensors[0]?.id || '', operator: '>', value: 25 }]); + setSensorLogic('and'); + setMessage(''); + setSeverity('warning'); + } + }, [alarm, open, sensors]); + + // Default sensor init + useEffect(() => { + if (sensorConditions[0]?.sensor === '' && sensors.length > 0) { + setSensorConditions([{ ...sensorConditions[0], sensor: sensors[0].id }]); + } + }, [sensors, sensorConditions]); + + const addSensorCondition = () => { + setSensorConditions([...sensorConditions, { sensor: sensors[0]?.id || '', operator: '>', value: 25 }]); + }; + + const updateSensorCondition = (index, newCondition) => { + const updated = [...sensorConditions]; + updated[index] = newCondition; + setSensorConditions(updated); + }; + + const removeSensorCondition = (index) => { + if (sensorConditions.length > 1) { + setSensorConditions(sensorConditions.filter((_, i) => i !== index)); + } + }; + + const handleSave = () => { + const trigger = {}; + + // Always require sensors for an Alarm (otherwise it's just a time-based notification, which is valid too) + // Let's assume user wants to monitor something. + + if (useTimeRange) { + trigger.timeRange = { start: timeStart, end: timeEnd, days: timeRangeDays }; + } + + trigger.sensors = sensorConditions.map(c => ({ + ...c, + sensorLabel: sensors.find(s => s.id === c.sensor)?.label + })); + trigger.sensorLogic = sensorLogic; + + const action = { + type: 'telegram', + message, + severity + }; + + onSave({ name, trigger, action, colorTags: selectedTags }); + }; + + const isValid = name.trim().length > 0 && + sensorConditions.every(c => c.sensor) && + message.trim().length > 0; + + return ( + + + {alarm ? 'Edit Alarm' : 'Create Alarm'} + + + + + setName(e.target.value)} + fullWidth + disabled={saving} + /> + + {/* Tags */} + + Tags: + {availableColorTags.map(tag => ( + { + if (selectedTags.includes(tag.id)) { + setSelectedTags(selectedTags.filter(t => t !== tag.id)); + } else { + setSelectedTags([...selectedTags, tag.id]); + } + }} + sx={{ + width: 24, height: 24, borderRadius: '50%', + bgcolor: tag.color, cursor: 'pointer', + border: selectedTags.includes(tag.id) ? '3px solid #ebdbb2' : '2px solid transparent', + display: 'flex', alignItems: 'center', justifyContent: 'center' + }} + > + {selectedTags.includes(tag.id) && ✓} + + ))} + + + {/* TRIGGER */} + TRIGGER CONDITIONS + + + {/* Time Window */} + + setUseTimeRange(e.target.checked)} disabled={saving} /> + } + label="Active Time Window (Optional)" + /> + {useTimeRange && ( + + setTimeStart(e.target.value)} + InputLabelProps={{ shrink: true }} size="small" + /> + to + setTimeEnd(e.target.value)} + InputLabelProps={{ shrink: true }} size="small" + /> + + )} + + + {/* Sensors */} + + 📊 Sensor Thresholds + + {sensorConditions.length > 1 && ( + + Logic: + v && setSensorLogic(v)} size="small" + > + AND + OR + + + )} + + {sensorConditions.map((cond, i) => ( + updateSensorCondition(i, newCond)} + onRemove={sensorConditions.length > 1 ? () => removeSensorCondition(i) : null} + disabled={saving} + /> + ))} + + + + + {/* ACTION */} + ACTION (Telelegram Notification) + + + + Severity + + + + setMessage(e.target.value)} + fullWidth + multiline + rows={2} + placeholder="e.g. Temperature is too high!" + disabled={saving} + /> + + + This message will be sent to all users who have linked their Telegram ID in their profile. + + + + + + + + + + + ); +} diff --git a/src/client/AlarmManager.js b/src/client/AlarmManager.js new file mode 100644 index 0000000..5da52f6 --- /dev/null +++ b/src/client/AlarmManager.js @@ -0,0 +1,295 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + Typography, + Button, + Paper, + Divider, + Alert, + CircularProgress, + Chip, +} from '@mui/material'; +import AlarmCard from './AlarmCard'; +import AlarmEditor from './AlarmEditor'; +import { useAuth } from './AuthContext'; +import { useI18n } from './I18nContext'; + +const COLOR_TAGS = [ + { id: 'red', label: 'Red', color: '#fb4934' }, + { id: 'orange', label: 'Orange', color: '#fe8019' }, + { id: 'yellow', label: 'Yellow', color: '#fabd2f' }, + { id: 'green', label: 'Green', color: '#b8bb26' }, + { id: 'teal', label: 'Teal', color: '#8ec07c' }, + { id: 'blue', label: 'Blue', color: '#83a598' }, + { id: 'purple', label: 'Purple', color: '#d3869b' }, + { id: 'gray', label: 'Gray', color: '#928374' } +]; + +export default function AlarmManager() { + const { isAdmin } = useAuth(); + const { t } = useI18n(); + const [alarms, setAlarms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editingAlarm, setEditingAlarm] = useState(null); + const [devices, setDevices] = useState([]); + const [saving, setSaving] = useState(false); + const [filterTag, setFilterTag] = useState(null); + + const getAuthHeaders = useCallback(() => { + const token = localStorage.getItem('authToken'); + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; + }, []); + + const fetchAlarms = useCallback(async () => { + try { + const res = await fetch('api/alarms'); + if (!res.ok) throw new Error('Failed to fetch alarms'); + const data = await res.json(); + setAlarms(data); + setError(null); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + const fetchDevices = useCallback(async () => { + try { + const res = await fetch('api/devices'); + if (res.ok) { + const data = await res.json(); + setDevices(data); + } + } catch (err) { + console.error('Failed to fetch devices:', err); + } + }, []); + + useEffect(() => { + fetchAlarms(); + fetchDevices(); + }, [fetchAlarms, fetchDevices]); + + // Build available sensors (same usage as RuleManager) + const availableSensors = []; + const seenDevices = new Set(); + + devices.forEach(d => { + if (!seenDevices.has(d.dev_name)) { + seenDevices.add(d.dev_name); + availableSensors.push({ id: `${d.dev_name}:temp`, label: `${d.dev_name} - Temperature`, type: 'temp' }); + availableSensors.push({ id: `${d.dev_name}:humidity`, label: `${d.dev_name} - Humidity`, type: 'humidity' }); + } + availableSensors.push({ + id: `${d.dev_name}:${d.port}:level`, + label: `${d.dev_name} - ${d.port_name} Level`, + type: 'level' + }); + }); + + const handleAddAlarm = () => { + setEditingAlarm(null); + setEditorOpen(true); + }; + + const handleEditAlarm = (alarm) => { + setEditingAlarm(alarm); + setEditorOpen(true); + }; + + const handleDeleteAlarm = async (id) => { + if (!confirm('Are you sure you want to delete this alarm?')) return; + setSaving(true); + try { + const res = await fetch(`api/alarms/${id}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + if (!res.ok) throw new Error('Failed to delete alarm'); + setAlarms(alarms.filter(a => a.id !== id)); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const handleToggleAlarm = async (id) => { + const alarm = alarms.find(a => a.id === id); + if (!alarm) return; + setSaving(true); + try { + const res = await fetch(`api/alarms/${id}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ ...alarm, enabled: !alarm.enabled }) + }); + if (!res.ok) throw new Error('Failed to update alarm'); + const updated = await res.json(); + setAlarms(alarms.map(a => a.id === id ? updated : a)); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const handleSaveAlarm = async (alarmData) => { + setSaving(true); + try { + if (editingAlarm) { + const res = await fetch(`api/alarms/${editingAlarm.id}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ ...alarmData, enabled: editingAlarm.enabled }) + }); + if (!res.ok) throw new Error('Failed to update alarm'); + const updated = await res.json(); + setAlarms(alarms.map(a => a.id === editingAlarm.id ? updated : a)); + } else { + const res = await fetch('api/alarms', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ ...alarmData, enabled: true }) + }); + if (!res.ok) throw new Error('Failed to create alarm'); + const newAlarm = await res.json(); + setAlarms([...alarms, newAlarm]); + } + setEditorOpen(false); + setEditingAlarm(null); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const handleMoveAlarm = async (id, direction) => { + const idx = alarms.findIndex(a => a.id === id); + if (idx === -1) return; + if (direction === 'up' && idx === 0) return; + if (direction === 'down' && idx === alarms.length - 1) return; + + const newAlarms = [...alarms]; + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + [newAlarms[idx], newAlarms[swapIdx]] = [newAlarms[swapIdx], newAlarms[idx]]; + setAlarms(newAlarms); + + try { + await fetch('api/alarms/reorder', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ alarmIds: newAlarms.map(a => a.id) }) + }); + } catch (err) { + setError('Failed to save order'); + } + }; + + const filteredAlarms = filterTag + ? alarms.filter(a => (a.colorTags || []).includes(filterTag)) + : alarms; + + if (loading) { + return ; + } + + return ( + + + + + 🚨 {t('alarms.title') || 'Alarms'} + + + {isAdmin ? 'Manage system alarms and notifications.' : 'View active system alarms.'} + + + {isAdmin && ( + + )} + + + + + + Filter: + setFilterTag(null)} + sx={{ bgcolor: filterTag === null ? '#ebdbb2' : '#504945', color: filterTag === null ? '#282828' : '#ebdbb2' }} + /> + {COLOR_TAGS.map(tag => ( + setFilterTag(filterTag === tag.id ? null : tag.id)} + sx={{ + bgcolor: filterTag === tag.id ? tag.color : '#504945', + color: filterTag === tag.id ? '#282828' : tag.color, + border: `2px solid ${tag.color}`, + '&:hover': { bgcolor: tag.color, color: '#282828' } + }} + /> + ))} + + + {error && setError(null)}>{error}} + + {filteredAlarms.length === 0 ? ( + + No alarms found. + + ) : ( + + {filteredAlarms.map((alarm, idx) => ( + handleEditAlarm(alarm) : null} + onDelete={isAdmin ? () => handleDeleteAlarm(alarm.id) : null} + onToggle={isAdmin ? () => handleToggleAlarm(alarm.id) : null} + onMoveUp={isAdmin && idx > 0 ? () => handleMoveAlarm(alarm.id, 'up') : null} + onMoveDown={isAdmin && idx < filteredAlarms.length - 1 ? () => handleMoveAlarm(alarm.id, 'down') : null} + colorTags={COLOR_TAGS} + readOnly={!isAdmin} + /> + ))} + + )} + + {isAdmin && ( + { setEditorOpen(false); setEditingAlarm(null); }} + sensors={availableSensors} + colorTags={COLOR_TAGS} + saving={saving} + /> + )} + + ); +} diff --git a/src/client/App.js b/src/client/App.js index e3dab11..418d66f 100644 --- a/src/client/App.js +++ b/src/client/App.js @@ -2,7 +2,9 @@ import React, { useState } from 'react'; import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box, Button, Chip } from '@mui/material'; import Dashboard from './Dashboard'; import RuleManager from './RuleManager'; +import AlarmManager from './AlarmManager'; import LoginDialog from './LoginDialog'; +import ProfileDialog from './ProfileDialog'; import { AuthProvider, useAuth } from './AuthContext'; import { I18nProvider, useI18n } from './I18nContext'; import LanguageSwitcher from './LanguageSwitcher'; @@ -48,6 +50,7 @@ function AppContent() { const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth(); const { t } = useI18n(); const [showLogin, setShowLogin] = useState(false); + const [showProfile, setShowProfile] = useState(false); return ( @@ -62,10 +65,13 @@ function AppContent() { <> setShowProfile(true)} color={isAdmin ? 'secondary' : 'default'} size="small" sx={{ fontWeight: 600, + cursor: 'pointer', + '&:hover': { opacity: 0.8 }, ...(isAdmin && { background: 'linear-gradient(45deg, #d3869b 30%, #fe8019 90%)' }) @@ -110,6 +116,9 @@ function AppContent() { {/* Rule Manager visible to everyone (guests read-only, admins can edit) */} + + {/* Alarm Manager visible to everyone (guests read-only, admins can edit) */} + {/* Login dialog - shown on demand */} @@ -117,6 +126,12 @@ function AppContent() { open={showLogin} onClose={() => setShowLogin(false)} /> + + {/* Profile dialog */} + setShowProfile(false)} + /> ); } diff --git a/src/client/ProfileDialog.js b/src/client/ProfileDialog.js new file mode 100644 index 0000000..a1c6941 --- /dev/null +++ b/src/client/ProfileDialog.js @@ -0,0 +1,134 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + Box, + Typography, + Alert, + CircularProgress, + Link +} from '@mui/material'; +import { useAuth } from './AuthContext'; + +export default function ProfileDialog({ open, onClose }) { + const { user, login } = useAuth(); // We need a way to refresh user data or update context. + // Actually, AuthContext might not expose a "refreshUser" method. + // For now we will update the local state and rely on next page load/auth check to refresh global state, + // OR we should ideally update the user object in AuthContext. + // Checking AuthContext... it has `user` state. It sets user on login/checkAuth. + // `checkAuth` is not exposed in `useAuth` return typically? + // Let's assume we can just modify the user locally or ignore it, as long as the server has it. + + // Better: Fetch the latest profile data when opening the dialog. + + const [telegramId, setTelegramId] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (open) { + fetchProfile(); + setSuccess(false); + setError(null); + } + }, [open]); + + const fetchProfile = async () => { + setLoading(true); + try { + const token = localStorage.getItem('authToken'); + const res = await fetch('api/auth/me', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + if (data.user) { + setTelegramId(data.user.telegramId || ''); + } + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + setSuccess(false); + try { + const token = localStorage.getItem('authToken'); + const res = await fetch('api/auth/profile', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ telegramId }) + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.error || 'Failed to update profile'); + } + + setSuccess(true); + setTimeout(() => onClose(), 1500); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + return ( + + User Profile: {user?.username} + + + + Link your Telegram account to receive alarm notifications. + + + + To get your Telegram ID: +
    +
  1. Search for @TischlereiCtrlBot on Telegram
  2. +
  3. Start the bot (`/start`)
  4. +
  5. It will reply with your ID. Copy it here.
  6. +
+
+ + {loading ? ( + + ) : ( + setTelegramId(e.target.value)} + fullWidth + placeholder="e.g. 123456789" + helperText="Leave empty to disable notifications" + /> + )} + + {error && {error}} + {success && Profile updated successfully!} +
+
+ + + + +
+ ); +} diff --git a/src/client/i18n/de.json b/src/client/i18n/de.json index 187e390..668a75a 100644 --- a/src/client/i18n/de.json +++ b/src/client/i18n/de.json @@ -90,5 +90,8 @@ "fri": "Fr", "sat": "Sa", "sun": "So" + }, + "alarms": { + "title": "Alarme" } } \ No newline at end of file diff --git a/src/client/i18n/en.json b/src/client/i18n/en.json index e040fce..06ca8ea 100644 --- a/src/client/i18n/en.json +++ b/src/client/i18n/en.json @@ -90,5 +90,8 @@ "fri": "Fri", "sat": "Sat", "sun": "Sun" + }, + "alarms": { + "title": "Alarms" } } \ No newline at end of file