diff --git a/backend/package.json b/backend/package.json index 5a1bbfc..b8ec0ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.50", + "version": "0.12.51", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index a913e62..8594194 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -190,10 +190,10 @@ router.post('/', authMiddleware, async (req, res) => { await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]); await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]); - // Mixed Age: if the other user is a minor, auto-add their guardian + // Mixed Age: if initiator is not a minor and the other user is a minor, auto-add their guardian let guardianAdded = false, guardianName = null; const loginType = await getLoginType(req.schema); - if (loginType === 'mixed_age') { + if (loginType === 'mixed_age' && !req.user.is_minor) { const otherUserFull = await queryOne(req.schema, 'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [otherUserId]); if (otherUserFull?.is_minor && otherUserFull.guardian_user_id) { @@ -234,6 +234,7 @@ router.post('/', authMiddleware, async (req, res) => { [name, type||'private', req.user.id, !!isReadonly] ); const groupId = r.rows[0].id; + const groupGuardianNames = []; if (type === 'public') { const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'"); for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]); @@ -251,9 +252,30 @@ router.post('/', authMiddleware, async (req, res) => { if (parseInt(totalCount.cnt) >= 3) { await computeAndStoreComposite(req.schema, groupId); } + + // Mixed Age: auto-add guardians for any minor members (non-minor initiators only) + const groupLoginType = await getLoginType(req.schema); + if (groupLoginType === 'mixed_age' && !req.user.is_minor && memberIds?.length > 0) { + for (const uid of memberIds) { + const memberInfo = await queryOne(req.schema, + 'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [uid]); + if (memberInfo?.is_minor && memberInfo.guardian_user_id && memberInfo.guardian_user_id !== req.user.id) { + await exec(req.schema, + 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', + [groupId, memberInfo.guardian_user_id]); + const g = await queryOne(req.schema, + 'SELECT name,display_name FROM users WHERE id=$1', [memberInfo.guardian_user_id]); + const gName = g?.display_name || g?.name; + if (gName && !groupGuardianNames.includes(gName)) groupGuardianNames.push(gName); + } + } + } } await emitGroupNew(req.schema, io, groupId); - res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) }); + res.json({ + group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]), + ...(groupGuardianNames.length ? { guardianAdded: true, guardianName: groupGuardianNames.join(', ') } : {}), + }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index f886d6f..3dc6331 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -80,18 +80,18 @@ router.get('/search', authMiddleware, async (req, res) => { const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]); if (group && (group.type === 'private' || group.is_direct)) { users = await query(req.schema, - `SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`, + `SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm,u.is_minor FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`, [parseInt(groupId), req.user.id, `%${q}%`] ); } else { users = await query(req.schema, - `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, + `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, [req.user.id, `%${q}%`] ); } } else { users = await query(req.schema, - `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, + `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, [`%${q}%`] ); } @@ -695,6 +695,64 @@ router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async } catch (e) { res.status(500).json({ error: e.message }); } }); +// List minor players available for this guardian to claim (Mixed Age — Family Manager) +// Returns minors in the players group who either have no guardian yet or are already linked to me. +router.get('/minor-players', authMiddleware, async (req, res) => { + try { + const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'"); + const playersGroupId = parseInt(playersRow?.value); + if (!playersGroupId) return res.json({ users: [] }); + const users = await query(req.schema, + `SELECT u.id,u.name,u.first_name,u.last_name,u.date_of_birth,u.avatar,u.status,u.guardian_user_id + FROM users u + JOIN user_group_members ugm ON ugm.user_id=u.id AND ugm.user_group_id=$1 + WHERE u.is_minor=TRUE AND u.status!='deleted' + AND (u.guardian_user_id IS NULL OR u.guardian_user_id=$2) + ORDER BY u.first_name,u.last_name`, + [playersGroupId, req.user.id] + ); + res.json({ users }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Claim minor as guardian (Mixed Age — Family Manager direct link, no approval needed) +router.post('/me/guardian-children/:minorId', authMiddleware, async (req, res) => { + const minorId = parseInt(req.params.minorId); + try { + const minor = await queryOne(req.schema, "SELECT * FROM users WHERE id=$1 AND status!='deleted'", [minorId]); + if (!minor) return res.status(404).json({ error: 'User not found' }); + if (!minor.is_minor) return res.status(400).json({ error: 'User is not a minor' }); + if (minor.guardian_user_id && minor.guardian_user_id !== req.user.id) + return res.status(409).json({ error: 'This minor already has a guardian' }); + await exec(req.schema, + "UPDATE users SET guardian_user_id=$1,guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$2", + [req.user.id, minorId] + ); + await addUserToPublicGroups(req.schema, minorId); + const user = await queryOne(req.schema, + 'SELECT id,name,first_name,last_name,date_of_birth,avatar,status,guardian_user_id FROM users WHERE id=$1', + [minorId] + ); + res.json({ user }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Remove minor from guardian's list (Mixed Age — re-suspends the minor) +router.delete('/me/guardian-children/:minorId', authMiddleware, async (req, res) => { + const minorId = parseInt(req.params.minorId); + try { + const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]); + if (!minor) return res.status(404).json({ error: 'User not found' }); + if (minor.guardian_user_id !== req.user.id) + return res.status(403).json({ error: 'You are not the guardian of this user' }); + await exec(req.schema, + "UPDATE users SET guardian_user_id=NULL,status='suspended',updated_at=NOW() WHERE id=$1", + [minorId] + ); + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + // Guardian self-link (Mixed Age — user links themselves as guardian of a minor, triggers approval) router.patch('/me/link-minor/:minorId', authMiddleware, async (req, res) => { const minorId = parseInt(req.params.minorId); diff --git a/build.sh b/build.sh index 10e3fe6..8fe248e 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.50}" +VERSION="${1:-0.12.51}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 1c951f3..c353ee8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.50", + "version": "0.12.51", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx index a9b3b4e..cb0402e 100644 --- a/frontend/src/components/AddChildAliasModal.jsx +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -3,16 +3,25 @@ import { useToast } from '../contexts/ToastContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx'; import { api } from '../utils/api.js'; -export default function AddChildAliasModal({ onClose }) { +export default function AddChildAliasModal({ features = {}, onClose }) { const toast = useToast(); const { user: currentUser } = useAuth(); + const loginType = features.loginType || 'guardian_only'; + const isMixedAge = loginType === 'mixed_age'; + + // ── Guardian-only state (alias form) ────────────────────────────────────── const [aliases, setAliases] = useState([]); - const [editingAlias, setEditingAlias] = useState(null); // null = new entry + const [editingAlias, setEditingAlias] = useState(null); const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' }); const [avatarFile, setAvatarFile] = useState(null); const [saving, setSaving] = useState(false); - // Partner state + // ── Mixed-age state (real minor users) ──────────────────────────────────── + const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine + const [selectedMinorId, setSelectedMinorId] = useState(''); + const [addingMinor, setAddingMinor] = useState(false); + + // ── Partner state (shared) ──────────────────────────────────────────────── const [partner, setPartner] = useState(null); const [selectedPartnerId, setSelectedPartnerId] = useState(''); const [respondSeparately, setRespondSeparately] = useState(false); @@ -20,20 +29,27 @@ export default function AddChildAliasModal({ onClose }) { const [savingPartner, setSavingPartner] = useState(false); useEffect(() => { - Promise.all([ - api.getAliases(), - api.getPartner(), - api.searchUsers(''), - ]).then(([aliasRes, partnerRes, usersRes]) => { - setAliases(aliasRes.aliases || []); + const loads = [api.getPartner(), api.searchUsers('')]; + if (isMixedAge) { + loads.push(api.getMinorPlayers()); + } else { + loads.push(api.getAliases()); + } + Promise.all(loads).then(([partnerRes, usersRes, thirdRes]) => { 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)); + if (isMixedAge) { + setMinorPlayers(thirdRes.users || []); + } else { + setAliases(thirdRes.aliases || []); + } }).catch(() => {}); - }, []); + }, [isMixedAge]); + // ── Helpers ─────────────────────────────────────────────────────────────── const set = k => e => setForm(p => ({ ...p, [k]: e.target.value })); const resetForm = () => { @@ -42,6 +58,47 @@ export default function AddChildAliasModal({ onClose }) { setAvatarFile(null); }; + const lbl = (text, required) => ( + + ); + + // ── Partner handlers ────────────────────────────────────────────────────── + const handleSavePartner = async () => { + setSavingPartner(true); + try { + if (!selectedPartnerId) { + await api.removePartner(); + setPartner(null); + setRespondSeparately(false); + if (!isMixedAge) { + const { aliases: fresh } = await api.getAliases(); + setAliases(fresh || []); + resetForm(); + } else { + const { users: fresh } = await api.getMinorPlayers(); + setMinorPlayers(fresh || []); + } + toast('Spouse/Partner/Co-Parent removed', 'success'); + } else { + const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately); + setPartner(p); + setRespondSeparately(p?.respond_separately || false); + if (!isMixedAge) { + const { aliases: fresh } = await api.getAliases(); + setAliases(fresh || []); + } + toast('Spouse/Partner/Co-Parent saved', 'success'); + } + } catch (e) { + toast(e.message, 'error'); + } finally { + setSavingPartner(false); + } + }; + + // ── Guardian-only alias handlers ────────────────────────────────────────── const handleSelectAlias = (a) => { if (editingAlias?.id === a.id) { resetForm(); return; } setEditingAlias(a); @@ -55,33 +112,7 @@ export default function AddChildAliasModal({ onClose }) { setAvatarFile(null); }; - const handleSavePartner = async () => { - setSavingPartner(true); - try { - if (!selectedPartnerId) { - await api.removePartner(); - setPartner(null); - setRespondSeparately(false); - const { aliases: fresh } = await api.getAliases(); - setAliases(fresh || []); - 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'); - } finally { - setSavingPartner(false); - } - }; - - const handleSave = async () => { + const handleSaveAlias = async () => { if (!form.firstName.trim() || !form.lastName.trim()) return toast('First and last name required', 'error'); setSaving(true); @@ -117,7 +148,7 @@ export default function AddChildAliasModal({ onClose }) { } }; - const handleDelete = async (e, aliasId) => { + const handleDeleteAlias = async (e, aliasId) => { e.stopPropagation(); try { await api.deleteAlias(aliasId); @@ -127,11 +158,35 @@ export default function AddChildAliasModal({ onClose }) { } catch (err) { toast(err.message, 'error'); } }; - const lbl = (text, required) => ( - - ); + // ── Mixed-age minor handlers ────────────────────────────────────────────── + const myMinors = minorPlayers.filter(u => u.guardian_user_id === currentUser?.id); + const availableMinors = minorPlayers.filter(u => !u.guardian_user_id); + + const handleAddMinor = async () => { + if (!selectedMinorId) return; + setAddingMinor(true); + try { + await api.addGuardianChild(parseInt(selectedMinorId)); + const { users: fresh } = await api.getMinorPlayers(); + setMinorPlayers(fresh || []); + setSelectedMinorId(''); + toast('Child added and account activated', 'success'); + } catch (e) { + toast(e.message, 'error'); + } finally { + setAddingMinor(false); + } + }; + + const handleRemoveMinor = async (e, minorId) => { + e.stopPropagation(); + try { + await api.removeGuardianChild(minorId); + const { users: fresh } = await api.getMinorPlayers(); + setMinorPlayers(fresh || []); + toast('Child removed', 'success'); + } catch (err) { toast(err.message, 'error'); } + }; return (
+ No minor players available to link. +
+ )} + > )} - {/* Form section label */} -- {guardianConfirm.guardianName} has been added to this conversation as the guardian of this minor. +
+ The following member{minorConfirm.minorNames.length > 1 ? 's are' : ' is'} a minor:
-+ Their designated guardian(s) will be automatically added to this conversation. Do you want to proceed? +
+