Files
KCal/src/routes/invites.js
T
ort 66b8fc5d4d 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.
2026-08-15 13:48:01 -04:00

104 lines
4.3 KiB
JavaScript

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 };