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