Add self-hosted kids calendar app
Express + SQLite (node:sqlite, no native build step) family calendar: parent accounts with household invites, per-child calendars with save/duplicate/print, a token-gated read-only kiosk view for tablets, and polling to keep parent and kiosk views in sync. Defaults to port 3007.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const { randomToken } = require('../lib/tokens');
|
||||
const { isNonEmptyString, LIMITS } = require('../lib/validate');
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
const insertInviteStmt = db.prepare(
|
||||
'INSERT INTO household_invites (household_id, token, created_by_parent_id, expires_at) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const listInvitesStmt = db.prepare(
|
||||
'SELECT id, token, expires_at, used_at, created_at FROM household_invites WHERE household_id = ? ORDER BY created_at DESC'
|
||||
);
|
||||
const getInviteForRevokeStmt = db.prepare('SELECT * FROM household_invites WHERE id = ?');
|
||||
const deleteInviteStmt = db.prepare('DELETE FROM household_invites WHERE id = ?');
|
||||
const getInviteByTokenStmt = db.prepare('SELECT * FROM household_invites WHERE token = ?');
|
||||
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
|
||||
const markInviteUsedStmt = db.prepare('UPDATE household_invites SET used_at = ?, used_by_parent_id = ? WHERE id = ?');
|
||||
const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?');
|
||||
const insertParentStmt = db.prepare(
|
||||
'INSERT INTO parents (household_id, email, password_hash, name) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
function publicParent(parent) {
|
||||
return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id };
|
||||
}
|
||||
|
||||
// Session-authenticated: manage invites for the caller's own household.
|
||||
const manageRouter = express.Router();
|
||||
manageRouter.use(requireAuth);
|
||||
|
||||
manageRouter.post('/', (req, res) => {
|
||||
const token = randomToken();
|
||||
const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();
|
||||
const id = insertInviteStmt.run(req.parent.household_id, token, req.parent.id, expiresAt).lastInsertRowid;
|
||||
res.status(201).json({
|
||||
invite: { id: Number(id), token, expiresAt, joinPath: `/join.html?token=${token}` },
|
||||
});
|
||||
});
|
||||
|
||||
manageRouter.get('/', (req, res) => {
|
||||
const invites = listInvitesStmt.all(req.parent.household_id);
|
||||
res.json({ invites });
|
||||
});
|
||||
|
||||
manageRouter.delete('/:id', (req, res) => {
|
||||
const invite = getInviteForRevokeStmt.get(req.params.id);
|
||||
if (!invite || invite.household_id !== req.parent.household_id) {
|
||||
return res.status(404).json({ error: 'Invite not found' });
|
||||
}
|
||||
deleteInviteStmt.run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Public: view + accept an invite (no session yet — the whole point is to create one).
|
||||
const publicRouter = express.Router();
|
||||
|
||||
publicRouter.get('/:token', (req, res) => {
|
||||
const invite = getInviteByTokenStmt.get(req.params.token);
|
||||
if (!invite || invite.used_at || invite.expires_at < new Date().toISOString()) {
|
||||
return res.status(404).json({ error: 'This invite link is invalid or has expired' });
|
||||
}
|
||||
const household = getHouseholdStmt.get(invite.household_id);
|
||||
res.json({ householdName: household.name });
|
||||
});
|
||||
|
||||
publicRouter.post('/:token/accept', (req, res) => {
|
||||
const invite = getInviteByTokenStmt.get(req.params.token);
|
||||
if (!invite || invite.used_at || invite.expires_at < new Date().toISOString()) {
|
||||
return res.status(404).json({ error: 'This invite link is invalid or has expired' });
|
||||
}
|
||||
|
||||
const { email, password, name } = req.body || {};
|
||||
if (!isNonEmptyString(email, LIMITS.EMAIL) || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ error: 'A valid email is required' });
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 8 || password.length > LIMITS.PASSWORD) {
|
||||
return res.status(400).json({ error: `Password must be 8-${LIMITS.PASSWORD} characters` });
|
||||
}
|
||||
if (!isNonEmptyString(name, LIMITS.NAME)) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
if (getParentByEmailStmt.get(email.toLowerCase())) {
|
||||
return res.status(409).json({ error: 'An account with that email already exists' });
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, 12);
|
||||
const parentId = insertParentStmt.run(
|
||||
invite.household_id, email.toLowerCase(), passwordHash, name.trim()
|
||||
).lastInsertRowid;
|
||||
|
||||
markInviteUsedStmt.run(new Date().toISOString(), Number(parentId), invite.id);
|
||||
|
||||
req.session.parentId = Number(parentId);
|
||||
res.status(201).json({
|
||||
parent: publicParent({ id: parentId, email, name: name.trim(), household_id: invite.household_id }),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = { manageRouter, publicRouter };
|
||||
Reference in New Issue
Block a user