Pushover: Emergency-Priorität mit Retry, has_pushover Badge in Admin

This commit is contained in:
2026-05-29 09:00:10 +02:00
parent 8f7dbbf3c9
commit 9b54bb700b
5 changed files with 123 additions and 26 deletions

View File

@@ -315,6 +315,13 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='boa
`); `);
} }
// Migration: Pushover retry/expire
const poCols = db.prepare("PRAGMA table_info(pushover_settings)").all().map(r => r.name);
if (poCols.length && !poCols.includes('retry'))
db.exec("ALTER TABLE pushover_settings ADD COLUMN retry INTEGER DEFAULT NULL");
if (poCols.length && !poCols.includes('expire'))
db.exec("ALTER TABLE pushover_settings ADD COLUMN expire INTEGER DEFAULT NULL");
// ── Push-Zeitplaner ─────────────────────────────────────────────────────────── // ── Push-Zeitplaner ───────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) { if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) {
db.exec(` db.exec(`

View File

@@ -89,12 +89,18 @@ app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
// ── Push-Zeitplaner Hintergrund-Job ────────────────────────────────────────── // ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
const db = require('./db'); const db = require('./db');
async function sendPushoverMsg(userKey, appToken, message) { async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
try { try {
const params = { token: appToken, user: userKey, title: 'DickenDock Erinnerung', message };
if (opts.retry && opts.expire) {
params.priority = 2;
params.retry = opts.retry;
params.expire = opts.expire;
}
await fetch('https://api.pushover.net/1/messages.json', { await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token: appToken, user: userKey, title: 'DickenDock Erinnerung', message }), body: new URLSearchParams(params),
}); });
} catch(e) { console.error('Pushover Fehler:', e.message); } } catch(e) { console.error('Pushover Fehler:', e.message); }
} }
@@ -110,13 +116,14 @@ async function runScheduler() {
try { try {
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t; const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
const due = db.prepare(` const due = db.prepare(`
SELECT s.*, p.user_key, p.app_token SELECT s.*, p.user_key, p.app_token, p.retry, p.expire
FROM push_schedules s FROM push_schedules s
JOIN pushover_settings p ON p.user_id = s.user_id JOIN pushover_settings p ON p.user_id = s.user_id
WHERE s.sent = 0 AND s.scheduled_at <= ? WHERE s.sent = 0 AND s.scheduled_at <= ?
`).all(now); `).all(now);
for (const row of due) { for (const row of due) {
await sendPushoverMsg(row.user_key, row.app_token, row.message); await sendPushoverMsg(row.user_key, row.app_token, row.message,
{ retry: row.retry, expire: row.expire });
db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id); db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id);
console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`); console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`);
} }

View File

