diff --git a/backend/src/models/migrations/016_guardian_partners.sql b/backend/src/models/migrations/016_guardian_partners.sql new file mode 100644 index 0000000..be02849 --- /dev/null +++ b/backend/src/models/migrations/016_guardian_partners.sql @@ -0,0 +1,16 @@ +-- 016_guardian_partners.sql +-- Partner/spouse relationship between guardians. +-- Partners share the same child alias list (both can manage it) and can +-- respond to events on behalf of each other within shared user groups. + +CREATE TABLE IF NOT EXISTS guardian_partners ( + id SERIAL PRIMARY KEY, + user_id_1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id_2 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id_1, user_id_2), + CHECK (user_id_1 < user_id_2) +); + +CREATE INDEX IF NOT EXISTS idx_guardian_partners_user1 ON guardian_partners(user_id_1); +CREATE INDEX IF NOT EXISTS idx_guardian_partners_user2 ON guardian_partners(user_id_2); diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 7b8c279..7de9fa7 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -48,6 +48,14 @@ async function postEventNotification(schema, eventId, actorId) { // ── Helpers ─────────────────────────────────────────────────────────────────── +async function getPartnerId(schema, userId) { + const row = await queryOne(schema, + 'SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', + [userId] + ); + return row?.partner_id || null; +} + async function isToolManagerFn(schema, user) { if (user.role === 'admin' || user.role === 'manager') return true; const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'"); @@ -73,7 +81,25 @@ async function canViewEvent(schema, event, userId, isToolManager) { JOIN guardian_aliases ga ON ga.id=agm.alias_id WHERE eug.event_id=$1 AND ga.guardian_id=$2 `, [event.id, userId]); - return !!aliasAssigned; + if (aliasAssigned) return true; + // Allow if partner is assigned to the event (directly or via alias) + const partnerId = await getPartnerId(schema, userId); + if (partnerId) { + const partnerAssigned = await queryOne(schema, ` + SELECT 1 FROM event_user_groups eug + JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id + WHERE eug.event_id=$1 AND ugm.user_id=$2 + `, [event.id, partnerId]); + if (partnerAssigned) return true; + const partnerAliasAssigned = await queryOne(schema, ` + SELECT 1 FROM event_user_groups eug + JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id + JOIN guardian_aliases ga ON ga.id=agm.alias_id + WHERE eug.event_id=$1 AND ga.guardian_id=$2 + `, [event.id, partnerId]); + if (partnerAliasAssigned) return true; + } + return false; } async function enrichEvent(schema, event) { @@ -243,6 +269,7 @@ router.get('/:id', authMiddleware, async (req, res) => { const itm = await isToolManagerFn(req.schema, req.user); if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' }); await enrichEvent(req.schema, event); + const partnerId = await getPartnerId(req.schema, req.user.id); const isMember = !itm && !!( (await queryOne(req.schema, ` SELECT 1 FROM event_user_groups eug @@ -257,6 +284,22 @@ router.get('/:id', authMiddleware, async (req, res) => { JOIN guardian_aliases ga ON ga.id=agm.alias_id WHERE eug.event_id=$1 AND ga.guardian_id=$2 `, [event.id, req.user.id])) + || + // Partner is assigned to this event (user group or alias) + (partnerId && !!( + (await queryOne(req.schema, ` + SELECT 1 FROM event_user_groups eug + JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id + WHERE eug.event_id=$1 AND ugm.user_id=$2 + `, [event.id, partnerId])) + || + (await queryOne(req.schema, ` + SELECT 1 FROM event_user_groups eug + JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id + JOIN guardian_aliases ga ON ga.id=agm.alias_id + WHERE eug.event_id=$1 AND ga.guardian_id=$2 + `, [event.id, partnerId])) + )) ); if (event.track_availability && (itm || isMember)) { // User responses @@ -274,7 +317,13 @@ router.get('/:id', authMiddleware, async (req, res) => { // For non-tool-managers: mask notes on entries that don't belong to them or their aliases if (!itm) { const myAliasIds = new Set( - (await query(req.schema, 'SELECT id FROM guardian_aliases WHERE guardian_id=$1', [req.user.id])).map(r => r.id) + (await query(req.schema, + `SELECT id FROM guardian_aliases WHERE guardian_id=$1 + OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1 + )`, + [req.user.id])).map(r => r.id) ); event.availability = event.availability.map(r => { const isOwn = !r.is_alias && r.user_id === req.user.id; @@ -318,12 +367,22 @@ router.get('/:id', authMiddleware, async (req, res) => { const guardiansRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_guardians_group_id'"); const guardiansGroupId = parseInt(guardiansRow?.value); event.in_guardians_group = !!(guardiansGroupId && event.user_groups?.some(g => g.id === guardiansGroupId) && - (await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id]))); + ( + (await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id])) + || + (partnerId && await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, partnerId])) + )); - // Return current user's aliases for the responder dropdown (Guardian Only) + // Return current user's aliases (and partner's) for the responder dropdown (Guardian Only) if (event.has_players_group) { event.my_aliases = await query(req.schema, - 'SELECT id,first_name,last_name,avatar FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name', + `SELECT id,first_name,last_name,avatar FROM guardian_aliases + WHERE guardian_id=$1 + OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1 + ) + ORDER BY first_name,last_name`, [req.user.id] ); } @@ -638,20 +697,28 @@ router.put('/:id/availability', authMiddleware, async (req, res) => { const trimmedNote = note ? String(note).trim().slice(0, 20) : null; if (aliasId) { - // Alias response (Guardian Only mode) — verify alias belongs to current user - const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]); + // Alias response (Guardian Only mode) — verify alias belongs to current user or their partner + const alias = await queryOne(req.schema, + `SELECT id FROM guardian_aliases WHERE id=$1 AND ( + guardian_id=$2 OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2 + ) + )`, + [aliasId, req.user.id]); if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' }); await exec(req.schema, ` INSERT INTO event_alias_availability (event_id,alias_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW()) ON CONFLICT (event_id,alias_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW() `, [event.id, aliasId, response, trimmedNote]); } else { - // Regular user response + // Regular user response — also allowed if partner is in the event's group const itm = await isToolManagerFn(req.schema, req.user); + const avPartner = await getPartnerId(req.schema, req.user.id); const inGroup = await queryOne(req.schema, ` SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id - WHERE eug.event_id=$1 AND ugm.user_id=$2 - `, [event.id, req.user.id]); + WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3) + `, [event.id, req.user.id, avPartner || -1]); if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' }); await exec(req.schema, ` INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW()) @@ -676,7 +743,14 @@ router.delete('/:id/availability', authMiddleware, async (req, res) => { try { const { aliasId } = req.query; if (aliasId) { - const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]); + const alias = await queryOne(req.schema, + `SELECT id FROM guardian_aliases WHERE id=$1 AND ( + guardian_id=$2 OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2 + ) + )`, + [aliasId, req.user.id]); if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' }); await exec(req.schema, 'DELETE FROM event_alias_availability WHERE event_id=$1 AND alias_id=$2', [req.params.id, aliasId]); } else { @@ -692,14 +766,15 @@ router.post('/me/bulk-availability', authMiddleware, async (req, res) => { try { let saved = 0; const itm = await isToolManagerFn(req.schema, req.user); + const bulkPartnerId = await getPartnerId(req.schema, req.user.id); for (const { eventId, response } of responses) { if (!['going','maybe','not_going'].includes(response)) continue; const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]); if (!event || !event.track_availability) continue; const inGroup = await queryOne(req.schema, ` SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id - WHERE eug.event_id=$1 AND ugm.user_id=$2 - `, [eventId, req.user.id]); + WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3) + `, [eventId, req.user.id, bulkPartnerId || -1]); if (!inGroup && !itm) continue; await exec(req.schema, ` INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW()) diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index a31b1a5..d4a4552 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -451,11 +451,58 @@ router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, re } catch (e) { res.status(500).json({ error: e.message }); } }); -// List current user's aliases +// Get current user's partner (spouse/partner relationship) +router.get('/me/partner', authMiddleware, async (req, res) => { + try { + const partner = await queryOne(req.schema, + `SELECT u.id, u.name, u.display_name, u.avatar + FROM guardian_partners gp + JOIN users u ON u.id = CASE WHEN gp.user_id_1=$1 THEN gp.user_id_2 ELSE gp.user_id_1 END + WHERE gp.user_id_1=$1 OR gp.user_id_2=$1`, + [req.user.id] + ); + res.json({ partner: partner || null }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Set partner (replaces any existing partnership for this user) +router.post('/me/partner', authMiddleware, async (req, res) => { + const userId = req.user.id; + const partnerId = parseInt(req.body.partnerId); + if (!partnerId || partnerId === userId) return res.status(400).json({ error: 'Invalid partner' }); + const uid1 = Math.min(userId, partnerId); + const uid2 = Math.max(userId, partnerId); + try { + await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [userId]); + await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2) VALUES ($1,$2)', [uid1, uid2]); + const partner = await queryOne(req.schema, + 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', + [partnerId] + ); + res.json({ partner }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Remove partner +router.delete('/me/partner', authMiddleware, async (req, res) => { + try { + await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [req.user.id]); + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// List current user's aliases (includes partner's aliases) router.get('/me/aliases', authMiddleware, async (req, res) => { try { const aliases = await query(req.schema, - 'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name', + `SELECT id,first_name,last_name,email,date_of_birth,avatar,phone + FROM guardian_aliases + WHERE guardian_id=$1 + OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1 + ) + ORDER BY first_name,last_name`, [req.user.id] ); res.json({ aliases }); @@ -496,7 +543,14 @@ router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => { const { firstName, lastName, email, dateOfBirth, phone } = req.body; if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' }); try { - const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]); + const existing = await queryOne(req.schema, + `SELECT id FROM guardian_aliases WHERE id=$1 AND ( + guardian_id=$2 OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2 + ) + )`, + [aliasId, req.user.id]); if (!existing) return res.status(404).json({ error: 'Alias not found' }); await exec(req.schema, 'UPDATE guardian_aliases SET first_name=$1,last_name=$2,email=$3,date_of_birth=$4,phone=$5,updated_at=NOW() WHERE id=$6', @@ -514,7 +568,14 @@ router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => { router.delete('/me/aliases/:aliasId', authMiddleware, async (req, res) => { const aliasId = parseInt(req.params.aliasId); try { - const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]); + const existing = await queryOne(req.schema, + `SELECT id FROM guardian_aliases WHERE id=$1 AND ( + guardian_id=$2 OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2 + ) + )`, + [aliasId, req.user.id]); if (!existing) return res.status(404).json({ error: 'Alias not found' }); await exec(req.schema, 'DELETE FROM guardian_aliases WHERE id=$1', [aliasId]); res.json({ success: true }); @@ -526,7 +587,14 @@ router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.sin const aliasId = parseInt(req.params.aliasId); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); try { - const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]); + const existing = await queryOne(req.schema, + `SELECT id FROM guardian_aliases WHERE id=$1 AND ( + guardian_id=$2 OR guardian_id IN ( + SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END + FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2 + ) + )`, + [aliasId, req.user.id]); if (!existing) return res.status(404).json({ error: 'Alias not found' }); const sharp = require('sharp'); const filePath = req.file.path; diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx index 543e595..9c85429 100644 --- a/frontend/src/components/AddChildAliasModal.jsx +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -1,17 +1,34 @@ import { useState, useEffect } from 'react'; import { useToast } from '../contexts/ToastContext.jsx'; +import { useAuth } from '../contexts/AuthContext.jsx'; import { api } from '../utils/api.js'; export default function AddChildAliasModal({ onClose }) { const toast = useToast(); - const [aliases, setAliases] = useState([]); - const [editingAlias, setEditingAlias] = useState(null); // null = new entry - const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' }); - const [avatarFile, setAvatarFile] = useState(null); - const [saving, setSaving] = useState(false); + const { user: currentUser } = useAuth(); + const [aliases, setAliases] = useState([]); + const [editingAlias, setEditingAlias] = useState(null); // null = new entry + const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' }); + const [avatarFile, setAvatarFile] = useState(null); + const [saving, setSaving] = useState(false); + + // Partner state + const [partner, setPartner] = useState(null); + const [selectedPartnerId, setSelectedPartnerId] = useState(''); + const [allUsers, setAllUsers] = useState([]); + const [savingPartner, setSavingPartner] = useState(false); useEffect(() => { - api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {}); + Promise.all([ + api.getAliases(), + api.getPartner(), + api.searchUsers(''), + ]).then(([aliasRes, partnerRes, usersRes]) => { + setAliases(aliasRes.aliases || []); + setPartner(partnerRes.partner || null); + setSelectedPartnerId(partnerRes.partner?.id?.toString() || ''); + setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id)); + }).catch(() => {}); }, []); const set = k => e => setForm(p => ({ ...p, [k]: e.target.value })); @@ -35,6 +52,27 @@ export default function AddChildAliasModal({ onClose }) { setAvatarFile(null); }; + const handleSavePartner = async () => { + setSavingPartner(true); + try { + if (!selectedPartnerId) { + await api.removePartner(); + setPartner(null); + toast('Spouse/Partner removed', 'success'); + } else { + const { partner: p } = await api.setPartner(parseInt(selectedPartnerId)); + setPartner(p); + const { aliases: fresh } = await api.getAliases(); + setAliases(fresh || []); + toast('Spouse/Partner saved', 'success'); + } + } catch (e) { + toast(e.message, 'error'); + } finally { + setSavingPartner(false); + } + }; + const handleSave = async () => { if (!form.firstName.trim() || !form.lastName.trim()) return toast('First and last name required', 'error'); @@ -93,7 +131,7 @@ export default function AddChildAliasModal({ onClose }) { {/* Header */}