feat: Web Push notifications
- VAPID-based push via web-push package - push_subscriptions table: endpoint + keys per user (upsert on conflict) - GET /api/push/vapid-key — public key for subscribe flow - POST/DELETE /api/push/subscribe — store/remove subscription - POST /api/push/test — manual test notification - Hourly scheduler: notifies users day before homework due + countdown expires - SW: push event handler shows notification; notificationclick opens /app - Settings: Push section with enable/disable/test buttons, auto-detects browser support and VAPID availability
This commit is contained in:
@@ -6,6 +6,7 @@ const path = require('path');
|
||||
const routes = require('./src/routes');
|
||||
const { router: filesRouter } = require('./src/files');
|
||||
const teacherRouter = require('./src/teacher');
|
||||
const { startNotificationScheduler } = require('./src/push');
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('FATAL: JWT_SECRET environment variable is not set.');
|
||||
@@ -51,4 +52,7 @@ app.get('/app', html('app.html'));
|
||||
app.get('/reset-password', html('reset-password.html'));
|
||||
app.get('/{*path}', html('index.html'));
|
||||
|
||||
app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`));
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`info1 läuft auf :${PORT}`);
|
||||
startNotificationScheduler();
|
||||
});
|
||||
|
||||
Generated
+79
-4
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "info1",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "info1",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
@@ -20,7 +20,8 @@
|
||||
"multer": "^2.1.1",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"resend": "^6.12.0"
|
||||
"resend": "^6.12.0",
|
||||
"web-push": "^3.6.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
@@ -119,6 +120,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -149,6 +159,18 @@
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -212,6 +234,12 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -869,6 +897,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -889,6 +926,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
@@ -1134,6 +1184,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
@@ -1956,6 +2012,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
|
||||
+10
-2
@@ -5,7 +5,14 @@
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"keywords": ["dashboard", "education", "class", "self-hosted", "express", "sqlite"],
|
||||
"keywords": [
|
||||
"dashboard",
|
||||
"education",
|
||||
"class",
|
||||
"self-hosted",
|
||||
"express",
|
||||
"sqlite"
|
||||
],
|
||||
"author": "lulinretrograde",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -25,6 +32,7 @@
|
||||
"multer": "^2.1.1",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"resend": "^6.12.0"
|
||||
"resend": "^6.12.0",
|
||||
"web-push": "^3.6.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1631,6 +1631,16 @@ footer {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="push-section">
|
||||
<div class="settings-label">Push-Benachrichtigungen</div>
|
||||
<div id="push-status" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geprüft…</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn-save" id="btn-push-enable" style="font-size:12px;padding:5px 12px;display:none" onclick="enablePush()">Aktivieren</button>
|
||||
<button class="btn-cancel" id="btn-push-disable" style="font-size:12px;padding:5px 12px;display:none" onclick="disablePush()">Deaktivieren</button>
|
||||
<button class="btn-cancel" id="btn-push-test" style="font-size:12px;padding:5px 12px;display:none" onclick="testPush()">Test senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Kalender-Export (iCal)</div>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.</p>
|
||||
@@ -2165,6 +2175,7 @@ function openSettings(){
|
||||
document.getElementById('settings-overlay').style.display='flex';
|
||||
document.getElementById('user-dropdown')?.classList.remove('open');
|
||||
load2FAStatus();
|
||||
initPushUI();
|
||||
if(currentRole==='teacher'){
|
||||
document.getElementById('teacher-subject-section').style.display='';
|
||||
loadMySubjects();
|
||||
@@ -2369,6 +2380,89 @@ async function deleteAccount(){
|
||||
else toast(r.error,'error');
|
||||
}
|
||||
|
||||
// ── WEB PUSH ──────────────────────────────────────────────────
|
||||
let _pushReg = null;
|
||||
|
||||
function urlBase64ToUint8Array(b64) {
|
||||
const pad = '='.repeat((4 - b64.length % 4) % 4);
|
||||
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return new Uint8Array([...raw].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
async function initPushUI() {
|
||||
const section = document.getElementById('push-section');
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
section.style.display = 'none'; return;
|
||||
}
|
||||
const { key } = await api('GET', 'push/vapid-key').catch(() => ({}));
|
||||
if (!key) { section.style.display = 'none'; return; }
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
_pushReg = reg;
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
updatePushUI(existing);
|
||||
}
|
||||
|
||||
function updatePushUI(sub) {
|
||||
const status = document.getElementById('push-status');
|
||||
const btnEn = document.getElementById('btn-push-enable');
|
||||
const btnDis = document.getElementById('btn-push-disable');
|
||||
const btnTest = document.getElementById('btn-push-test');
|
||||
if (sub) {
|
||||
status.textContent = 'Aktiv – du erhältst Benachrichtigungen bei fälligen Hausaufgaben und Countdowns.';
|
||||
status.style.color = 'var(--green)';
|
||||
btnEn.style.display = 'none';
|
||||
btnDis.style.display = '';
|
||||
btnTest.style.display = '';
|
||||
} else {
|
||||
status.textContent = 'Deaktiviert.';
|
||||
status.style.color = 'var(--text-muted)';
|
||||
btnEn.style.display = '';
|
||||
btnDis.style.display = 'none';
|
||||
btnTest.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function enablePush() {
|
||||
try {
|
||||
const { key } = await api('GET', 'push/vapid-key');
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') { toast('Benachrichtigungen verweigert', 'error'); return; }
|
||||
const reg = _pushReg || await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key),
|
||||
});
|
||||
const { endpoint, keys } = sub.toJSON();
|
||||
const r = await api('POST', 'push/subscribe', { endpoint, keys });
|
||||
if (r.error) { toast(r.error, 'error'); return; }
|
||||
updatePushUI(sub);
|
||||
toast('Push-Benachrichtigungen aktiviert ✓');
|
||||
} catch (e) {
|
||||
toast('Fehler: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePush() {
|
||||
try {
|
||||
const reg = _pushReg || await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await api('DELETE', 'push/subscribe', { endpoint: sub.endpoint });
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
updatePushUI(null);
|
||||
toast('Push-Benachrichtigungen deaktiviert');
|
||||
} catch (e) {
|
||||
toast('Fehler: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testPush() {
|
||||
await api('POST', 'push/test');
|
||||
toast('Test-Benachrichtigung gesendet');
|
||||
}
|
||||
|
||||
// ── ICAL EXPORT ───────────────────────────────────────────────
|
||||
async function getIcalUrl(regen=false){
|
||||
const r=await api('POST', regen?'ical-token/regenerate':'ical-token');
|
||||
|
||||
@@ -28,6 +28,32 @@ self.addEventListener('activate', e => {
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('push', e => {
|
||||
let data = { title: 'Klassenportal', body: '' };
|
||||
try { data = e.data ? e.data.json() : data; } catch {}
|
||||
e.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
tag: data.tag || 'kp',
|
||||
renotify: true,
|
||||
data: { url: data.url || '/app' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', e => {
|
||||
e.notification.close();
|
||||
const url = e.notification.data?.url || '/app';
|
||||
e.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
|
||||
const found = list.find(c => c.url.includes(self.location.origin));
|
||||
return found ? found.focus() : clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
const url = new URL(e.request.url);
|
||||
|
||||
|
||||
@@ -251,6 +251,18 @@ try { db.exec(`ALTER TABLE absences ADD COLUMN teacher_id INTEGER`); } catch {}
|
||||
try { db.exec(`ALTER TABLE users ADD COLUMN ical_token TEXT`); } catch {}
|
||||
try { db.exec(`ALTER TABLE grades ADD COLUMN created_at TEXT DEFAULT (datetime('now'))`); } catch {}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
const webpush = require('web-push');
|
||||
const db = require('./db');
|
||||
|
||||
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
|
||||
webpush.setVapidDetails(
|
||||
process.env.VAPID_SUBJECT || 'mailto:admin@localhost',
|
||||
process.env.VAPID_PUBLIC_KEY,
|
||||
process.env.VAPID_PRIVATE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
async function sendToUser(userId, payload) {
|
||||
const subs = db.prepare('SELECT id, endpoint, keys FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: JSON.parse(sub.keys) },
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToAll(payload) {
|
||||
const subs = db.prepare('SELECT id, user_id, endpoint, keys FROM push_subscriptions').all();
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: JSON.parse(sub.keys) },
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startNotificationScheduler() {
|
||||
if (!process.env.VAPID_PUBLIC_KEY) return;
|
||||
|
||||
setInterval(async () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = tomorrow.toISOString().slice(0, 10);
|
||||
|
||||
// Homework due tomorrow
|
||||
const hwUsers = db.prepare(`
|
||||
SELECT DISTINCT user_id FROM homework
|
||||
WHERE due_date = ? AND done = 0
|
||||
`).all(tomorrowStr);
|
||||
|
||||
for (const { user_id } of hwUsers) {
|
||||
const items = db.prepare("SELECT title, subject FROM homework WHERE user_id = ? AND due_date = ? AND done = 0").all(user_id, tomorrowStr);
|
||||
const body = items.length === 1
|
||||
? `${items[0].subject ? items[0].subject + ': ' : ''}${items[0].title}`
|
||||
: `${items.length} Aufgaben fällig`;
|
||||
await sendToUser(user_id, { title: '📚 Hausaufgaben morgen fällig', body, tag: 'hw-due' });
|
||||
}
|
||||
|
||||
// Countdowns expiring tomorrow
|
||||
const cdUsers = db.prepare(`
|
||||
SELECT DISTINCT user_id FROM countdowns WHERE target_date = ?
|
||||
`).all(tomorrowStr);
|
||||
|
||||
for (const { user_id } of cdUsers) {
|
||||
const items = db.prepare('SELECT title FROM countdowns WHERE user_id = ? AND target_date = ?').all(user_id, tomorrowStr);
|
||||
for (const c of items) {
|
||||
await sendToUser(user_id, { title: '⏳ Morgen: ' + c.title, body: 'Dein Countdown endet morgen', tag: 'cd-' + c.title });
|
||||
}
|
||||
}
|
||||
}, 60 * 60 * 1000); // check every hour
|
||||
}
|
||||
|
||||
module.exports = { sendToUser, sendToAll, startNotificationScheduler, vapidPublicKey: process.env.VAPID_PUBLIC_KEY };
|
||||
@@ -10,6 +10,7 @@ const db = require('./db');
|
||||
const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
|
||||
const { deleteUserFiles } = require('./files');
|
||||
const { sendVerifyMail, sendPasswordResetMail } = require('./mailer');
|
||||
const { sendToUser, vapidPublicKey } = require('./push');
|
||||
|
||||
const STORAGE_DIR = path.resolve(__dirname, '../storage');
|
||||
|
||||
@@ -892,6 +893,43 @@ router.get('/ical/:token.ics', (req, res) => {
|
||||
res.send(L.join('\r\n'));
|
||||
});
|
||||
|
||||
// --- WEB PUSH ---
|
||||
router.get('/push/vapid-key', (req, res) => {
|
||||
if (!vapidPublicKey) return res.status(503).json({ error: 'Push not configured' });
|
||||
res.json({ key: vapidPublicKey });
|
||||
});
|
||||
|
||||
router.post('/push/subscribe', requireAuth, (req, res) => {
|
||||
const { endpoint, keys } = req.body;
|
||||
if (!endpoint || !keys) return res.status(400).json({ error: 'endpoint and keys required' });
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO push_subscriptions (user_id, endpoint, keys)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, keys=excluded.keys
|
||||
`).run(req.user.id, endpoint, JSON.stringify(keys));
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Speichern fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/push/subscribe', requireAuth, (req, res) => {
|
||||
const { endpoint } = req.body;
|
||||
if (endpoint) {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
|
||||
} else {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(req.user.id);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Test notification (dev only convenience)
|
||||
router.post('/push/test', requireAuth, async (req, res) => {
|
||||
await sendToUser(req.user.id, { title: '🔔 Klassenportal', body: 'Push-Benachrichtigungen funktionieren!', tag: 'test' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- SESSION MANAGEMENT ---
|
||||
router.post('/me/logout-other', requireAuth, (req, res) => {
|
||||
const newVer = (req.user.token_version | 0) + 1;
|
||||
|
||||
Reference in New Issue
Block a user