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:
+62
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
function randomToken(bytes = 24) {
|
||||
return crypto.randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
module.exports = { randomToken };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user