From 703197957184c62dda6f9181bc204d88bc7af163 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Wed, 1 Apr 2026 09:25:17 -0400 Subject: [PATCH] v0.12.49 Login Type and Event bug fixes --- backend/package.json | 2 +- backend/src/routes/schedule.js | 42 ++++++++++++-- backend/src/routes/usergroups.js | 20 ++++++- backend/src/routes/users.js | 14 +++++ build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/SchedulePage.jsx | 14 ++++- frontend/src/pages/GroupManagerPage.jsx | 73 +++++++++++++++++++++--- frontend/src/utils/api.js | 1 + 9 files changed, 151 insertions(+), 19 deletions(-) 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 ( +
+ setSearch(e.target.value)} autoComplete="off" style={{ marginBottom:8 }} onFocus={onIF} onBlur={onIB} /> +
+ {filtered.map(a => ( + + ))} + {filtered.length === 0 &&
No aliases found
} +
+
+ ); +} + function GroupCheckList({ allGroups, selectedIds, onChange }) { return (
@@ -60,7 +83,7 @@ function GroupCheckList({ allGroups, selectedIds, onChange }) { } // ── All Groups tab ──────────────────────────────────────────────────────────── -function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { +function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB, playersGroupId }) { const toast = useToast(); const [groups, setGroups] = useState([]); const [selected, setSelected] = useState(null); @@ -68,6 +91,8 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { const [members, setMembers] = useState(new Set()); const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted const [aliasMembers, setAliasMembers] = useState([]); // child aliases in this group + const [allAliases, setAllAliases] = useState([]); // all aliases for players group management + const [aliasSelection, setAliasSelection] = useState(new Set()); // selected alias ids for players group const [editName, setEditName] = useState(''); const [noDm, setNoDm] = useState(false); const [saving, setSaving] = useState(false); @@ -89,12 +114,25 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { setAliasMembers(aliases || []); // No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked setNoDm(!g.dm_group_id); + // Players group: load all aliases for alias-based membership management + if (playersGroupId && g.id === playersGroupId) { + api.getAllAliases().then(({ aliases: all }) => { + setAllAliases(all || []); + setAliasSelection(new Set((aliases || []).map(a => a.id))); + }).catch(() => {}); + } else { + setAllAliases([]); + setAliasSelection(new Set()); + } }; const clearSelection = () => { setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); setShowDelete(false); setFullMembers([]); setAliasMembers([]); setNoDm(false); + setAllAliases([]); setAliasSelection(new Set()); }; + const isPlayersGroup = !!(playersGroupId && selected?.id === playersGroupId); + const handleSave = async () => { if (!editName.trim()) return toast('Name required', 'error'); setSaving(true); @@ -102,11 +140,18 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { if (selected) { // createDm=true when the group has no DM and the user unchecked "Do not create Group DM" const createDm = !selected.dm_group_id && !noDm; - const { group: updated } = await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members], createDm }); + const body = isPlayersGroup + ? { name: editName.trim(), memberIds: [], aliasMemberIds: [...aliasSelection], createDm } + : { name: editName.trim(), memberIds: [...members], createDm }; + const { group: updated } = await api.updateUserGroup(selected.id, body); toast('Group updated', 'success'); const { members: fresh, aliasMembers: freshAliases } = await api.getUserGroup(selected.id); const freshIds = new Set(fresh.map(m => m.id)); setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); setAliasMembers(freshAliases || []); + if (isPlayersGroup) { + setAliasSelection(new Set((freshAliases || []).map(a => a.id))); + setAllAliases(prev => prev); // keep existing list + } // Reflect new dm_group_id if a DM was just created setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id })); if (createDm) setNoDm(false); @@ -218,11 +263,20 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { {selected && selected.dm_group_id &&

Group DM already exists — cannot be removed.

}
- -
-

{members.size} selected

+ + {isPlayersGroup ? ( +
+ +

{aliasSelection.size} selected

+
+ ) : ( + <> +
+

{members.size} selected

+ + )}
- {aliasMembers.length > 0 && ( + {!isPlayersGroup && aliasMembers.length > 0 && (
@@ -699,6 +753,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, const [allUserGroups, setAllUserGroups] = useState([]); const [refreshKey, setRefreshKey] = useState(0); const [inputFocused, setInputFocused] = useState(false); + const [playersGroupId, setPlayersGroupId] = useState(null); const onIF = () => setInputFocused(true); const onIB = () => setInputFocused(false); const onRefresh = () => setRefreshKey(k => k+1); @@ -706,6 +761,10 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, useEffect(() => { api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active').sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {}); api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}); + api.getSettings().then(({ settings }) => { + const pgid = (settings || []).find(s => s.key === 'feature_players_group_id')?.value; + setPlayersGroupId(pgid ? parseInt(pgid) : null); + }).catch(() => {}); }, [refreshKey]); // Nav item helper — matches Schedule page style @@ -758,7 +817,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, {/* Content */}
- {tab==='all' && } + {tab==='all' && } {tab==='dm' && } {tab==='u2u' && }
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e3a4488..9c848ef 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -75,6 +75,7 @@ export const api = { linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`), // Guardian aliases getAliases: () => req('GET', '/users/me/aliases'), + getAllAliases: () => req('GET', '/users/aliases-all'), createAlias: (body) => req('POST', '/users/me/aliases', body), updateAlias: (id, body) => req('PATCH', `/users/me/aliases/${id}`, body), deleteAlias: (id) => req('DELETE', `/users/me/aliases/${id}`),