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 ───────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) {
db.exec(`

View File

@@ -89,12 +89,18 @@ app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
const db = require('./db');
async function sendPushoverMsg(userKey, appToken, message) {
async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
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', {
method: 'POST',
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); }
}
@@ -110,13 +116,14 @@ async function runScheduler() {
try {
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
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
JOIN pushover_settings p ON p.user_id = s.user_id
WHERE s.sent = 0 AND s.scheduled_at <= ?
`).all(now);
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);
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
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 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;

View File

@@ -4,12 +4,18 @@ const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── Pushover Helper ───────────────────────────────────────────────────────────
async function sendPushover(userKey, appToken, title, message) {
async function sendPushover(userKey, appToken, title, message, opts = {}) {
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', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token: appToken, user: userKey, title, message }),
body: new URLSearchParams(params),
});
} 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 pushover = db.prepare('SELECT * FROM pushover_settings WHERE user_id = ?').get(recipId);
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 });
@@ -132,18 +139,19 @@ router.delete('/my-messages', authenticate, (req, res) => {
// ── Pushover Settings ─────────────────────────────────────────────────────────
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);
res.json(row || { user_key: '', app_token: '' });
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: '', retry: null, expire: null });
});
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' });
db.prepare(`
INSERT INTO pushover_settings (user_id, user_key, app_token)
VALUES (?, ?, ?)
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);
INSERT INTO pushover_settings (user_id, user_key, app_token, retry, expire)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET user_key=excluded.user_key, app_token=excluded.app_token,
retry=excluded.retry, expire=excluded.expire
`).run(req.user.id, user_key, app_token, retry || null, expire || null);
res.json({ ok: true });
});
@@ -155,7 +163,12 @@ router.delete('/pushover', authenticate, (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);
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 });
});

View File

@@ -757,30 +757,46 @@ function NachrichtenWidget({ unreadMsgs, setActive }) {
// ── Pushover Settings ─────────────────────────────────────────────────────────
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 [hasSaved, setHasSaved] = useState(false);
const [emergency,setEmergency]= useState(false);
useEffect(() => {
api('/tools/nachrichten/pushover')
.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);
setHasSaved(!!(v.user_key && v.app_token));
setEmergency(!!(d.retry && d.expire));
}).catch(()=>{});
}, []);
const save = async () => {
if (!form.user_key.trim() || !form.app_token.trim()) { toast('Beide Felder ausfüllen', 'error'); return; }
setBusy(true);
try { await api('/tools/nachrichten/pushover', { body: form }); toast('Gespeichert'); setHasSaved(true); }
catch(e) { toast(e.message, 'error'); }
try {
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);
};
const test = async () => {
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'); }
setBusy(false);
};
@@ -788,8 +804,8 @@ function PushoverSettings({ toast }) {
const clear = async () => {
try {
await api('/tools/nachrichten/pushover', { method:'DELETE' });
setForm({ user_key:'', app_token:'' });
setHasSaved(false);
setForm({ user_key:'', app_token:'', retry:'', expire:'' });
setHasSaved(false); setEmergency(false);
toast('Pushover entfernt');
} catch(e) { toast(e.message, 'error'); }
};
@@ -797,12 +813,47 @@ function PushoverSettings({ toast }) {
return (
<div>
<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>.
Du brauchst einen Account und eine eigene App (kostenlos).
</p>
<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}))} />
{/* 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 }}>
<button onClick={save} disabled={busy}
style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
@@ -810,11 +861,15 @@ function PushoverSettings({ toast }) {
</button>
{hasSaved && (
<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 && (
<button onClick={clear}
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}></button>
style={{ ...S.btn('#ff6b9d', true), padding:'0 10px' }}></button>
)}
</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)'}`,
borderRadius:4, padding:'1px 6px',
}}>{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 && (
<span style={{
marginLeft:6, fontSize:9, fontFamily:'monospace',