v0.12.12 user manager update

This commit is contained in:
2026-03-24 07:34:03 -04:00
parent dec24eb842
commit 2e3e4100f5
8 changed files with 327 additions and 161 deletions

View File

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

View File

@@ -0,0 +1,17 @@
-- Migration 009: Extended user profile fields
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_minor BOOLEAN NOT NULL DEFAULT FALSE;
-- Back-fill first_name / last_name from existing combined name for non-deleted users
UPDATE users
SET
first_name = SPLIT_PART(TRIM(name), ' ', 1),
last_name = CASE
WHEN POSITION(' ' IN TRIM(name)) > 0
THEN NULLIF(TRIM(SUBSTR(TRIM(name), POSITION(' ' IN TRIM(name)) + 1)), '')
ELSE NULL
END
WHERE first_name IS NULL
AND TRIM(name) NOT IN ('Deleted User', '');

View File

@@ -33,7 +33,7 @@ function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const users = await query(req.schema,
"SELECT id,name,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC"
"SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
);
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
@@ -82,26 +82,66 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
// Create user
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, email, password, role } = req.body;
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
const { firstName, lastName, email, password, role, phone, isMinor } = req.body;
if (!firstName?.trim() || !lastName?.trim() || !email)
return res.status(400).json({ error: 'First name, last name and email required' });
if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
const validRoles = ['member', 'admin', 'manager'];
const assignedRole = validRoles.includes(role) ? role : 'member';
const name = `${firstName.trim()} ${lastName.trim()}`;
try {
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email = $1 AND status != 'deleted'", [email]);
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = await resolveUniqueName(req.schema, name.trim());
const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
const hash = bcrypt.hashSync(pw, 10);
const r = await queryResult(req.schema,
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
[resolvedName, email, hash, role === 'admin' ? 'admin' : 'member']
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',TRUE) RETURNING id",
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor]
);
const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId);
if (role === 'admin') {
if (assignedRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
}
const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
const user = await queryOne(req.schema, 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user (general — name components, phone, is_minor, role, optional password reset)
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
const { firstName, lastName, phone, isMinor, role, password } = req.body;
if (!firstName?.trim() || !lastName?.trim())
return res.status(400).json({ error: 'First and last name required' });
const validRoles = ['member', 'admin', 'manager'];
if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' });
try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin && role !== 'admin')
return res.status(403).json({ error: 'Cannot change default admin role' });
const name = `${firstName.trim()} ${lastName.trim()}`;
const resolvedName = await resolveUniqueName(req.schema, name, id);
await exec(req.schema,
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7',
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, !!isMinor, role, id]
);
if (password && password.length >= 6) {
const hash = bcrypt.hashSync(password, 10);
await exec(req.schema, 'UPDATE users SET password=$1,must_change_password=TRUE,updated_at=NOW() WHERE id=$2', [hash, id]);
}
if (role === 'admin' && target.role !== 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
}
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
[id]
);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -159,7 +199,7 @@ router.patch('/:id/name', authMiddleware, teamManagerMiddleware, async (req, res
// Patch role
router.patch('/:id/role', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { role } = req.body;
if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (!['member','admin','manager'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!target) return res.status(404).json({ error: 'User not found' });
@@ -216,6 +256,10 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
status = 'deleted',
email = $1,
name = 'Deleted User',
first_name = NULL,
last_name = NULL,
phone = NULL,
is_minor = FALSE,
display_name = NULL,
avatar = NULL,
about_me = NULL,