diff --git a/backend/src/db.js b/backend/src/db.js index adb8e1f..26ba3f3 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -313,6 +313,20 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='boa `); } +// ── Push-Zeitplaner ─────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) { + db.exec(` + CREATE TABLE push_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message TEXT NOT NULL, + scheduled_at DATETIME NOT NULL, + sent INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT NULL + ) + `); +} + // ── Link-Liste ──────────────────────────────────────────────────────────────── if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) { db.exec(` diff --git a/backend/src/index.js b/backend/src/index.js index 8ccfcdb..7d941c7 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -68,3 +68,33 @@ app.use(express.static(PUBLIC)); app.get('*', (_req, res) => res.sendFile(path.join(PUBLIC, 'index.html'))); 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) { + try { + 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 }), + }); + } catch(e) { console.error('Pushover Fehler:', e.message); } +} + +setInterval(async () => { + try { + const due = db.prepare(` + SELECT s.*, p.user_key, p.app_token + FROM push_schedules s + JOIN pushover_settings p ON p.user_id = s.user_id + WHERE s.sent = 0 AND s.scheduled_at <= datetime('now','localtime') + `).all(); + + for (const row of due) { + await sendPushoverMsg(row.user_key, row.app_token, row.message); + db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id); + console.log(`✓ Push gesendet an user ${row.user_id}: ${row.message}`); + } + } catch(e) { console.error('Push-Job Fehler:', e.message); } +}, 60 * 1000); // jede Minute diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index 61d7e10..d492d43 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -206,4 +206,38 @@ router.post('/board/:id/promote', authenticate, (req, res) => { res.json(updated); }); +// ── Push-Zeitplaner ─────────────────────────────────────────────────────────── +router.get('/push-schedules', authenticate, (req, res) => { + const rows = db.prepare(` + SELECT * FROM push_schedules + WHERE user_id=? AND sent=0 AND scheduled_at > datetime('now','localtime') + ORDER BY scheduled_at ASC + `).all(uid(req)); + res.json(rows); +}); + +router.post('/push-schedules', authenticate, (req, res) => { + const { message, scheduled_at } = req.body; + if (!message?.trim() || !scheduled_at) return res.status(400).json({ error: 'Nachricht und Zeitpunkt erforderlich' }); + const r = db.prepare(` + INSERT INTO push_schedules (user_id, message, scheduled_at, created_at) + VALUES (?, ?, ?, datetime('now','localtime')) + `).run(uid(req), message.trim(), scheduled_at); + res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(r.lastInsertRowid)); +}); + +router.put('/push-schedules/:id', authenticate, (req, res) => { + const row = db.prepare('SELECT * FROM push_schedules WHERE id=? AND user_id=?').get(req.params.id, uid(req)); + if (!row) return res.status(404).json({ error: 'Nicht gefunden' }); + const { message, scheduled_at } = req.body; + db.prepare('UPDATE push_schedules SET message=?, scheduled_at=?, sent=0 WHERE id=?') + .run(message ?? row.message, scheduled_at ?? row.scheduled_at, row.id); + res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(row.id)); +}); + +router.delete('/push-schedules/:id', authenticate, (req, res) => { + db.prepare('DELETE FROM push_schedules WHERE id=? AND user_id=?').run(req.params.id, uid(req)); + res.json({ ok: true }); +}); + module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 78dc7b2..8a32dbc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -990,6 +990,131 @@ function BoardModal({ user, toast, onClose, onRead }) { } +// ── Push-Zeitplaner Widget ──────────────────────────────────────────────────── +function PushScheduler({ toast }) { + const [isOpen, setIsOpen] = useState(false); + const [schedules, setSchedules] = useState([]); + const [msg, setMsg] = useState(''); + const [dt, setDt] = useState(''); + const [editId, setEditId] = useState(null); + const [editMsg, setEditMsg] = useState(''); + const [editDt, setEditDt] = useState(''); + const [hasPushover,setHasPushover]= useState(null); // null=unbekannt + + useEffect(() => { + if (!isOpen) return; + api('/dashboard/push-schedules').then(setSchedules).catch(()=>{}); + if (hasPushover === null) + api('/tools/nachrichten/pushover').then(d=>setHasPushover(!!(d.user_key&&d.app_token))).catch(()=>{}); + }, [isOpen]); + + // Vorauswahl: jetzt + 1 Stunde + const defaultDt = () => { + const d = new Date(Date.now() + 60*60*1000); + return d.toISOString().slice(0,16); + }; + + const fmtDt = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?s:d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); }; + + const add = async () => { + if (!msg.trim()) { toast('Nachricht eingeben','error'); return; } + if (!dt) { toast('Datum und Uhrzeit wählen','error'); return; } + try { + const r = await api('/dashboard/push-schedules',{body:{message:msg.trim(),scheduled_at:dt}}); + setSchedules(p=>[...p,r].sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at))); + setMsg(''); setDt(''); toast('Erinnerung gespeichert ✓'); + } catch(e){ toast(e.message,'error'); } + }; + + const del = async id => { + try { await api(`/dashboard/push-schedules/${id}`,{method:'DELETE'}); setSchedules(p=>p.filter(s=>s.id!==id)); } + catch(e){ toast(e.message,'error'); } + }; + + const saveEdit = async () => { + try { + const r = await api(`/dashboard/push-schedules/${editId}`,{method:'PUT',body:{message:editMsg,scheduled_at:editDt}}); + setSchedules(p=>p.map(s=>s.id===editId?r:s).sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at))); + setEditId(null); toast('Gespeichert ✓'); + } catch(e){ toast(e.message,'error'); } + }; + + return ( +
+ + + {isOpen && ( +
+ {hasPushover===false && ( +
+ ⚠ Kein Pushover eingerichtet. Bitte erst unter Einstellungen → Profil → Pushover konfigurieren. +
+ )} + + {/* Neue Erinnerung */} +
+ setMsg(e.target.value)} + placeholder="Nachricht…" style={{...S.inp,marginBottom:6,fontSize:13}} + onKeyDown={e=>e.key==='Enter'&&add()}/> +
+ setDt(e.target.value)} + style={{...S.inp,flex:1,fontSize:12,colorScheme:'dark'}}/> + +
+
+ + {/* Liste */} + {schedules.length===0 ? ( +
Keine aktiven Erinnerungen.
+ ) : schedules.map(s=>( +
+ {editId===s.id ? ( +
+ setEditMsg(e.target.value)} + style={{...S.inp,marginBottom:6,fontSize:13}} autoFocus/> +
+ setEditDt(e.target.value)} + style={{...S.inp,flex:1,fontSize:12,colorScheme:'dark'}}/> + + +
+
+ ) : ( +
+
+
{s.message}
+
+ 🕐 {fmtDt(s.scheduled_at)} +
+
+ + +
+ )} +
+ ))} +
+ )} +
+ ); +} + function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, user }) { const [now, setNow] = useState(new Date()); const [showBoard, setShowBoard] = useState(false); @@ -1013,6 +1138,7 @@ function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, unre fontFamily:'monospace', marginTop:2, flexShrink:0 }}>↺ +