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:
ort
2026-08-15 13:48:01 -04:00
commit 66b8fc5d4d
42 changed files with 3567 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
const path = require('node:path');
const express = require('express');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const config = require('./config');
const SqliteSessionStore = require('./lib/sqliteSessionStore');
const csrfCheck = require('./middleware/csrf');
const authRoutes = require('./routes/auth');
const householdRoutes = require('./routes/household');
const { manageRouter: inviteManageRoutes, publicRouter: invitePublicRoutes } = require('./routes/invites');
const childrenRoutes = require('./routes/children');
const calendarRoutes = require('./routes/calendars');
const kioskRoutes = require('./routes/kiosk');
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '256kb' }));
app.use(session({
store: new SqliteSessionStore(),
name: 'kc.sid',
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: config.cookieSecure,
maxAge: 30 * 24 * 60 * 60 * 1000,
},
}));
app.use('/api', csrfCheck);
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false });
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/signup', authLimiter);
app.use('/api/invites', authLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/household/invites', inviteManageRoutes);
app.use('/api/household', householdRoutes);
app.use('/api/invites', invitePublicRoutes);
app.use('/api/children', childrenRoutes);
app.use('/api/calendars', calendarRoutes);
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.
app.get('/k/:token', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'kiosk.html'));
});
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use('/api', (req, res) => {
res.status(404).json({ error: 'Not found' });
});
module.exports = app;
+12
View File
@@ -0,0 +1,12 @@
const path = require('node:path');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
module.exports = {
port: parseInt(process.env.PORT || '3007', 10),
dataDir: DATA_DIR,
dbPath: path.join(DATA_DIR, 'kids-calendar.sqlite'),
sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-me',
cookieSecure: process.env.COOKIE_SECURE === 'true',
disablePublicSignup: process.env.DISABLE_PUBLIC_SIGNUP === 'true',
};
+14
View File
@@ -0,0 +1,14 @@
const fs = require('node:fs');
const { DatabaseSync } = require('node:sqlite');
const config = require('../config');
const { migrate } = require('./migrate');
fs.mkdirSync(config.dataDir, { recursive: true });
const db = new DatabaseSync(config.dbPath);
db.exec('PRAGMA foreign_keys = ON;');
db.exec('PRAGMA journal_mode = WAL;');
migrate(db);
module.exports = db;
+9
View File
@@ -0,0 +1,9 @@
const fs = require('node:fs');
const path = require('node:path');
function migrate(db) {
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
}
module.exports = { migrate };
+76
View File
@@ -0,0 +1,76 @@
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS parents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_parents_household ON parents(household_id);
CREATE TABLE IF NOT EXISTS household_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by_parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
used_at TEXT,
used_by_parent_id INTEGER REFERENCES parents(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_invites_household ON household_invites(household_id);
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kiosk_token TEXT NOT NULL UNIQUE,
active_calendar_id INTEGER REFERENCES calendars(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_children_household ON children(household_id);
CREATE TABLE IF NOT EXISTS calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id) ON DELETE CASCADE,
title TEXT NOT NULL,
week_start_date TEXT,
show_weekend INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_calendars_child ON calendars(child_id);
CREATE TABLE IF NOT EXISTS calendar_blocks (
calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
block_key TEXT NOT NULL CHECK (block_key IN ('morning','school','after','evening')),
label TEXT NOT NULL,
PRIMARY KEY (calendar_id, block_key)
);
CREATE TABLE IF NOT EXISTS calendar_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
day_of_week TEXT NOT NULL CHECK (day_of_week IN
('Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday')),
block_key TEXT NOT NULL CHECK (block_key IN ('morning','school','after','evening')),
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_tasks_calendar ON calendar_tasks(calendar_id, day_of_week, block_key);
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
+52
View File
@@ -0,0 +1,52 @@
const db = require('../db');
const { BLOCK_KEYS, ALL_DAYS } = require('./defaults');
const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?');
const getChildStmt = db.prepare('SELECT id, name FROM children WHERE id = ?');
const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?');
const getTasksStmt = db.prepare(
'SELECT id, day_of_week, block_key, text, done, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order, id'
);
// Shared shape consumed by the parent editor, the kiosk view, and the poll endpoint.
function hydrateCalendar(calendarId) {
const calendar = getCalendarStmt.get(calendarId);
if (!calendar) return null;
const child = getChildStmt.get(calendar.child_id);
const blockRows = getBlocksStmt.all(calendarId);
const blockLabels = Object.fromEntries(blockRows.map((b) => [b.block_key, b.label]));
const days = {};
ALL_DAYS.forEach((day) => {
days[day] = {};
BLOCK_KEYS.forEach((key) => {
days[day][key] = [];
});
});
getTasksStmt.all(calendarId).forEach((t) => {
if (!days[t.day_of_week]) return;
days[t.day_of_week][t.block_key].push({
id: t.id,
text: t.text,
done: !!t.done,
sortOrder: t.sort_order,
});
});
return {
id: calendar.id,
childId: calendar.child_id,
childName: child ? child.name : null,
title: calendar.title,
weekStartDate: calendar.week_start_date,
showWeekend: !!calendar.show_weekend,
updatedAt: calendar.updated_at,
createdAt: calendar.created_at,
blocks: BLOCK_KEYS.map((key) => ({ key, label: blockLabels[key] || key })),
days,
};
}
module.exports = { hydrateCalendar };
+52
View File
@@ -0,0 +1,52 @@
const db = require('../db');
const { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, ALL_DAYS } = require('./defaults');
const { hydrateCalendar } = require('./calendarHydrate');
const insertCalendarStmt = db.prepare(
'INSERT INTO calendars (child_id, title, week_start_date) VALUES (?, ?, ?)'
);
const setShowWeekendStmt = db.prepare('UPDATE calendars SET show_weekend = ? WHERE id = ?');
const insertBlockStmt = db.prepare(
'INSERT INTO calendar_blocks (calendar_id, block_key, label) VALUES (?, ?, ?)'
);
const insertTaskStmt = db.prepare(
'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)'
);
const getBlocksStmt = db.prepare('SELECT block_key, label FROM calendar_blocks WHERE calendar_id = ?');
const getTasksStmt = db.prepare(
'SELECT day_of_week, block_key, text, sort_order FROM calendar_tasks WHERE calendar_id = ? ORDER BY sort_order'
);
const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?');
function createBlankCalendar(childId, title, weekStartDate) {
const calendarId = Number(insertCalendarStmt.run(childId, title, weekStartDate).lastInsertRowid);
BLOCK_KEYS.forEach((key) => {
insertBlockStmt.run(calendarId, key, DEFAULT_BLOCK_LABELS[key]);
DEFAULT_TASKS[key].forEach((text, i) => {
ALL_DAYS.forEach((day) => {
insertTaskStmt.run(calendarId, day, key, text, i);
});
});
});
return hydrateCalendar(calendarId);
}
function createDuplicateCalendar(sourceCalendarId, childId, title, weekStartDate) {
const source = getCalendarStmt.get(sourceCalendarId);
const calendarId = Number(insertCalendarStmt.run(childId, title, weekStartDate).lastInsertRowid);
setShowWeekendStmt.run(source.show_weekend, calendarId);
getBlocksStmt.all(sourceCalendarId).forEach((b) => {
insertBlockStmt.run(calendarId, b.block_key, b.label);
});
// done intentionally not copied — new week starts unchecked (column default is 0)
getTasksStmt.all(sourceCalendarId).forEach((t) => {
insertTaskStmt.run(calendarId, t.day_of_week, t.block_key, t.text, t.sort_order);
});
return hydrateCalendar(calendarId);
}
module.exports = { createBlankCalendar, createDuplicateCalendar };
+21
View File
@@ -0,0 +1,21 @@
const BLOCK_KEYS = ['morning', 'school', 'after', 'evening'];
const DEFAULT_BLOCK_LABELS = {
morning: 'Morning',
school: 'School',
after: 'After School',
evening: 'Evening',
};
const DEFAULT_TASKS = {
morning: ['Wake up & get dressed', 'Brush teeth', 'Eat breakfast', 'Pack backpack'],
school: ['Reading time', 'Math practice', 'Lunch & recess'],
after: ['Snack', '20 min free play', 'Homework'],
evening: ['Dinner', 'Bath', "Pick tomorrow's clothes", 'Read a book', 'Lights out'],
};
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
const WEEKEND = ['Saturday', 'Sunday'];
const ALL_DAYS = [...WEEKDAYS, ...WEEKEND];
module.exports = { BLOCK_KEYS, DEFAULT_BLOCK_LABELS, DEFAULT_TASKS, WEEKDAYS, WEEKEND, ALL_DAYS };
+59
View File
@@ -0,0 +1,59 @@
const session = require('express-session');
const db = require('../db');
const insertStmt = db.prepare(
'INSERT INTO sessions (sid, sess, expires_at) VALUES (?, ?, ?) ' +
'ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires_at = excluded.expires_at'
);
const selectStmt = db.prepare('SELECT sess, expires_at FROM sessions WHERE sid = ?');
const deleteStmt = db.prepare('DELETE FROM sessions WHERE sid = ?');
const pruneStmt = db.prepare('DELETE FROM sessions WHERE expires_at < ?');
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
class SqliteSessionStore extends session.Store {
constructor() {
super();
this._pruneInterval = setInterval(() => {
pruneStmt.run(Date.now());
}, 60 * 60 * 1000);
this._pruneInterval.unref();
}
get(sid, cb) {
try {
const row = selectStmt.get(sid);
if (!row || row.expires_at < Date.now()) return cb(null, null);
cb(null, JSON.parse(row.sess));
} catch (err) {
cb(err);
}
}
set(sid, sessionData, cb) {
try {
const ttl = sessionData.cookie && sessionData.cookie.maxAge
? sessionData.cookie.maxAge
: DEFAULT_TTL_MS;
insertStmt.run(sid, JSON.stringify(sessionData), Date.now() + ttl);
cb(null);
} catch (err) {
cb(err);
}
}
destroy(sid, cb) {
try {
deleteStmt.run(sid);
cb(null);
} catch (err) {
cb(err);
}
}
touch(sid, sessionData, cb) {
this.set(sid, sessionData, cb || (() => {}));
}
}
module.exports = SqliteSessionStore;
+7
View File
@@ -0,0 +1,7 @@
const crypto = require('node:crypto');
function randomToken(bytes = 24) {
return crypto.randomBytes(bytes).toString('base64url');
}
module.exports = { randomToken };
+13
View File
@@ -0,0 +1,13 @@
const db = require('../db');
const touchStmt = db.prepare(
"UPDATE calendars SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
);
// Bumped inside the same synchronous call as any task/block write so the
// poll endpoint's since= comparison sees every mutation.
function touchCalendar(calendarId) {
touchStmt.run(calendarId);
}
module.exports = touchCalendar;
+15
View File
@@ -0,0 +1,15 @@
function isNonEmptyString(value, maxLength) {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
const LIMITS = {
NAME: 100,
EMAIL: 254,
PASSWORD: 200,
HOUSEHOLD_NAME: 100,
TITLE: 200,
LABEL: 60,
TASK_TEXT: 300,
};
module.exports = { isNonEmptyString, LIMITS };
+28
View File
@@ -0,0 +1,28 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Cookie-based sessions + same-origin fetch calls: block cross-site state
// changes by checking Origin (falling back to Referer) against the request's
// own Host. Simpler than a full CSRF-token library for a small LAN-scale app.
function csrfCheck(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const origin = req.get('origin');
const referer = req.get('referer');
const source = origin || referer;
if (!source) return res.status(403).json({ error: 'Missing origin' });
let sourceHost;
try {
sourceHost = new URL(source).host;
} catch {
return res.status(403).json({ error: 'Invalid origin' });
}
if (sourceHost !== req.get('host')) {
return res.status(403).json({ error: 'Cross-origin request blocked' });
}
next();
}
module.exports = csrfCheck;
+19
View File
@@ -0,0 +1,19 @@
const db = require('../db');
const getParentStmt = db.prepare('SELECT id, household_id, email, name FROM parents WHERE id = ?');
function requireAuth(req, res, next) {
const parentId = req.session && req.session.parentId;
if (!parentId) return res.status(401).json({ error: 'Not signed in' });
const parent = getParentStmt.get(parentId);
if (!parent) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Not signed in' });
}
req.parent = parent;
next();
}
module.exports = requireAuth;
+33
View File
@@ -0,0 +1,33 @@
const db = require('../db');
const getChildStmt = db.prepare('SELECT * FROM children WHERE id = ?');
const getCalendarStmt = db.prepare('SELECT * FROM calendars WHERE id = ?');
// Resource nesting differs per route (child vs. calendar vs. task-under-calendar),
// so ownership checks are exposed as helpers route handlers call directly,
// rather than a one-size-fits-all param middleware.
function loadOwnedChild(req, res, childId) {
const child = getChildStmt.get(childId);
if (!child || child.household_id !== req.parent.household_id) {
res.status(404).json({ error: 'Child not found' });
return null;
}
return child;
}
function loadOwnedCalendar(req, res, calendarId) {
const calendar = getCalendarStmt.get(calendarId);
if (!calendar) {
res.status(404).json({ error: 'Calendar not found' });
return null;
}
const child = getChildStmt.get(calendar.child_id);
if (!child || child.household_id !== req.parent.household_id) {
res.status(404).json({ error: 'Calendar not found' });
return null;
}
return calendar;
}
module.exports = { loadOwnedChild, loadOwnedCalendar };
+15
View File
@@ -0,0 +1,15 @@
const db = require('../db');
const getChildByTokenStmt = db.prepare('SELECT * FROM children WHERE kiosk_token = ?');
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);
if (!child) return res.status(404).json({ error: 'Invalid kiosk link' });
req.child = child;
next();
}
module.exports = resolveKiosk;
+84
View File
@@ -0,0 +1,84 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const config = require('../config');
const requireAuth = require('../middleware/requireAuth');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)');
const insertParentStmt = db.prepare(
'INSERT INTO parents (household_id, email, password_hash, name) VALUES (?, ?, ?, ?)'
);
const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?');
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
function publicParent(parent) {
return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id };
}
router.post('/signup', (req, res) => {
if (config.disablePublicSignup) {
return res.status(403).json({ error: 'New signups are disabled on this server' });
}
const { email, password, name, householdName } = 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 (householdName !== undefined && householdName !== '' && !isNonEmptyString(householdName, LIMITS.HOUSEHOLD_NAME)) {
return res.status(400).json({ error: 'Family name is too long' });
}
if (getParentByEmailStmt.get(email)) {
return res.status(409).json({ error: 'An account with that email already exists' });
}
const hhName = (typeof householdName === 'string' && householdName.trim())
? householdName.trim()
: `${name.trim()}'s Family`;
const householdId = insertHouseholdStmt.run(hhName).lastInsertRowid;
const passwordHash = bcrypt.hashSync(password, 12);
const parentId = insertParentStmt.run(householdId, email.toLowerCase(), passwordHash, name.trim()).lastInsertRowid;
req.session.parentId = Number(parentId);
res.status(201).json({ parent: publicParent({ id: parentId, email, name: name.trim(), household_id: householdId }) });
});
router.post('/login', (req, res) => {
const { email, password } = req.body || {};
if (!isNonEmptyString(email, LIMITS.EMAIL) || typeof password !== 'string' || password.length > LIMITS.PASSWORD) {
return res.status(400).json({ error: 'Email and password are required' });
}
const parent = getParentByEmailStmt.get(email.toLowerCase());
if (!parent || !bcrypt.compareSync(password, parent.password_hash)) {
return res.status(401).json({ error: 'Invalid email or password' });
}
req.session.parentId = parent.id;
res.json({ parent: publicParent(parent) });
});
router.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('kc.sid');
res.status(204).end();
});
});
router.get('/me', requireAuth, (req, res) => {
const household = getHouseholdStmt.get(req.parent.household_id);
res.json({ parent: publicParent(req.parent), household: { id: household.id, name: household.name } });
});
module.exports = router;
+164
View File
@@ -0,0 +1,164 @@
const express = require('express');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
const { loadOwnedCalendar } = require('../middleware/requireHousehold');
const { hydrateCalendar } = require('../lib/calendarHydrate');
const touchCalendar = require('../lib/touchCalendar');
const { BLOCK_KEYS, ALL_DAYS } = require('../lib/defaults');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
router.use(requireAuth);
const updateCalendarStmt = db.prepare(
'UPDATE calendars SET title = COALESCE(?, title), week_start_date = COALESCE(?, week_start_date), show_weekend = COALESCE(?, show_weekend) WHERE id = ?'
);
const deleteCalendarStmt = db.prepare('DELETE FROM calendars WHERE id = ?');
const upsertBlockStmt = db.prepare(
'INSERT INTO calendar_blocks (calendar_id, block_key, label) VALUES (?, ?, ?) ' +
'ON CONFLICT(calendar_id, block_key) DO UPDATE SET label = excluded.label'
);
const insertTaskStmt = db.prepare(
'INSERT INTO calendar_tasks (calendar_id, day_of_week, block_key, text, sort_order) VALUES (?, ?, ?, ?, ?)'
);
const maxSortOrderStmt = db.prepare(
'SELECT COALESCE(MAX(sort_order), -1) AS maxOrder FROM calendar_tasks WHERE calendar_id = ? AND day_of_week = ? AND block_key = ?'
);
const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?');
const updateTaskStmt = db.prepare(
'UPDATE calendar_tasks SET text = COALESCE(?, text), done = COALESCE(?, done), sort_order = COALESCE(?, sort_order), ' +
"updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
);
const deleteTaskStmt = db.prepare('DELETE FROM calendar_tasks WHERE id = ?');
const resetChecksStmt = db.prepare('UPDATE calendar_tasks SET done = 0 WHERE calendar_id = ?');
router.get('/:id', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
res.json({ calendar: hydrateCalendar(calendar.id) });
});
router.get('/:id/poll', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const since = req.query.since;
if (since && calendar.updated_at <= since) {
return res.json({ changed: false });
}
res.json({ changed: true, calendar: hydrateCalendar(calendar.id) });
});
router.patch('/:id', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const { title, weekStartDate, showWeekend } = req.body || {};
if (title !== undefined && !isNonEmptyString(title, LIMITS.TITLE)) {
return res.status(400).json({ error: 'Title cannot be empty' });
}
updateCalendarStmt.run(
title !== undefined ? title.trim() : null,
weekStartDate !== undefined ? weekStartDate : null,
showWeekend !== undefined ? (showWeekend ? 1 : 0) : null,
calendar.id
);
touchCalendar(calendar.id);
res.json({ calendar: hydrateCalendar(calendar.id) });
});
router.delete('/:id', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
deleteCalendarStmt.run(calendar.id);
res.status(204).end();
});
router.patch('/:id/blocks/:blockKey', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const { blockKey } = req.params;
if (!BLOCK_KEYS.includes(blockKey)) {
return res.status(400).json({ error: 'Unknown block' });
}
const { label } = req.body || {};
if (!isNonEmptyString(label, LIMITS.LABEL)) {
return res.status(400).json({ error: 'Label cannot be empty' });
}
upsertBlockStmt.run(calendar.id, blockKey, label.trim());
touchCalendar(calendar.id);
res.json({ calendar: hydrateCalendar(calendar.id) });
});
router.post('/:id/tasks', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const { dayOfWeek, blockKey, text } = req.body || {};
if (!ALL_DAYS.includes(dayOfWeek)) return res.status(400).json({ error: 'Invalid day' });
if (!BLOCK_KEYS.includes(blockKey)) return res.status(400).json({ error: 'Invalid block' });
if (!isNonEmptyString(text, LIMITS.TASK_TEXT)) return res.status(400).json({ error: 'Text is required' });
const nextOrder = maxSortOrderStmt.get(calendar.id, dayOfWeek, blockKey).maxOrder + 1;
const taskId = insertTaskStmt.run(calendar.id, dayOfWeek, blockKey, text.trim(), nextOrder).lastInsertRowid;
touchCalendar(calendar.id);
const task = getTaskStmt.get(taskId);
res.status(201).json({
task: { id: task.id, text: task.text, done: !!task.done, sortOrder: task.sort_order, dayOfWeek, blockKey },
});
});
router.patch('/:id/tasks/:taskId', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const task = getTaskStmt.get(req.params.taskId);
if (!task || task.calendar_id !== calendar.id) {
return res.status(404).json({ error: 'Task not found' });
}
const { text, done, sortOrder } = req.body || {};
if (text !== undefined && !isNonEmptyString(text, LIMITS.TASK_TEXT)) {
return res.status(400).json({ error: 'Text cannot be empty' });
}
updateTaskStmt.run(
text !== undefined ? text.trim() : null,
done !== undefined ? (done ? 1 : 0) : null,
sortOrder !== undefined ? sortOrder : null,
task.id
);
touchCalendar(calendar.id);
const updated = getTaskStmt.get(task.id);
res.json({
task: { id: updated.id, text: updated.text, done: !!updated.done, sortOrder: updated.sort_order },
});
});
router.delete('/:id/tasks/:taskId', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
const task = getTaskStmt.get(req.params.taskId);
if (!task || task.calendar_id !== calendar.id) {
return res.status(404).json({ error: 'Task not found' });
}
deleteTaskStmt.run(task.id);
touchCalendar(calendar.id);
res.status(204).end();
});
router.post('/:id/reset-checks', (req, res) => {
const calendar = loadOwnedCalendar(req, res, req.params.id);
if (!calendar) return;
resetChecksStmt.run(calendar.id);
touchCalendar(calendar.id);
res.json({ calendar: hydrateCalendar(calendar.id) });
});
module.exports = router;
+128
View File
@@ -0,0 +1,128 @@
const express = require('express');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
const { loadOwnedChild, loadOwnedCalendar } = require('../middleware/requireHousehold');
const { randomToken } = require('../lib/tokens');
const { createBlankCalendar, createDuplicateCalendar } = require('../lib/calendarSeed');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
router.use(requireAuth);
const listChildrenStmt = db.prepare('SELECT * FROM children WHERE household_id = ? ORDER BY created_at');
const insertChildStmt = db.prepare(
'INSERT INTO children (household_id, name, kiosk_token) VALUES (?, ?, ?)'
);
const updateChildNameStmt = db.prepare('UPDATE children SET name = ? WHERE id = ?');
const updateChildActiveCalendarStmt = db.prepare('UPDATE children SET active_calendar_id = ? WHERE id = ?');
const deleteChildStmt = db.prepare('DELETE FROM children WHERE id = ?');
const regenKioskTokenStmt = db.prepare('UPDATE children SET kiosk_token = ? WHERE id = ?');
const listCalendarsForChildStmt = db.prepare(
'SELECT id, title, week_start_date, show_weekend, updated_at, created_at FROM calendars WHERE child_id = ? ORDER BY created_at DESC'
);
function publicChild(child) {
return {
id: child.id,
name: child.name,
kioskToken: child.kiosk_token,
kioskPath: `/k/${child.kiosk_token}`,
activeCalendarId: child.active_calendar_id,
};
}
router.get('/', (req, res) => {
const children = listChildrenStmt.all(req.parent.household_id).map(publicChild);
res.json({ children });
});
router.post('/', (req, res) => {
const { name } = req.body || {};
if (!isNonEmptyString(name, LIMITS.NAME)) {
return res.status(400).json({ error: 'Name is required' });
}
const kioskToken = randomToken();
const id = insertChildStmt.run(req.parent.household_id, name.trim(), kioskToken).lastInsertRowid;
const child = { id: Number(id), name: name.trim(), kiosk_token: kioskToken, active_calendar_id: null };
res.status(201).json({ child: publicChild(child) });
});
router.get('/:id', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
res.json({ child: publicChild(child) });
});
router.patch('/:id', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
const { name, activeCalendarId } = req.body || {};
if (name !== undefined) {
if (!isNonEmptyString(name, LIMITS.NAME)) {
return res.status(400).json({ error: 'Name cannot be empty' });
}
updateChildNameStmt.run(name.trim(), child.id);
}
if (activeCalendarId !== undefined) {
if (activeCalendarId !== null) {
const cal = loadOwnedCalendar(req, res, activeCalendarId);
if (!cal) return;
if (cal.child_id !== child.id) {
return res.status(400).json({ error: 'That calendar does not belong to this child' });
}
}
updateChildActiveCalendarStmt.run(activeCalendarId, child.id);
}
const updated = loadOwnedChild(req, res, child.id);
res.json({ child: publicChild(updated) });
});
router.delete('/:id', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
deleteChildStmt.run(child.id);
res.status(204).end();
});
router.post('/:id/kiosk-token/regenerate', (req, res) => {
const child = loadOwnedChild(req, res, req.params.id);
if (!child) return;
const kioskToken = randomToken();
regenKioskTokenStmt.run(kioskToken, child.id);
res.json({ kioskToken, kioskPath: `/k/${kioskToken}` });
});
router.get('/:childId/calendars', (req, res) => {
const child = loadOwnedChild(req, res, req.params.childId);
if (!child) return;
const calendars = listCalendarsForChildStmt.all(child.id);
res.json({ calendars });
});
router.post('/:childId/calendars', (req, res) => {
const child = loadOwnedChild(req, res, req.params.childId);
if (!child) return;
const { title, weekStartDate, duplicateFromCalendarId } = req.body || {};
if (!isNonEmptyString(title, LIMITS.TITLE)) {
return res.status(400).json({ error: 'Title is required' });
}
let calendar;
if (duplicateFromCalendarId) {
const source = loadOwnedCalendar(req, res, duplicateFromCalendarId);
if (!source) return;
if (source.child_id !== child.id) {
return res.status(400).json({ error: 'That calendar does not belong to this child' });
}
calendar = createDuplicateCalendar(source.id, child.id, title.trim(), weekStartDate || null);
} else {
calendar = createBlankCalendar(child.id, title.trim(), weekStartDate || null);
}
res.status(201).json({ calendar });
});
module.exports = router;
+29
View File
@@ -0,0 +1,29 @@
const express = require('express');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
const router = express.Router();
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
const listParentsStmt = db.prepare('SELECT id, email, name, created_at FROM parents WHERE household_id = ? ORDER BY created_at');
const renameHouseholdStmt = db.prepare('UPDATE households SET name = ? WHERE id = ?');
router.use(requireAuth);
router.get('/', (req, res) => {
const household = getHouseholdStmt.get(req.parent.household_id);
const parents = listParentsStmt.all(req.parent.household_id);
res.json({ household: { id: household.id, name: household.name }, parents });
});
router.patch('/', (req, res) => {
const { name } = req.body || {};
if (!isNonEmptyString(name, LIMITS.HOUSEHOLD_NAME)) {
return res.status(400).json({ error: 'Name is required' });
}
renameHouseholdStmt.run(name.trim(), req.parent.household_id);
res.json({ household: { id: req.parent.household_id, name: name.trim() } });
});
module.exports = router;
+103
View File
@@ -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 };
+48
View File
@@ -0,0 +1,48 @@
const express = require('express');
const db = require('../db');
const resolveKiosk = require('../middleware/resolveKiosk');
const { hydrateCalendar } = require('../lib/calendarHydrate');
const touchCalendar = require('../lib/touchCalendar');
const router = express.Router({ mergeParams: true });
router.use(resolveKiosk);
const getTaskStmt = db.prepare('SELECT * FROM calendar_tasks WHERE id = ?');
const setDoneStmt = db.prepare(
"UPDATE calendar_tasks SET done = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
);
// Deliberately only two routes exist on this router: read the active calendar,
// 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('/calendar', (req, res) => {
if (!req.child.active_calendar_id) {
return res.json({ child: { id: req.child.id, name: req.child.name }, calendar: null });
}
const calendar = hydrateCalendar(req.child.active_calendar_id);
res.json({ child: { id: req.child.id, name: req.child.name }, calendar });
});
router.patch('/tasks/:taskId', (req, res) => {
if (!req.child.active_calendar_id) {
return res.status(404).json({ error: 'No active calendar for this child' });
}
const task = getTaskStmt.get(req.params.taskId);
if (!task || task.calendar_id !== req.child.active_calendar_id) {
return res.status(404).json({ error: 'Task not found' });
}
const { done } = req.body || {};
if (typeof done !== 'boolean') {
return res.status(400).json({ error: 'done must be a boolean' });
}
setDoneStmt.run(done ? 1 : 0, task.id);
touchCalendar(task.calendar_id);
res.json({ task: { id: task.id, done } });
});
module.exports = router;