@@ -31,7 +31,13 @@ router.post('/restore', authenticate, requireAdmin, upload.single('database'), (
// GET /api/admin/users alle Benutzer mit Statistiken // GET /api/admin/users alle Benutzer mit Statistiken
router.get('/users', authenticate, requireAdmin, (req, res) => { router.get('/users', authenticate, requireAdmin, (req, res) => {
const users = db.prepare("SELECT id, username, role, created_at, last_active_at FROM users ORDER BY role DESC, username").all(); const users = db.prepare(`
SELECT u.id, u.username, u.role, u.created_at, u.last_active_at,
CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover
FROM users u
LEFT JOIN pushover_settings p ON p.user_id = u.id
ORDER BY u.role DESC, u.username
`).all();
const stats = users.map(u => { const stats = users.map(u => {
const archiv = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(u.id).c; const archiv = db.prepare('SELECT COUNT(*) c FROM calculations WHERE user_id=?').get(u.id).c;
const bestellung = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(u.id).c; const bestellung = db.prepare('SELECT COUNT(*) c FROM orders WHERE user_id=?').get(u.id).c;

View File

@@ -4,12 +4,18 @@ const { authenticate } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
// ── Pushover Helper ─────────────────────────────────────────────────────────── // ── Pushover Helper ───────────────────────────────────────────────────────────
async function sendPushover(userKey, appToken, title, message) { async function sendPushover(userKey, appToken, title, message, opts = {}) {
try { try {
const params = { token: appToken, user: userKey, title, message };
if (opts.retry && opts.expire) {
params.priority = 2;
params.retry = opts.retry;
params.expire = opts.expire;
}
await fetch('https://api.pushover.net/1/messages.json', { await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token: appToken, user: userKey, title, message }), body: new URLSearchParams(params),
}); });
} catch {} } catch {}
} }
@@ -96,7 +102,8 @@ router.post('/messages/:userId', authenticate, async (req, res) => {
const senderName = db.prepare('SELECT username FROM users WHERE id = ?').get(me)?.username || 'Jemand'; const senderName = db.prepare('SELECT username FROM users WHERE id = ?').get(me)?.username || 'Jemand';
const pushover = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(recipId); const pushover = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(recipId);
if (pushover) { if (pushover) {
await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`); await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`,
{ retry: pushover.retry, expire: pushover.expire });
} }
res.json({ id: result.lastInsertRowid }); res.json({ id: result.lastInsertRowid });
@@ -132,18 +139,19 @@ router.delete('/my-messages', authenticate, (req, res) => {
// ── Pushover Settings ───────────────────────────────────────────────────────── // ── Pushover Settings ─────────────────────────────────────────────────────────
router.get('/pushover', authenticate, (req, res) => { router.get('/pushover', authenticate, (req, res) => {
const row = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id = ?').get(req.user.id); const row = db.prepare('SELECT user_key, app_token, retry, expire FROM pushover_settings WHERE user_id = ?').get(req.user.id);
res.json(row || { user_key: '', app_token: '' }); res.json(row || { user_key: '', app_token: '', retry: null, expire: null });
}); });
router.post('/pushover', authenticate, (req, res) => { router.post('/pushover', authenticate, (req, res) => {
const { user_key, app_token } = req.body; const { user_key, app_token, retry, expire } = req.body;
if (!user_key || !app_token) return res.status(400).json({ error: 'Beide Felder erforderlich' }); if (!user_key || !app_token) return res.status(400).json({ error: 'Beide Felder erforderlich' });
db.prepare(` db.prepare(`
INSERT INTO pushover_settings (user_id, user_key, app_token) INSERT INTO pushover_settings (user_id, user_key, app_token, retry, expire)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET user_key=excluded.user_key, app_token=excluded.app_token ON CONFLICT(user_id) DO UPDATE SET user_key=excluded.user_key, app_token=excluded.app_token,
`).run(req.user.id, user_key, app_token); retry=excluded.retry, expire=excluded.expire
`).run(req.user.id, user_key, app_token, retry || null, expire || null);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -155,7 +163,12 @@ router.delete('/pushover', authenticate, (req, res) => {
router.post('/pushover/test', authenticate, async (req, res) => { router.post('/pushover/test', authenticate, async (req, res) => {
const row = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(req.user.id); const row = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(req.user.id);
if (!row) return res.status(400).json({ error: 'Keine Pushover-Einstellungen gespeichert' }); if (!row) return res.status(400).json({ error: 'Keine Pushover-Einstellungen gespeichert' });
await sendPushover(row.user_key, row.app_token, 'DickenDock', 'Pushover-Verbindung erfolgreich! 🎉'); const useEmergency = req.body?.emergency === true && row.retry && row.expire;
const msg = useEmergency
? `🚨 Emergency-Test! Wiederholt alle ${row.retry}s für max. ${row.expire}s. Bitte in Pushover quittieren.`
: 'Pushover-Verbindung erfolgreich! 🎉';
await sendPushover(row.user_key, row.app_token, 'DickenDock', msg,
useEmergency ? { retry: row.retry, expire: row.expire } : {});
res.json({ ok: true }); res.json({ ok: true });
}); });

View File

@@ -757,30 +757,46 @@ function NachrichtenWidget({ unreadMsgs, setActive }) {
// ── Pushover Settings ───────────────────────────────────────────────────────── // ── Pushover Settings ─────────────────────────────────────────────────────────
function PushoverSettings({ toast }) { function PushoverSettings({ toast }) {
const [form, setForm] = useState({ user_key:'', app_token:'' }); const [form, setForm] = useState({ user_key:'', app_token:'', retry: '', expire: '' });
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [hasSaved, setHasSaved] = useState(false); const [hasSaved, setHasSaved] = useState(false);
const [emergency,setEmergency]= useState(false);
useEffect(() => { useEffect(() => {
api('/tools/nachrichten/pushover') api('/tools/nachrichten/pushover')
.then(d => { .then(d => {
const v = { user_key: d.user_key||'', app_token: d.app_token||'' }; const v = { user_key: d.user_key||'', app_token: d.app_token||'',
retry: d.retry||'', expire: d.expire||'' };
setForm(v); setForm(v);
setHasSaved(!!(v.user_key && v.app_token)); setHasSaved(!!(v.user_key && v.app_token));
setEmergency(!!(d.retry && d.expire));
}).catch(()=>{}); }).catch(()=>{});
}, []); }, []);
const save = async () => { const save = async () => {
if (!form.user_key.trim() || !form.app_token.trim()) { toast('Beide Felder ausfüllen', 'error'); return; } if (!form.user_key.trim() || !form.app_token.trim()) { toast('Beide Felder ausfüllen', 'error'); return; }
setBusy(true); setBusy(true);
try { await api('/tools/nachrichten/pushover', { body: form }); toast('Gespeichert'); setHasSaved(true); } try {
catch(e) { toast(e.message, 'error'); } await api('/tools/nachrichten/pushover', { body: {
...form,
retry: emergency ? (parseInt(form.retry) || 60) : null,
expire: emergency ? (parseInt(form.expire) || 3600) : null,
}});
toast('Gespeichert'); setHasSaved(true);
} catch(e) { toast(e.message, 'error'); }
setBusy(false); setBusy(false);
}; };
const test = async () => { const test = async () => {
setBusy(true); setBusy(true);
try { await api('/tools/nachrichten/pushover/test', { method:'POST' }); toast('Testbenachrichtigung gesendet ✓'); } try { await api('/tools/nachrichten/pushover/test', { method:'POST', body:{ emergency: false } }); toast('Test gesendet ✓ (normale Priorität)'); }
catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const testEmergency = async () => {
setBusy(true);
try { await api('/tools/nachrichten/pushover/test', { method:'POST', body:{ emergency: true } }); toast('🚨 Emergency-Test gesendet quittiere in der Pushover-App!'); }
catch(e) { toast(e.message, 'error'); } catch(e) { toast(e.message, 'error'); }
setBusy(false); setBusy(false);
}; };
@@ -788,8 +804,8 @@ function PushoverSettings({ toast }) {
const clear = async () => { const clear = async () => {
try { try {
await api('/tools/nachrichten/pushover', { method:'DELETE' }); await api('/tools/nachrichten/pushover', { method:'DELETE' });
setForm({ user_key:'', app_token:'' }); setForm({ user_key:'', app_token:'', retry:'', expire:'' });
setHasSaved(false); setHasSaved(false); setEmergency(false);
toast('Pushover entfernt'); toast('Pushover entfernt');
} catch(e) { toast(e.message, 'error'); } } catch(e) { toast(e.message, 'error'); }
}; };
@@ -797,12 +813,47 @@ function PushoverSettings({ toast }) {
return ( return (
<div> <div>
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace', marginBottom:12, lineHeight:1.7 }}> <p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace', marginBottom:12, lineHeight:1.7 }}>
Push-Benachrichtigungen bei neuen Nachrichten via{' '} Push-Benachrichtigungen via{' '}
<a href="https://pushover.net" target="_blank" rel="noopener noreferrer" style={{ color:'#4ecdc4' }}>pushover.net</a>. <a href="https://pushover.net" target="_blank" rel="noopener noreferrer" style={{ color:'#4ecdc4' }}>pushover.net</a>.
Du brauchst einen Account und eine eigene App (kostenlos). Du brauchst einen Account und eine eigene App (kostenlos).
</p> </p>
<Field label="USER KEY" value={form.user_key} onChange={e=>setForm(p=>({...p,user_key:e.target.value}))} /> <Field label="USER KEY" value={form.user_key} onChange={e=>setForm(p=>({...p,user_key:e.target.value}))} />
<Field label="APP TOKEN" value={form.app_token} onChange={e=>setForm(p=>({...p,app_token:e.target.value}))} /> <Field label="APP TOKEN" value={form.app_token} onChange={e=>setForm(p=>({...p,app_token:e.target.value}))} />
{/* Emergency / Wiederholung */}
<div style={{ marginBottom:14, background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8, padding:'10px 12px' }}>
<label style={{ display:'flex', alignItems:'center', gap:8, cursor:'pointer', marginBottom: emergency ? 10 : 0 }}>
<input type="checkbox" checked={emergency} onChange={e=>setEmergency(e.target.checked)}
style={{ width:14, height:14, accentColor:'#ff6b9d', cursor:'pointer' }}/>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11 }}>
🚨 Wiederholen bis Quittierung (Emergency-Priorität)
</span>
</label>
{emergency && (
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginTop:6 }}>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3, fontSize:9 }}>WIEDERHOLUNG (SEK)</label>
<input type="number" value={form.retry||60} min={30} max={3600}
onChange={e=>setForm(p=>({...p,retry:e.target.value}))}
style={{ ...S.inp, fontSize:13 }}/>
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:8, marginTop:2 }}>
min. 30s · z.B. 60 = jede Minute
</div>
</div>
<div>
<label style={{ ...S.head, display:'block', marginBottom:3, fontSize:9 }}>MAX. DAUER (SEK)</label>
<input type="number" value={form.expire||3600} min={60} max={86400}
onChange={e=>setForm(p=>({...p,expire:e.target.value}))}
style={{ ...S.inp, fontSize:13 }}/>
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:8, marginTop:2 }}>
z.B. 3600 = max. 1 Stunde
</div>
</div>
</div>
)}
</div>
<div style={{ display:'flex', gap:8 }}> <div style={{ display:'flex', gap:8 }}>
<button onClick={save} disabled={busy} <button onClick={save} disabled={busy}
style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}> style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
@@ -810,11 +861,15 @@ function PushoverSettings({ toast }) {
</button> </button>
{hasSaved && ( {hasSaved && (
<button onClick={test} disabled={busy} <button onClick={test} disabled={busy}
style={{ ...S.btn('#ffe66d', true), padding:'0 14px', opacity:busy?0.5:1 }}>Test</button> style={{ ...S.btn('#ffe66d', true), padding:'0 10px', opacity:busy?0.5:1, fontSize:10 }}>Test</button>
)}
{hasSaved && emergency && (
<button onClick={testEmergency} disabled={busy}
style={{ ...S.btn('#ff6b9d', true), padding:'0 10px', opacity:busy?0.5:1, fontSize:10 }}>🚨 Test</button>
)} )}
{hasSaved && ( {hasSaved && (
<button onClick={clear} <button onClick={clear}
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}></button> style={{ ...S.btn('#ff6b9d', true), padding:'0 10px' }}></button>
)} )}
</div> </div>
</div> </div>
@@ -1499,6 +1554,15 @@ function UserManagement({ toast }) {
border:`1px solid ${u.role==='admin'?'rgba(255,230,109,0.3)':'rgba(78,205,196,0.3)'}`, border:`1px solid ${u.role==='admin'?'rgba(255,230,109,0.3)':'rgba(78,205,196,0.3)'}`,
borderRadius:4, padding:'1px 6px', borderRadius:4, padding:'1px 6px',
}}>{u.role}</span> }}>{u.role}</span>
{!!u.has_pushover && (
<span title="Pushover eingerichtet" style={{
marginLeft:4, fontSize:9, fontFamily:'monospace',
color:'rgba(255,107,157,0.8)',
background:'rgba(255,107,157,0.1)',
border:'1px solid rgba(255,107,157,0.25)',
borderRadius:4, padding:'1px 6px',
}}>🔔 Push</span>
)}
{u.last_active_at && ( {u.last_active_at && (
<span style={{ <span style={{
marginLeft:6, fontSize:9, fontFamily:'monospace', marginLeft:6, fontSize:9, fontFamily:'monospace',