diff --git a/backend/package.json b/backend/package.json index b78edb5..5a1bbfc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.49", + "version": "0.12.50", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/migrations/017_partner_respond_separately.sql b/backend/src/models/migrations/017_partner_respond_separately.sql new file mode 100644 index 0000000..c94ffb2 --- /dev/null +++ b/backend/src/models/migrations/017_partner_respond_separately.sql @@ -0,0 +1,6 @@ +-- 017_partner_respond_separately.sql +-- Adds respond_separately flag to guardian_partners. +-- When true, linked partners can each respond to events on behalf of children +-- in the shared alias list, but cannot respond on behalf of each other. + +ALTER TABLE guardian_partners ADD COLUMN IF NOT EXISTS respond_separately BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 3b591dc..611f36e 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -395,10 +395,12 @@ router.get('/:id', authMiddleware, async (req, res) => { 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 pUser = await queryOne(req.schema, 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', [partnerId]); + const pGp = await queryOne(req.schema, + 'SELECT respond_separately FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)', + [Math.min(req.user.id, partnerId), Math.max(req.user.id, partnerId)] ); + event.my_partner = pUser ? { ...pUser, respond_separately: pGp?.respond_separately || false } : null; } } } diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index d4a4552..f886d6f 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -455,7 +455,7 @@ router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, re 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 + `SELECT u.id, u.name, u.display_name, u.avatar, gp.respond_separately 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`, @@ -466,26 +466,52 @@ router.get('/me/partner', authMiddleware, async (req, res) => { }); // Set partner (replaces any existing partnership for this user) +// If the partner is changing to a different person, the user's child aliases are also removed. router.post('/me/partner', authMiddleware, async (req, res) => { const userId = req.user.id; const partnerId = parseInt(req.body.partnerId); + const respondSeparately = !!req.body.respondSeparately; 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 { + // Check current partner before replacing + const currentRow = await queryOne(req.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] + ); + const currentPartnerId = currentRow?.partner_id ? parseInt(currentRow.partner_id) : null; 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]); + // If switching to a different partner, remove user's own child aliases + if (currentPartnerId && currentPartnerId !== partnerId) { + await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [userId]); + } + await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2,respond_separately) VALUES ($1,$2,$3)', [uid1, uid2, respondSeparately]); const partner = await queryOne(req.schema, 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', [partnerId] ); - res.json({ partner }); + res.json({ partner: { ...partner, respond_separately: respondSeparately } }); } catch (e) { res.status(500).json({ error: e.message }); } }); -// Remove partner +// Update respond_separately on existing partnership +router.patch('/me/partner', authMiddleware, async (req, res) => { + const respondSeparately = !!req.body.respondSeparately; + try { + await exec(req.schema, + 'UPDATE guardian_partners SET respond_separately=$1 WHERE user_id_1=$2 OR user_id_2=$2', + [respondSeparately, req.user.id] + ); + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Remove partner — also removes the requesting user's child aliases router.delete('/me/partner', authMiddleware, async (req, res) => { try { + await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [req.user.id]); 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 }); } diff --git a/build.sh b/build.sh index be86696..10e3fe6 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.49}" +VERSION="${1:-0.12.50}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 69afa7e..1c951f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.49", + "version": "0.12.50", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx index 8d8e6dd..a9b3b4e 100644 --- a/frontend/src/components/AddChildAliasModal.jsx +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -15,6 +15,7 @@ export default function AddChildAliasModal({ onClose }) { // Partner state const [partner, setPartner] = useState(null); const [selectedPartnerId, setSelectedPartnerId] = useState(''); + const [respondSeparately, setRespondSeparately] = useState(false); const [allUsers, setAllUsers] = useState([]); const [savingPartner, setSavingPartner] = useState(false); @@ -25,8 +26,10 @@ export default function AddChildAliasModal({ onClose }) { api.searchUsers(''), ]).then(([aliasRes, partnerRes, usersRes]) => { setAliases(aliasRes.aliases || []); - setPartner(partnerRes.partner || null); - setSelectedPartnerId(partnerRes.partner?.id?.toString() || ''); + const p = partnerRes.partner || null; + setPartner(p); + setSelectedPartnerId(p?.id?.toString() || ''); + setRespondSeparately(p?.respond_separately || false); setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id)); }).catch(() => {}); }, []); @@ -58,13 +61,18 @@ export default function AddChildAliasModal({ onClose }) { if (!selectedPartnerId) { await api.removePartner(); setPartner(null); - toast('Spouse/Partner removed', 'success'); - } else { - const { partner: p } = await api.setPartner(parseInt(selectedPartnerId)); - setPartner(p); + setRespondSeparately(false); const { aliases: fresh } = await api.getAliases(); setAliases(fresh || []); - toast('Spouse/Partner saved', 'success'); + resetForm(); + toast('Spouse/Partner/Co-Parent removed', 'success'); + } else { + const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately); + setPartner(p); + setRespondSeparately(p?.respond_separately || false); + const { aliases: fresh } = await api.getAliases(); + setAliases(fresh || []); + toast('Spouse/Partner/Co-Parent saved', 'success'); } } catch (e) { toast(e.message, 'error'); @@ -139,9 +147,9 @@ export default function AddChildAliasModal({ onClose }) { - {/* Spouse/Partner section */} + {/* Spouse/Partner/Co-Parent section */}
- {lbl('Spouse/Partner')} + {lbl('Spouse/Partner/Co-Parent')}
setRespondSeparately(e.target.checked)} + style={{ width: 15, height: 15, cursor: 'pointer', accentColor: 'var(--primary)' }} + /> + Respond separately to events + {partner && ( -
+
Linked with {partner.display_name || partner.name}
)} diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 334899c..5f42ace 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -789,11 +789,12 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool const [noteSaving,setNoteSaving]=useState(false); const [avail,setAvail]=useState(event.availability||[]); const [expandedNotes,setExpandedNotes]=useState(new Set()); + const [responsesExpanded,setResponsesExpanded]=useState(false); // Guardian Only: responder select ('all' | 'self' | 'alias:' | 'partner:') const myAliases = event.my_aliases || []; 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'); + const [responder, setResponder] = useState(event.in_guardians_group ? 'self' : 'all'); // Response that should be highlighted for the currently selected responder const activeResp = !showResponderSelect || responder === 'all' @@ -825,7 +826,7 @@ 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 }] : []), + ...(myPartner && !myPartner.respond_separately ? [{ type:'partner', userId:myPartner.id }] : []), ] : responder === 'self' ? [{ type:'self' }] @@ -1002,9 +1003,9 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
@@ -1029,44 +1030,60 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool )} {(isToolManager||avail.length>0)&&( <> -
Responses
-
- {Object.entries(counts).map(([k,n])=>{n} {RESP_LABEL[k]})} - {isToolManager&&{event.no_response_count||0} No response} -
- {avail.length>0&&( -
- {avail.map(r=>{ - const rowKey = r.is_alias ? `alias:${r.alias_id}` : `user:${r.user_id}`; - const displayName = r.is_alias - ? `${r.first_name} ${r.last_name}` - : (r.display_name || r.name); - const hasNote=!!(r.note&&r.note.trim()); - const expanded=expandedNotes.has(rowKey); - return( -
-
toggleNote(rowKey):undefined} - > - - {displayName} - {r.is_alias&&child} - {hasNote&&( - - )} - {RESP_LABEL[r.response]} -
- {hasNote&&expanded&&( -
- {r.note} -
- )} -
- ); - })} +
setResponsesExpanded(e=>!e)} + style={{display:'flex',alignItems:'center',justifyContent:'space-between',cursor:'pointer',userSelect:'none',marginBottom:responsesExpanded?8:0}} + > + Responses +
+
+ {Object.entries(counts).map(([k,n])=>{n} {RESP_LABEL[k]})} + {isToolManager&&{event.no_response_count||0} No response} +
+
- )} +
+ {responsesExpanded&&avail.length>0&&(()=>{ + const RESP_ORDER={going:0,maybe:1,not_going:2}; + const sortedAvail=[...avail].sort((a,b)=>{ + const od=(RESP_ORDER[a.response]??99)-(RESP_ORDER[b.response]??99); + if(od!==0)return od; + const na=a.is_alias?`${a.first_name} ${a.last_name}`:(a.display_name||a.name||''); + const nb=b.is_alias?`${b.first_name} ${b.last_name}`:(b.display_name||b.name||''); + return na.localeCompare(nb); + }); + return( +
4?'140px':undefined,overflowY:avail.length>4?'auto':undefined}}> + {sortedAvail.map(r=>{ + const rowKey=r.is_alias?`alias:${r.alias_id}`:`user:${r.user_id}`; + const displayName=r.is_alias?`${r.first_name} ${r.last_name}`:(r.display_name||r.name); + const hasNote=!!(r.note&&r.note.trim()); + const expanded=expandedNotes.has(rowKey); + return( +
+
toggleNote(rowKey):undefined} + > + + {displayName} + {r.is_alias&&child} + {hasNote&&( + + )} + {RESP_LABEL[r.response]} +
+ {hasNote&&expanded&&( +
+ {r.note} +
+ )} +
+ ); + })} +
+ ); + })()} )}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index adc92de..80b77cc 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -85,7 +85,8 @@ export const api = { }, // Spouse/Partner getPartner: () => req('GET', '/users/me/partner'), - setPartner: (partnerId) => req('POST', '/users/me/partner', { partnerId }), + setPartner: (partnerId, respondSeparately = false) => req('POST', '/users/me/partner', { partnerId, respondSeparately }), + updatePartnerRespondSeparately: (respondSeparately) => req('PATCH', '/users/me/partner', { respondSeparately }), removePartner: () => req('DELETE', '/users/me/partner'), // Groups