From 6de899112bd2e398ccc1e13d4fdd3d17d01531b8 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Wed, 1 Apr 2026 18:47:36 -0400 Subject: [PATCH] Family manager bug fixes --- backend/src/routes/schedule.js | 45 ++++++++++++++++-- .../src/components/AddChildAliasModal.jsx | 2 +- frontend/src/components/SchedulePage.jsx | 47 ++++++++++++------- frontend/src/utils/api.js | 4 +- 4 files changed, 75 insertions(+), 23 deletions(-) diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 7de9fa7..3b591dc 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -386,6 +386,21 @@ router.get('/:id', authMiddleware, async (req, res) => { [req.user.id] ); } + + // Return partner user info if they are in one of this event's user groups + if (partnerId) { + const partnerInGroup = 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]); + if (partnerInGroup) { + event.my_partner = await queryOne(req.schema, + 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', + [partnerId] + ); + } + } } const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]); event.my_response = mine?.response || null; @@ -692,10 +707,28 @@ router.put('/:id/availability', authMiddleware, async (req, res) => { const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]); if (!event) return res.status(404).json({ error: 'Not found' }); if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' }); - const { response, note, aliasId } = req.body; + const { response, note, aliasId, forPartnerId } = req.body; if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' }); const trimmedNote = note ? String(note).trim().slice(0, 20) : null; + if (forPartnerId) { + // Respond on behalf of partner — verify partnership and partner's group membership + const isPartner = await queryOne(req.schema, + 'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)', + [req.user.id, forPartnerId]); + if (!isPartner) return res.status(403).json({ error: 'Not your partner' }); + const partnerInGroup = 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, forPartnerId]); + if (!partnerInGroup) return res.status(403).json({ error: 'Partner is 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()) + ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW() + `, [event.id, forPartnerId, response, trimmedNote]); + return res.json({ success: true, response, note: trimmedNote }); + } + if (aliasId) { // Alias response (Guardian Only mode) — verify alias belongs to current user or their partner const alias = await queryOne(req.schema, @@ -741,8 +774,14 @@ router.patch('/:id/availability/note', authMiddleware, async (req, res) => { router.delete('/:id/availability', authMiddleware, async (req, res) => { try { - const { aliasId } = req.query; - if (aliasId) { + const { aliasId, forPartnerId } = req.query; + if (forPartnerId) { + const isPartner = await queryOne(req.schema, + 'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)', + [req.user.id, forPartnerId]); + if (!isPartner) return res.status(403).json({ error: 'Not your partner' }); + await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, forPartnerId]); + } else if (aliasId) { const alias = await queryOne(req.schema, `SELECT id FROM guardian_aliases WHERE id=$1 AND ( guardian_id=$2 OR guardian_id IN ( diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx index 9c85429..8d8e6dd 100644 --- a/frontend/src/components/AddChildAliasModal.jsx +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -211,7 +211,7 @@ export default function AddChildAliasModal({ onClose }) {
{editingAlias ? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}` - : 'New Child Alias'} + : 'Add Child'}
{/* Form */} diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index e12a287..334899c 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -789,11 +789,21 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool const [noteSaving,setNoteSaving]=useState(false); const [avail,setAvail]=useState(event.availability||[]); const [expandedNotes,setExpandedNotes]=useState(new Set()); - // Guardian Only: responder select ('all' | 'self' | 'alias:') + // Guardian Only: responder select ('all' | 'self' | 'alias:' | 'partner:') const myAliases = event.my_aliases || []; - const showResponderSelect = !!(event.has_players_group && myAliases.length > 0); + const myPartner = event.my_partner || null; + const showResponderSelect = !!(event.has_players_group && (myAliases.length > 0 || myPartner)) || !!(myPartner && event.in_guardians_group); const [responder, setResponder] = useState('all'); + // Response that should be highlighted for the currently selected responder + const activeResp = !showResponderSelect || responder === 'all' + ? myResp + : responder === 'self' + ? myResp + : responder.startsWith('alias:') + ? (avail.find(r => r.is_alias && r.alias_id === parseInt(responder.replace('alias:','')))?.response || null) + : (avail.find(r => !r.is_alias && r.user_id === parseInt(responder.replace('partner:','')))?.response || null); + // Sync when parent reloads event after availability change useEffect(()=>{ setMyResp(event.my_response); @@ -815,28 +825,30 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool ? [ ...(event.in_guardians_group ? [{ type:'self' }] : []), ...myAliases.map(a => ({ type:'alias', aliasId:a.id })), + ...(myPartner ? [{ type:'partner', userId:myPartner.id }] : []), ] : responder === 'self' ? [{ type:'self' }] - : [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }]; + : responder.startsWith('alias:') + ? [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }] + : [{ type:'partner', userId:parseInt(responder.replace('partner:','')) }]; + + const getCurrentResp = (t) => + t.type === 'self' ? myResp + : t.type === 'alias' ? (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null) + : (avail.find(r => !r.is_alias && r.user_id === t.userId)?.response || null); // For "All": toggle all off only when every target already has this response; // otherwise set all to this response (avoids partial-toggle confusion) - const allHaveResp = responder === 'all' && targets.every(t => - t.type === 'self' - ? myResp === resp - : (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null) === resp - ); + const allHaveResp = responder === 'all' && targets.every(t => getCurrentResp(t) === resp); try { for (const t of targets) { - const prevResp = t.type === 'self' - ? myResp - : (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null); + const prevResp = getCurrentResp(t); const shouldDelete = responder === 'all' ? allHaveResp : prevResp === resp; if (shouldDelete) { - await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : undefined); + await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : undefined, t.type === 'partner' ? t.userId : undefined); } else { - await api.setAvailability(event.id, resp, note, t.type === 'alias' ? t.aliasId : undefined); + await api.setAvailability(event.id, resp, note, t.type === 'alias' ? t.aliasId : undefined, t.type === 'partner' ? t.userId : undefined); } } if (targets.some(t => t.type === 'self')) { @@ -979,8 +991,8 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool <>
{Object.entries(RESP_LABEL).map(([key,label])=>( - ))}
@@ -990,8 +1002,9 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 20e6386..adc92de 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -139,9 +139,9 @@ export const api = { createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount} updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body), deleteEvent: (id, scope = 'this', occurrenceStart = null) => req('DELETE', `/schedule/${id}`, { recurringScope: scope, occurrenceStart }), - setAvailability: (id, response, note, aliasId) => req('PUT', `/schedule/${id}/availability`, { response, note, ...(aliasId ? { aliasId } : {}) }), + setAvailability: (id, response, note, aliasId, forPartnerId) => req('PUT', `/schedule/${id}/availability`, { response, note, ...(aliasId ? { aliasId } : {}), ...(forPartnerId ? { forPartnerId } : {}) }), setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }), - deleteAvailability: (id, aliasId) => req('DELETE', `/schedule/${id}/availability${aliasId ? `?aliasId=${aliasId}` : ''}`), + deleteAvailability: (id, aliasId, forPartnerId) => req('DELETE', `/schedule/${id}/availability${aliasId ? `?aliasId=${aliasId}` : forPartnerId ? `?forPartnerId=${forPartnerId}` : ''}`), getPendingAvailability: () => req('GET', '/schedule/me/pending'), bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }), importPreview: (file) => {