Compare commits

...

2 Commits

Author SHA1 Message Date
7276228a98 v0.12.24 minor user manager buf fixes 2026-03-24 16:10:27 -04:00
e77176841c v0.12.23 group manager display update 2026-03-24 15:55:48 -04:00
6 changed files with 56 additions and 45 deletions

View File

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

View File

@@ -0,0 +1,3 @@
-- Migration 010: Date of birth and guardian fields
ALTER TABLE users ADD COLUMN IF NOT EXISTS date_of_birth DATE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;

View File

@@ -279,7 +279,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
}
}
if (Array.isArray(memberIds) && ug.dm_group_id) {
if (Array.isArray(memberIds)) {
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
const newIds = new Set(memberIds.map(Number).filter(Boolean));
if (defaultAdmin) newIds.delete(defaultAdmin.id); // default admin cannot be in user groups
@@ -289,32 +289,36 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
for (const uid of newIds) {
if (!currentSet.has(uid)) {
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, uid]);
await addUserSilent(req.schema, ug.dm_group_id, uid);
if (ug.dm_group_id) await addUserSilent(req.schema, ug.dm_group_id, uid);
addedUids.push(uid);
}
}
for (const uid of currentSet) {
if (!newIds.has(uid)) {
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, uid]);
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, uid]);
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id });
if (ug.dm_group_id) {
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, uid]);
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id });
}
removedUids.push(uid);
}
}
// Notification rule: single user → named message; multiple users → one generic message
if (addedUids.length === 1) {
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [addedUids[0]]);
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
} else if (addedUids.length > 1) {
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${addedUids.length} new members have joined the conversation.`);
}
if (removedUids.length === 1) {
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [removedUids[0]]);
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
} else if (removedUids.length > 1) {
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${removedUids.length} members have been removed from the conversation.`);
// Notification rule (only if DM exists): single user → named message; multiple → generic
if (ug.dm_group_id) {
if (addedUids.length === 1) {
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [addedUids[0]]);
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
} else if (addedUids.length > 1) {
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${addedUids.length} new members have joined the conversation.`);
}
if (removedUids.length === 1) {
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [removedUids[0]]);
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
} else if (removedUids.length > 1) {
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${removedUids.length} members have been removed from the conversation.`);
}
}
// Propagate to multi-group DMs

View File

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

View File

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

View File

@@ -119,9 +119,10 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
const [lastName, setLastName] = useState(user?.last_name || '');
const [email, setEmail] = useState(user?.email || '');
const [phone, setPhone] = useState(user?.phone || '');
const [role, setRole] = useState(user?.role || 'member');
const [isMinor, setIsMinor] = useState(!!user?.is_minor);
const [password, setPassword] = useState('');
const [role, setRole] = useState(user?.role || 'member');
const [dob, setDob] = useState(user?.date_of_birth || '');
const [guardianId, setGuardianId] = useState(user?.guardian_user_id || '');
const [password, setPassword] = useState('');
const [pwEnabled, setPwEnabled] = useState(!isEdit);
const [saving, setSaving] = useState(false);
const [selectedGroupIds, setSelectedGroupIds] = useState(new Set());
@@ -155,8 +156,6 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
if (!lastName.trim()) return toast('Last name is required', 'error');
if (!isValidPhone(phone)) return toast('Invalid phone number', 'error');
if (!['member', 'admin', 'manager'].includes(role)) return toast('Role is required', 'error');
if (!isEdit && (!password || password.length < 6))
return toast('Password must be at least 6 characters', 'error');
if (isEdit && pwEnabled && (!password || password.length < 6))
return toast('New password must be at least 6 characters', 'error');
@@ -167,16 +166,15 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
isMinor,
role,
...(pwEnabled && password ? { password } : {}),
});
// Sync group memberships: add newly selected, remove deselected
for (const gId of selectedGroupIds) {
if (!origGroupIds.has(gId)) await api.addUserToGroup(gId, user.id).catch(() => {});
if (!origGroupIds.has(gId)) await api.addUserToGroup(gId, user.id);
}
for (const gId of origGroupIds) {
if (!selectedGroupIds.has(gId)) await api.removeUserFromGroup(gId, user.id).catch(() => {});
if (!selectedGroupIds.has(gId)) await api.removeUserFromGroup(gId, user.id);
}
toast('User updated', 'success');
} else {
@@ -185,13 +183,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
lastName: lastName.trim(),
email: email.trim(),
phone: phone.trim(),
isMinor,
role,
password,
...(password ? { password } : {}),
});
// Add to selected groups
for (const gId of selectedGroupIds) {
await api.addUserToGroup(gId, newUser.id).catch(() => {});
await api.addUserToGroup(gId, newUser.id);
}
toast('User created', 'success');
}
@@ -273,13 +270,23 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
</div>
</div>
{/* Row 4: Is minor */}
<div style={{ marginBottom:12 }}>
<label style={{ display:'flex', alignItems:'center', gap:8, cursor:'pointer', fontSize:14, color:'var(--text-primary)', userSelect:'none' }}>
<input type="checkbox" checked={isMinor} onChange={e => setIsMinor(e.target.checked)}
style={{ accentColor:'var(--primary)', width:15, height:15 }} />
User is a minor
</label>
{/* Row 4: DOB + Guardian */}
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Date of Birth', false, '(optional)')}
<input className="input" type="text" placeholder="YYYY-MM-DD"
value={dob} onChange={e => setDob(e.target.value)}
disabled
style={{ opacity:0.5, cursor:'not-allowed' }} />
</div>
<div>
{lbl('Guardian', false, '(optional)')}
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
disabled
style={{ opacity:0.5, cursor:'not-allowed' }}>
<option value=""> Select guardian </option>
</select>
</div>
</div>
{/* Row 4b: User Groups */}
@@ -303,9 +310,9 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
{/* Row 5: Password */}
<div style={{ marginBottom:16 }}>
{lbl('Password',
(!isEdit) || (isEdit && pwEnabled),
isEdit && pwEnabled,
isEdit && !pwEnabled ? '(not changing — click Reset Password to set a new one)' :
!isEdit ? `(blank = ${userPass})` : null
!isEdit ? `(optional — blank uses system default)` : null
)}
<div style={{ opacity: pwEnabled ? 1 : 0.55 }}>
<PasswordInput
@@ -335,9 +342,6 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
Cancel Reset
</button>
)}
<button className="btn btn-secondary" onClick={onCancel} style={{ marginLeft:'auto' }}>
Cancel
</button>
</div>
{/* Row 7 (edit only): Last login + must change password */}
@@ -393,7 +397,7 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
finally { setLoading(false); }
};
const codeStyle = { fontSize:12, color:'var(--text-secondary)', display:'block', background:'var(--surface)', padding:'6px 8px', borderRadius:4, border:'1px solid var(--border)', whiteSpace:'pre', fontFamily:'monospace', marginBottom:4 };
const codeStyle = { fontSize:12, color:'var(--text-secondary)', display:'block', background:'var(--surface)', padding:'6px 8px', borderRadius:4, border:'1px solid var(--border)', whiteSpace:'pre-wrap', overflowWrap:'anywhere', fontFamily:'monospace', marginBottom:4 };
return (
<div style={{ maxWidth:580, display:'flex', flexDirection:'column', gap:16 }}>
@@ -444,7 +448,7 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
</ul>
</div>
<p style={{ color:'var(--text-tertiary)', marginTop:2 }}>
Optional field defaults: password = <strong>{userPass}</strong>, role = member, usergroup = (none), minor = (none)
Optional field defaults: password = <strong>{userPass}</strong>, role = member, usergroup = (none)
</p>
</div>
)}