v0.12.49 Login Type and Event bug fixes

This commit is contained in:
2026-04-01 09:25:17 -04:00
parent a3a878854e
commit 7031979571
9 changed files with 151 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-backend", "name": "rosterchirp-backend",
"version": "0.12.48", "version": "0.12.49",
"description": "RosterChirp backend server", "description": "RosterChirp backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -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 JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2 WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, userId]); `, [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) { async function enrichEvent(schema, event) {
@@ -235,11 +243,21 @@ router.get('/:id', authMiddleware, async (req, res) => {
const itm = await isToolManagerFn(req.schema, req.user); 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' }); if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
await enrichEvent(req.schema, event); await enrichEvent(req.schema, event);
const isMember = !itm && !!(await queryOne(req.schema, ` const isMember = !itm && !!(
SELECT 1 FROM event_user_groups eug (await queryOne(req.schema, `
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id SELECT 1 FROM event_user_groups eug
WHERE eug.event_id=$1 AND ugm.user_id=$2 JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
`, [event.id, req.user.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)) { if (event.track_availability && (itm || isMember)) {
// User responses // User responses
const userAvail = await query(req.schema, ` const userAvail = await query(req.schema, `
@@ -253,6 +271,18 @@ router.get('/:id', authMiddleware, async (req, res) => {
`, [req.params.id]); `, [req.params.id]);
event.availability = [...userAvail, ...aliasAvail]; 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) { if (itm) {
const assignedRows = await query(req.schema, ` const assignedRows = await query(req.schema, `
SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name

View File

@@ -259,7 +259,7 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
// PATCH /:id // PATCH /:id
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, memberIds, createDm = false } = req.body; const { name, memberIds, createDm = false, aliasMemberIds } = req.body;
try { try {
let ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]); 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' }); 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]); const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
res.json({ group: updated }); res.json({ group: updated });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }

View File

@@ -437,6 +437,20 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (
// ── Guardian alias routes (Guardian Only mode) ────────────────────────────── // ── 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 // List current user's aliases
router.get('/me/aliases', authMiddleware, async (req, res) => { router.get('/me/aliases', authMiddleware, async (req, res) => {
try { try {

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.12.48}" VERSION="${1:-0.12.49}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp" IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-frontend", "name": "rosterchirp-frontend",
"version": "0.12.48", "version": "0.12.49",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -820,18 +820,28 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
? [{ type:'self' }] ? [{ type:'self' }]
: [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }]; : [{ 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 { try {
for (const t of targets) { for (const t of targets) {
const prevResp = t.type === 'self' const prevResp = t.type === 'self'
? myResp ? myResp
: (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null); : (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); await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : 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);
} }
} }
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); onAvailabilityChange?.(resp);
} catch(e) { toast(e.message,'error'); } } catch(e) { toast(e.message,'error'); }
return; return;

View File

@@ -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 (
<div>
<input className="input" placeholder="Search aliases…" value={search} onChange={e => setSearch(e.target.value)} autoComplete="off" style={{ marginBottom:8 }} onFocus={onIF} onBlur={onIB} />
<div style={{ maxHeight:220, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
{filtered.map(a => (
<label key={a.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => { const n=new Set(selectedIds); n.has(a.id)?n.delete(a.id):n.add(a.id); onChange(n); }}
style={{ accentColor:'var(--primary)', width:15, height:15 }} />
<span className="flex-1 text-sm">{a.first_name} {a.last_name}</span>
<span className="text-xs" style={{ color:'var(--text-tertiary)' }}>{a.guardian_display_name || a.guardian_name}</span>
</label>
))}
{filtered.length === 0 && <div style={{ padding:16, textAlign:'center', color:'var(--text-tertiary)', fontSize:13 }}>No aliases found</div>}
</div>
</div>
);
}
function GroupCheckList({ allGroups, selectedIds, onChange }) { function GroupCheckList({ allGroups, selectedIds, onChange }) {
return ( return (
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:220, overflowY:'auto' }}> <div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:220, overflowY:'auto' }}>
@@ -60,7 +83,7 @@ function GroupCheckList({ allGroups, selectedIds, onChange }) {
} }
// ── All Groups tab ──────────────────────────────────────────────────────────── // ── All Groups tab ────────────────────────────────────────────────────────────
function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB, playersGroupId }) {
const toast = useToast(); const toast = useToast();
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
@@ -68,6 +91,8 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
const [members, setMembers] = useState(new Set()); const [members, setMembers] = useState(new Set());
const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted
const [aliasMembers, setAliasMembers] = useState([]); // child aliases in this group 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 [editName, setEditName] = useState('');
const [noDm, setNoDm] = useState(false); const [noDm, setNoDm] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -89,12 +114,25 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
setAliasMembers(aliases || []); setAliasMembers(aliases || []);
// No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked // No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked
setNoDm(!g.dm_group_id); 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 = () => { const clearSelection = () => {
setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set());
setShowDelete(false); setFullMembers([]); setAliasMembers([]); setNoDm(false); setShowDelete(false); setFullMembers([]); setAliasMembers([]); setNoDm(false);
setAllAliases([]); setAliasSelection(new Set());
}; };
const isPlayersGroup = !!(playersGroupId && selected?.id === playersGroupId);
const handleSave = async () => { const handleSave = async () => {
if (!editName.trim()) return toast('Name required', 'error'); if (!editName.trim()) return toast('Name required', 'error');
setSaving(true); setSaving(true);
@@ -102,11 +140,18 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
if (selected) { if (selected) {
// createDm=true when the group has no DM and the user unchecked "Do not create Group DM" // 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 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'); toast('Group updated', 'success');
const { members: fresh, aliasMembers: freshAliases } = await api.getUserGroup(selected.id); const { members: fresh, aliasMembers: freshAliases } = await api.getUserGroup(selected.id);
const freshIds = new Set(fresh.map(m => m.id)); const freshIds = new Set(fresh.map(m => m.id));
setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); setAliasMembers(freshAliases || []); 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 // 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 })); setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id }));
if (createDm) setNoDm(false); if (createDm) setNoDm(false);
@@ -218,11 +263,20 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
{selected && selected.dm_group_id && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:4 }}>Group DM already exists cannot be removed.</p>} {selected && selected.dm_group_id && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:4 }}>Group DM already exists cannot be removed.</p>}
</div> </div>
<div> <div>
<label className="settings-section-label">Members</label> <label className="settings-section-label">{isPlayersGroup ? 'Child Aliases' : 'Members'}</label>
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div> {isPlayersGroup ? (
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p> <div style={{ marginTop:6 }}>
<AliasCheckList allAliases={allAliases} selectedIds={aliasSelection} onChange={setAliasSelection} onIF={onIF} onIB={onIB} />
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{aliasSelection.size} selected</p>
</div>
) : (
<>
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div>
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p>
</>
)}
</div> </div>
{aliasMembers.length > 0 && ( {!isPlayersGroup && aliasMembers.length > 0 && (
<div> <div>
<label className="settings-section-label">Child Aliases</label> <label className="settings-section-label">Child Aliases</label>
<div style={{ marginTop:6, border:'1px solid var(--border)', borderRadius:'var(--radius)', overflow:'hidden' }}> <div style={{ marginTop:6, border:'1px solid var(--border)', borderRadius:'var(--radius)', overflow:'hidden' }}>
@@ -699,6 +753,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
const [allUserGroups, setAllUserGroups] = useState([]); const [allUserGroups, setAllUserGroups] = useState([]);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [inputFocused, setInputFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false);
const [playersGroupId, setPlayersGroupId] = useState(null);
const onIF = () => setInputFocused(true); const onIF = () => setInputFocused(true);
const onIB = () => setInputFocused(false); const onIB = () => setInputFocused(false);
const onRefresh = () => setRefreshKey(k => k+1); const onRefresh = () => setRefreshKey(k => k+1);
@@ -706,6 +761,10 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
useEffect(() => { 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.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.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]); }, [refreshKey]);
// Nav item helper — matches Schedule page style // Nav item helper — matches Schedule page style
@@ -758,7 +817,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
{/* Content */} {/* Content */}
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 0 }}> <div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 0 }}>
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} onIF={onIF} onIB={onIB} />} {tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} onIF={onIF} onIB={onIB} playersGroupId={playersGroupId} />}
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} onIF={onIF} onIB={onIB} />} {tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} onIF={onIF} onIB={onIB} />} {tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
</div> </div>

View File

@@ -75,6 +75,7 @@ export const api = {
linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`), linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`),
// Guardian aliases // Guardian aliases
getAliases: () => req('GET', '/users/me/aliases'), getAliases: () => req('GET', '/users/me/aliases'),
getAllAliases: () => req('GET', '/users/aliases-all'),
createAlias: (body) => req('POST', '/users/me/aliases', body), createAlias: (body) => req('POST', '/users/me/aliases', body),
updateAlias: (id, body) => req('PATCH', `/users/me/aliases/${id}`, body), updateAlias: (id, body) => req('PATCH', `/users/me/aliases/${id}`, body),
deleteAlias: (id) => req('DELETE', `/users/me/aliases/${id}`), deleteAlias: (id) => req('DELETE', `/users/me/aliases/${id}`),