Add Web Push notifications, PWA support, and full-screen tablet kiosk
Parents get a real push notification when a kid checks off a task (false->true transitions only, fire-and-forget, degrades gracefully with no VAPID keys configured). Dashboard is a fully installable iOS/Android PWA; each child's kiosk link gets its own dynamic per-token manifest so "Add to Home Screen" opens straight into their board in standalone mode. Kiosk view is reworked for tablets: safe-area-aware full-bleed layout, the whole task row is now tappable (previously only the 24px checkbox was, well under Apple's touch-target minimum), and app icons are generated by a small dependency-free PNG encoder (no image tooling available in this environment). Push requires real HTTPS (iOS Safari won't allow it otherwise) - README and UNRAID.md cover VAPID setup and the HTTPS prerequisite.
This commit is contained in:
+26
-3
@@ -1,4 +1,5 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
@@ -12,6 +13,8 @@ const { manageRouter: inviteManageRoutes, publicRouter: invitePublicRoutes } = r
|
||||
const childrenRoutes = require('./routes/children');
|
||||
const calendarRoutes = require('./routes/calendars');
|
||||
const kioskRoutes = require('./routes/kiosk');
|
||||
const pushRoutes = require('./routes/push');
|
||||
const { getChildByToken } = require('./middleware/resolveKiosk');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -45,12 +48,32 @@ app.use('/api/household', householdRoutes);
|
||||
app.use('/api/invites', invitePublicRoutes);
|
||||
app.use('/api/children', childrenRoutes);
|
||||
app.use('/api/calendars', calendarRoutes);
|
||||
app.use('/api/push', pushRoutes);
|
||||
app.use('/api/kiosk/:token', kioskRoutes);
|
||||
|
||||
// Short, bookmarkable kiosk URL for the tablet: redirect to the static kiosk page,
|
||||
// which reads the token back out of the path client-side.
|
||||
// Short, bookmarkable kiosk URL for the tablet. Templated (not sendFile) so
|
||||
// each child gets a manifest <link>/apple-touch-icon/title pointing at their
|
||||
// own token — must be present in the initial HTML, since WebKit's support
|
||||
// for post-parse-injected manifest links is inconsistent across iOS versions.
|
||||
const kioskHtmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'public', 'kiosk.html'), 'utf8');
|
||||
|
||||
function escapeHtmlAttr(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
app.get('/k/:token', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'kiosk.html'));
|
||||
const child = getChildByToken(req.params.token);
|
||||
const childName = child ? child.name : 'Kids Calendar';
|
||||
const title = escapeHtmlAttr(childName);
|
||||
|
||||
const headTags = [
|
||||
`<link rel="manifest" href="/api/kiosk/${encodeURIComponent(req.params.token)}/manifest.webmanifest">`,
|
||||
`<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">`,
|
||||
`<meta name="apple-mobile-web-app-title" content="${title}">`,
|
||||
].join('\n');
|
||||
|
||||
const html = kioskHtmlTemplate.replace('<!--KIOSK_HEAD_TAGS-->', headTags);
|
||||
res.type('html').send(html);
|
||||
});
|
||||
|
||||
// dashboard.js itself bounces to /login.html if the session check fails,
|
||||
|
||||
@@ -9,4 +9,10 @@ module.exports = {
|
||||
sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-me',
|
||||
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||
disablePublicSignup: process.env.DISABLE_PUBLIC_SIGNUP === 'true',
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || null,
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || null,
|
||||
vapidSubject: process.env.VAPID_SUBJECT || null,
|
||||
get pushConfigured() {
|
||||
return Boolean(this.vapidPublicKey && this.vapidPrivateKey && this.vapidSubject);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,6 +68,18 @@ CREATE TABLE IF NOT EXISTS calendar_tasks (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_calendar ON calendar_tasks(calendar_id, day_of_week, block_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subs_parent ON push_subscriptions(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const webpush = require('web-push');
|
||||
const db = require('../db');
|
||||
const config = require('../config');
|
||||
|
||||
if (config.pushConfigured) {
|
||||
webpush.setVapidDetails(config.vapidSubject, config.vapidPublicKey, config.vapidPrivateKey);
|
||||
}
|
||||
|
||||
const listSubscriptionsForHouseholdStmt = db.prepare(`
|
||||
SELECT ps.id, ps.endpoint, ps.p256dh, ps.auth
|
||||
FROM push_subscriptions ps
|
||||
JOIN parents p ON p.id = ps.parent_id
|
||||
WHERE p.household_id = ?
|
||||
`);
|
||||
const deleteSubscriptionStmt = db.prepare('DELETE FROM push_subscriptions WHERE id = ?');
|
||||
|
||||
async function notifyHouseholdParents(householdId, payload) {
|
||||
if (!config.pushConfigured) return;
|
||||
|
||||
const subs = listSubscriptionsForHouseholdStmt.all(householdId);
|
||||
if (subs.length === 0) return;
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subs.map((sub) =>
|
||||
webpush
|
||||
.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
body,
|
||||
{ TTL: 3600 }
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.statusCode === 404 || err.statusCode === 410) {
|
||||
deleteSubscriptionStmt.run(sub.id);
|
||||
} else {
|
||||
console.error('[push] send failed', sub.id, err.statusCode, err.message);
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { notifyHouseholdParents };
|
||||
@@ -2,14 +2,17 @@ const db = require('../db');
|
||||
|
||||
const getChildByTokenStmt = db.prepare('SELECT * FROM children WHERE kiosk_token = ?');
|
||||
|
||||
function getChildByToken(token) {
|
||||
if (typeof token !== 'string' || !token) return null;
|
||||
return getChildByTokenStmt.get(token) || null;
|
||||
}
|
||||
|
||||
function resolveKiosk(req, res, next) {
|
||||
if (typeof req.params.token !== 'string' || !req.params.token) {
|
||||
return res.status(404).json({ error: 'Invalid kiosk link' });
|
||||
}
|
||||
const child = getChildByTokenStmt.get(req.params.token);
|
||||
const child = getChildByToken(req.params.token);
|
||||
if (!child) return res.status(404).json({ error: 'Invalid kiosk link' });
|
||||
req.child = child;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = resolveKiosk;
|
||||
module.exports.getChildByToken = getChildByToken;
|
||||
|
||||
@@ -3,6 +3,7 @@ const db = require('../db');
|
||||
const resolveKiosk = require('../middleware/resolveKiosk');
|
||||
const { hydrateCalendar } = require('../lib/calendarHydrate');
|
||||
const touchCalendar = require('../lib/touchCalendar');
|
||||
const { notifyHouseholdParents } = require('../lib/push');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
router.use(resolveKiosk);
|
||||
@@ -16,6 +17,23 @@ const setDoneStmt = db.prepare(
|
||||
// and toggle a task's `done` flag. No create/delete/text-edit/label routes are
|
||||
// wired up here at all — the access boundary is what routes exist, not UI hiding.
|
||||
|
||||
router.get('/manifest.webmanifest', (req, res) => {
|
||||
res.type('application/manifest+json').json({
|
||||
name: `${req.child.name}'s Calendar`,
|
||||
short_name: req.child.name,
|
||||
start_url: `/k/${req.params.token}`,
|
||||
scope: `/k/${req.params.token}`,
|
||||
display: 'standalone',
|
||||
background_color: '#F1F5FB',
|
||||
theme_color: '#F1F5FB',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/calendar', (req, res) => {
|
||||
if (!req.child.active_calendar_id) {
|
||||
return res.json({ child: { id: req.child.id, name: req.child.name }, calendar: null });
|
||||
@@ -39,10 +57,22 @@ router.patch('/tasks/:taskId', (req, res) => {
|
||||
return res.status(400).json({ error: 'done must be a boolean' });
|
||||
}
|
||||
|
||||
const wasDone = !!task.done;
|
||||
|
||||
setDoneStmt.run(done ? 1 : 0, task.id);
|
||||
touchCalendar(task.calendar_id);
|
||||
|
||||
res.json({ task: { id: task.id, done } });
|
||||
|
||||
// Fire-and-forget: notify parents on the false->true transition only,
|
||||
// never blocking the kiosk's response on push delivery.
|
||||
if (done === true && !wasDone) {
|
||||
notifyHouseholdParents(req.child.household_id, {
|
||||
title: `${req.child.name} checked something off`,
|
||||
body: task.text,
|
||||
url: `/calendar.html?calendarId=${task.calendar_id}`,
|
||||
}).catch((err) => console.error('[push] notify failed', err));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const config = require('../config');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const upsertSubscriptionStmt = db.prepare(`
|
||||
INSERT INTO push_subscriptions (parent_id, endpoint, p256dh, auth, user_agent, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
parent_id = excluded.parent_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`);
|
||||
const deleteOwnSubscriptionStmt = db.prepare(
|
||||
'DELETE FROM push_subscriptions WHERE endpoint = ? AND parent_id = ?'
|
||||
);
|
||||
|
||||
router.get('/vapid-public-key', (req, res) => {
|
||||
if (!config.pushConfigured) return res.json({ enabled: false });
|
||||
res.json({ enabled: true, publicKey: config.vapidPublicKey });
|
||||
});
|
||||
|
||||
router.post('/subscribe', (req, res) => {
|
||||
const { subscription } = req.body || {};
|
||||
const endpoint = subscription && subscription.endpoint;
|
||||
const keys = subscription && subscription.keys;
|
||||
|
||||
if (typeof endpoint !== 'string' || !endpoint || !keys || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid subscription' });
|
||||
}
|
||||
|
||||
upsertSubscriptionStmt.run(req.parent.id, endpoint, keys.p256dh, keys.auth, req.get('user-agent') || null);
|
||||
res.status(201).json({ subscribed: true });
|
||||
});
|
||||
|
||||
router.delete('/subscribe', (req, res) => {
|
||||
const { endpoint } = req.body || {};
|
||||
if (typeof endpoint !== 'string' || !endpoint) {
|
||||
return res.status(400).json({ error: 'endpoint is required' });
|
||||
}
|
||||
deleteOwnSubscriptionStmt.run(endpoint, req.parent.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user