Family manager bug fixes

This commit is contained in:
2026-04-01 18:47:36 -04:00
parent 3910063ed3
commit 6de899112b
4 changed files with 75 additions and 23 deletions

View File

@@ -386,6 +386,21 @@ router.get('/:id', authMiddleware, async (req, res) => {
[req.user.id] [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]); 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; 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]); 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) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' }); 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' }); 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; 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) { if (aliasId) {
// Alias response (Guardian Only mode) — verify alias belongs to current user or their partner // Alias response (Guardian Only mode) — verify alias belongs to current user or their partner
const alias = await queryOne(req.schema, 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) => { router.delete('/:id/availability', authMiddleware, async (req, res) => {
try { try {
const { aliasId } = req.query; const { aliasId, forPartnerId } = req.query;
if (aliasId) { 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, const alias = await queryOne(req.schema,
`SELECT id FROM guardian_aliases WHERE id=$1 AND ( `SELECT id FROM guardian_aliases WHERE id=$1 AND (
guardian_id=$2 OR guardian_id IN ( guardian_id=$2 OR guardian_id IN (

View File

@@ -211,7 +211,7 @@ export default function AddChildAliasModal({ onClose }) {
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}> <div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
{editingAlias {editingAlias
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}` ? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
: 'New Child Alias'} : 'Add Child'}
</div> </div>
{/* Form */} {/* Form */}

View File

@@ -789,11 +789,21 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const [noteSaving,setNoteSaving]=useState(false); const [noteSaving,setNoteSaving]=useState(false);
const [avail,setAvail]=useState(event.availability||[]); const [avail,setAvail]=useState(event.availability||[]);
const [expandedNotes,setExpandedNotes]=useState(new Set()); const [expandedNotes,setExpandedNotes]=useState(new Set());
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>') // Guardian Only: responder select ('all' | 'self' | 'alias:<id>' | 'partner:<id>')
const myAliases = event.my_aliases || []; 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'); 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 // Sync when parent reloads event after availability change
useEffect(()=>{ useEffect(()=>{
setMyResp(event.my_response); setMyResp(event.my_response);
@@ -815,28 +825,30 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
? [ ? [
...(event.in_guardians_group ? [{ type:'self' }] : []), ...(event.in_guardians_group ? [{ type:'self' }] : []),
...myAliases.map(a => ({ type:'alias', aliasId:a.id })), ...myAliases.map(a => ({ type:'alias', aliasId:a.id })),
...(myPartner ? [{ type:'partner', userId:myPartner.id }] : []),
] ]
: responder === 'self' : responder === 'self'
? [{ type:'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; // For "All": toggle all off only when every target already has this response;
// otherwise set all to this response (avoids partial-toggle confusion) // otherwise set all to this response (avoids partial-toggle confusion)
const allHaveResp = responder === 'all' && targets.every(t => const allHaveResp = responder === 'all' && targets.every(t => getCurrentResp(t) === resp);
t.type === 'self'
? myResp === resp
: (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null) === resp
);
try { try {
for (const t of targets) { for (const t of targets) {
const prevResp = t.type === 'self' const prevResp = getCurrentResp(t);
? myResp
: (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null);
const shouldDelete = responder === 'all' ? allHaveResp : prevResp === resp; const shouldDelete = responder === 'all' ? allHaveResp : prevResp === resp;
if (shouldDelete) { 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 { } 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')) { if (targets.some(t => t.type === 'self')) {
@@ -979,8 +991,8 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
<> <>
<div style={{display:'flex',gap:8,marginBottom:12}}> <div style={{display:'flex',gap:8,marginBottom:12}}>
{Object.entries(RESP_LABEL).map(([key,label])=>( {Object.entries(RESP_LABEL).map(([key,label])=>(
<button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}> <button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:activeResp===key?RESP_COLOR[key]:'transparent',color:activeResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
{myResp===key?'✓ ':''}{label} {activeResp===key?'✓ ':''}{label}
</button> </button>
))} ))}
</div> </div>
@@ -990,8 +1002,9 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
<label style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',display:'block',marginBottom:4}}>Responding for</label> <label style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',display:'block',marginBottom:4}}>Responding for</label>
<select value={responder} onChange={e=>setResponder(e.target.value)} <select value={responder} onChange={e=>setResponder(e.target.value)}
style={{width:'100%',padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13}}> style={{width:'100%',padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13}}>
<option value="all">All</option> <option value="all">Entire Family</option>
{event.in_guardians_group && <option value="self">{/* guardian's own name shown as self */}My own response</option>} {event.in_guardians_group && <option value="self">Myself</option>}
{myPartner && <option value={`partner:${myPartner.id}`}>{myPartner.display_name || myPartner.name}</option>}
{myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)} {myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)}
</select> </select>
</div> </div>

View File

@@ -139,9 +139,9 @@ export const api = {
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount} createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body), updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
deleteEvent: (id, scope = 'this', occurrenceStart = null) => req('DELETE', `/schedule/${id}`, { recurringScope: scope, occurrenceStart }), 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 }), 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'), getPendingAvailability: () => req('GET', '/schedule/me/pending'),
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }), bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
importPreview: (file) => { importPreview: (file) => {