diff --git a/backend/package.json b/backend/package.json index f731fb9..b78edb5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.48", + "version": "0.12.49", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index b147c54..7b8c279 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -65,7 +65,15 @@ async function canViewEvent(schema, event, userId, isToolManager) { 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, userId]); - return !!assigned; + if (assigned) return true; + // Also allow if user has an alias in one of the event's user groups (Guardian Only mode) + const aliasAssigned = 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, userId]); + return !!aliasAssigned; } async function enrichEvent(schema, event) { @@ -235,11 +243,21 @@ 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 isMember = !itm && !!(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])); + const isMember = !itm && !!( + (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])) + || + // Guardian Only: user has an alias in one of the event's user groups + (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, req.user.id])) + ); if (event.track_availability && (itm || isMember)) { // User responses const userAvail = await query(req.schema, ` @@ -253,6 +271,18 @@ router.get('/:id', authMiddleware, async (req, res) => { `, [req.params.id]); event.availability = [...userAvail, ...aliasAvail]; + // 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) + ); + event.availability = event.availability.map(r => { + const isOwn = !r.is_alias && r.user_id === req.user.id; + const isOwnAlias = r.is_alias && myAliasIds.has(r.alias_id); + return (isOwn || isOwnAlias) ? r : { ...r, note: null }; + }); + } + if (itm) { const assignedRows = await query(req.schema, ` SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js index cd49d19..77614e2 100644 --- a/backend/src/routes/usergroups.js +++ b/backend/src/routes/usergroups.js @@ -259,7 +259,7 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { // PATCH /:id router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { - const { name, memberIds, createDm = false } = req.body; + const { name, memberIds, createDm = false, aliasMemberIds } = req.body; try { let ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]); if (!ug) return res.status(404).json({ error: 'Not found' }); @@ -365,6 +365,24 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => } } + // Alias member management (Guardian Only mode — players group) + if (Array.isArray(aliasMemberIds)) { + const newAliasIds = new Set(aliasMemberIds.map(Number).filter(Boolean)); + const currentAliasSet = new Set( + (await query(req.schema, 'SELECT alias_id FROM alias_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.alias_id) + ); + for (const aid of newAliasIds) { + if (!currentAliasSet.has(aid)) { + await exec(req.schema, 'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, aid]); + } + } + for (const aid of currentAliasSet) { + if (!newAliasIds.has(aid)) { + await exec(req.schema, 'DELETE FROM alias_group_members WHERE user_group_id=$1 AND alias_id=$2', [ug.id, aid]); + } + } + } + const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]); res.json({ group: updated }); } catch (e) { res.status(500).json({ error: e.message }); } diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index c398b33..a31b1a5 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -437,6 +437,20 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async ( // ── Guardian alias routes (Guardian Only mode) ────────────────────────────── +// List ALL aliases — admin/manager only (for Group Manager alias management) +router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, res) => { + try { + const aliases = await query(req.schema, + `SELECT ga.id, ga.first_name, ga.last_name, ga.guardian_id, ga.avatar, ga.date_of_birth, + u.name AS guardian_name, u.display_name AS guardian_display_name + FROM guardian_aliases ga + JOIN users u ON u.id = ga.guardian_id + ORDER BY ga.first_name, ga.last_name`, + ); + res.json({ aliases }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + // List current user's aliases router.get('/me/aliases', authMiddleware, async (req, res) => { try { diff --git a/build.sh b/build.sh index d4f919d..be86696 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.48}" +VERSION="${1:-0.12.49}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 37e25c7..69afa7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.48", + "version": "0.12.49", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 569f4a3..e12a287 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -820,18 +820,28 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool ? [{ type:'self' }] : [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }]; + // 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 + ); 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); - if (prevResp === resp) { + const shouldDelete = responder === 'all' ? allHaveResp : prevResp === resp; + if (shouldDelete) { await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : undefined); } else { await api.setAvailability(event.id, resp, note, t.type === 'alias' ? t.aliasId : undefined); } } - if (targets.some(t => t.type === 'self')) setMyResp(prev => prev === resp ? null : resp); + if (targets.some(t => t.type === 'self')) { + setMyResp(responder === 'all' ? (allHaveResp ? null : resp) : (myResp === resp ? null : resp)); + } onAvailabilityChange?.(resp); } catch(e) { toast(e.message,'error'); } return; diff --git a/frontend/src/pages/GroupManagerPage.jsx b/frontend/src/pages/GroupManagerPage.jsx index 24ba31f..3f05f7a 100644 --- a/frontend/src/pages/GroupManagerPage.jsx +++ b/frontend/src/pages/GroupManagerPage.jsx @@ -43,6 +43,29 @@ function UserCheckList({ allUsers, selectedIds, onChange, onIF, onIB }) { ); } +function AliasCheckList({ allAliases, selectedIds, onChange, onIF, onIB }) { + const [search, setSearch] = useState(''); + const filtered = allAliases + .filter(a => `${a.first_name} ${a.last_name}`.toLowerCase().includes(search.toLowerCase())) + .sort((a, b) => `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`)); + return ( +
Group DM already exists — cannot be removed.
}{members.size} selected
+ + {isPlayersGroup ? ( +{aliasSelection.size} selected
+{members.size} selected
+ > + )}