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.
104 lines
4.3 KiB
JavaScript
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 };
|