v0.12.43 minor protection added

This commit is contained in:
2026-03-30 16:02:09 -04:00
parent e8e941c436
commit fe836ae69f
18 changed files with 1132 additions and 105 deletions

View File

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

View File

@@ -0,0 +1,41 @@
-- 015_minor_age_protection.sql
-- Adds tables and columns for Guardian Only and Mixed Age login type modes.
-- 1. guardian_approval_required on users (Mixed Age: minor needs approval before unsuspend)
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_approval_required BOOLEAN NOT NULL DEFAULT FALSE;
-- 2. guardian_aliases — children as name aliases under a guardian (Guardian Only mode)
CREATE TABLE IF NOT EXISTS guardian_aliases (
id SERIAL PRIMARY KEY,
guardian_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
date_of_birth DATE,
avatar TEXT,
phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_guardian_aliases_guardian ON guardian_aliases(guardian_id);
-- 3. alias_group_members — links guardian aliases to user groups (e.g. players group)
CREATE TABLE IF NOT EXISTS alias_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_group_id, alias_id)
);
-- 4. event_alias_availability — availability responses for guardian aliases
CREATE TABLE IF NOT EXISTS event_alias_availability (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
note VARCHAR(20),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, alias_id)
);
CREATE INDEX IF NOT EXISTS idx_event_alias_availability_event ON event_alias_availability(event_id);

View File

@@ -4,6 +4,11 @@ const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
async function getLoginType(schema) {
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
return row?.value || 'all_ages';
}
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
@@ -67,7 +72,7 @@ router.get('/', authMiddleware, async (req, res) => {
`);
const privateGroupsRaw = await query(req.schema, `
SELECT g.*, u.name AS owner_name,
SELECT g.*, u.name AS owner_name, ug.id AS source_user_group_id,
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
@@ -80,7 +85,9 @@ router.get('/', authMiddleware, async (req, res) => {
ORDER BY u2.name LIMIT 4
) t) AS member_previews
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private'
LEFT JOIN users u ON g.owner_id=u.id
LEFT JOIN user_groups ug ON ug.dm_group_id=g.id AND g.is_managed=TRUE AND g.is_multi_group IS NOT TRUE
WHERE g.type='private'
ORDER BY last_message_at DESC NULLS LAST
`, [userId]);
@@ -182,8 +189,30 @@ router.post('/', authMiddleware, async (req, res) => {
const groupId = r.rows[0].id;
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
let guardianAdded = false, guardianName = null;
const loginType = await getLoginType(req.schema);
if (loginType === 'mixed_age') {
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) {
const guardianId = otherUserFull.guardian_user_id;
if (guardianId !== userId) {
await exec(req.schema,
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[groupId, guardianId]);
const guardian = await queryOne(req.schema,
'SELECT name, display_name FROM users WHERE id=$1', [guardianId]);
guardianAdded = true;
guardianName = guardian?.display_name || guardian?.name || null;
}
}
}
await emitGroupNew(req.schema, io, groupId);
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
return res.json({ group, guardianAdded, guardianName });
}
// Check for duplicate private group

View File

@@ -241,10 +241,18 @@ router.get('/:id', authMiddleware, async (req, res) => {
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, req.user.id]));
if (event.track_availability && (itm || isMember)) {
event.availability = await query(req.schema, `
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar
// User responses
const userAvail = await query(req.schema, `
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar, FALSE AS is_alias
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
`, [req.params.id]);
// Alias responses (Guardian Only mode)
const aliasAvail = await query(req.schema, `
SELECT eaa.response, eaa.note, eaa.updated_at, ga.id AS alias_id, ga.first_name, ga.last_name, ga.avatar, ga.guardian_id, TRUE AS is_alias
FROM event_alias_availability eaa JOIN guardian_aliases ga ON ga.id=eaa.alias_id WHERE eaa.event_id=$1
`, [req.params.id]);
event.availability = [...userAvail, ...aliasAvail];
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
@@ -253,11 +261,42 @@ router.get('/:id', authMiddleware, async (req, res) => {
JOIN users u ON u.id=ugm.user_id
WHERE eug.event_id=$1
`, [req.params.id]);
const respondedIds = new Set(event.availability.map(r => r.user_id));
const noResponseRows = assignedRows.filter(r => !respondedIds.has(r.user_id));
// Also include alias members
const assignedAliases = await query(req.schema, `
SELECT DISTINCT ga.id AS alias_id, ga.first_name, ga.last_name
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
`, [req.params.id]);
const respondedUserIds = new Set(userAvail.map(r => r.user_id));
const respondedAliasIds = new Set(aliasAvail.map(r => r.alias_id));
const noResponseRows = [
...assignedRows.filter(r => !respondedUserIds.has(r.user_id)),
...assignedAliases.filter(r => !respondedAliasIds.has(r.alias_id)).map(r => ({ ...r, is_alias: true })),
];
event.no_response_count = noResponseRows.length;
event.no_response_users = noResponseRows;
}
// Detect if event targets the players group (for responder select dropdown)
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
const playersGroupId = parseInt(playersRow?.value);
event.has_players_group = !!(playersGroupId && event.user_groups?.some(g => g.id === playersGroupId));
// Detect if event targets the guardians group (so guardian shows own name in select)
const guardiansRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_guardians_group_id'");
const guardiansGroupId = parseInt(guardiansRow?.value);
event.in_guardians_group = !!(guardiansGroupId && event.user_groups?.some(g => g.id === guardiansGroupId) &&
(await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id])));
// Return current user's aliases for the responder dropdown (Guardian Only)
if (event.has_players_group) {
event.my_aliases = await query(req.schema,
'SELECT id,first_name,last_name,avatar FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name',
[req.user.id]
);
}
}
const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
event.my_response = mine?.response || null;
@@ -564,19 +603,31 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
const { response, note } = req.body;
const { response, note, aliasId } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
const itm = await isToolManagerFn(req.schema, req.user);
const inGroup = 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]);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
`, [event.id, req.user.id, response, trimmedNote]);
if (aliasId) {
// Alias response (Guardian Only mode) — verify alias belongs to current user
const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
await exec(req.schema, `
INSERT INTO event_alias_availability (event_id,alias_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (event_id,alias_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
`, [event.id, aliasId, response, trimmedNote]);
} else {
// Regular user response
const itm = await isToolManagerFn(req.schema, req.user);
const inGroup = 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]);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
`, [event.id, req.user.id, response, trimmedNote]);
}
res.json({ success: true, response, note: trimmedNote });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -593,7 +644,14 @@ router.patch('/:id/availability/note', authMiddleware, async (req, res) => {
router.delete('/:id/availability', authMiddleware, async (req, res) => {
try {
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
const { aliasId } = req.query;
if (aliasId) {
const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
await exec(req.schema, 'DELETE FROM event_alias_availability WHERE event_id=$1 AND alias_id=$2', [req.params.id, aliasId]);
} else {
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});

View File

@@ -152,6 +152,26 @@ router.patch('/messages', authMiddleware, adminMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
const VALID_LOGIN_TYPES = ['all_ages', 'guardian_only', 'mixed_age'];
router.patch('/login-type', authMiddleware, adminMiddleware, async (req, res) => {
const { loginType, playersGroupId, guardiansGroupId } = req.body;
if (!VALID_LOGIN_TYPES.includes(loginType)) return res.status(400).json({ error: 'Invalid login type' });
try {
// Enforce: can only change when no non-admin users exist, UNLESS staying on same value
const existing = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_login_type'");
const current = existing?.value || 'all_ages';
if (loginType !== current) {
const { count } = await queryOne(req.schema, "SELECT COUNT(*)::int AS count FROM users WHERE role != 'admin' AND status != 'deleted'");
if (count > 0) return res.status(400).json({ error: 'Login Type can only be changed when no non-admin users exist.' });
}
await setSetting(req.schema, 'feature_login_type', loginType);
await setSetting(req.schema, 'feature_players_group_id', playersGroupId != null ? String(playersGroupId) : '');
await setSetting(req.schema, 'feature_guardians_group_id', guardiansGroupId != null ? String(guardiansGroupId) : '');
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
const { toolManagers } = req.body;
try {

View File

@@ -16,6 +16,17 @@ const uploadAvatar = multer({
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
// Alias avatar upload (separate from user avatar so filename doesn't collide)
const aliasAvatarStorage = multer.diskStorage({
destination: '/app/uploads/avatars',
filename: (req, file, cb) => cb(null, `alias_${req.params.aliasId}_${Date.now()}${path.extname(file.originalname)}`),
});
const uploadAliasAvatar = multer({
storage: aliasAvatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
async function resolveUniqueName(schema, baseName, excludeId = null) {
const existing = await query(schema,
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
@@ -29,11 +40,28 @@ async function resolveUniqueName(schema, baseName, excludeId = null) {
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
// Returns true if the given date-of-birth string corresponds to age <= 15
function isMinorFromDOB(dob) {
if (!dob) return false;
const birth = new Date(dob);
if (isNaN(birth)) return false;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age <= 15;
}
async function getLoginType(schema) {
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
return row?.value || 'all_ages';
}
// List users
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const users = await query(req.schema,
"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"
"SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,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 }); }
@@ -86,7 +114,7 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
// Create user
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { firstName, lastName, email, password, role, phone, isMinor } = req.body;
const { firstName, lastName, email, password, role, phone, dateOfBirth } = 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' });
@@ -94,45 +122,74 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const assignedRole = validRoles.includes(role) ? role : 'member';
const name = `${firstName.trim()} ${lastName.trim()}`;
try {
const loginType = await getLoginType(req.schema);
if (loginType === 'mixed_age' && !dateOfBirth)
return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' });
const dob = dateOfBirth || null;
const isMinor = isMinorFromDOB(dob);
// In mixed_age mode, minors start suspended and need guardian approval
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
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);
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,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]
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,date_of_birth,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING id",
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, isMinor, dob, initStatus]
);
const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId);
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
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,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
res.json({ user });
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,created_at FROM users WHERE id=$1',
[userId]
);
res.json({ user, pendingApproval: initStatus === 'suspended' });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user (general — name components, phone, is_minor, role, optional password reset)
// Update user (general — name components, phone, DOB, 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;
const { firstName, lastName, phone, role, password, dateOfBirth, guardianUserId } = 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 loginType = await getLoginType(req.schema);
if (loginType === 'mixed_age' && !dateOfBirth)
return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' });
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 dob = dateOfBirth || null;
const isMinor = isMinorFromDOB(dob);
const name = `${firstName.trim()} ${lastName.trim()}`;
const resolvedName = await resolveUniqueName(req.schema, name, id);
// Validate guardian if provided
let guardianId = null;
if (guardianUserId) {
const gUser = await queryOne(req.schema, 'SELECT id,is_minor FROM users WHERE id=$1 AND status=$2', [parseInt(guardianUserId), 'active']);
if (!gUser) return res.status(400).json({ error: 'Guardian user not found or inactive' });
if (gUser.is_minor) return res.status(400).json({ error: 'A minor cannot be a guardian' });
guardianId = gUser.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]
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,date_of_birth=$6,guardian_user_id=$7,role=$8,updated_at=NOW() WHERE id=$9',
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, isMinor, dob, guardianId, role, id]
);
if (password && password.length >= 6) {
const hash = bcrypt.hashSync(password, 10);
@@ -143,7 +200,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
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',
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
[id]
);
res.json({ user });
@@ -324,7 +381,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
// Update own profile
router.patch('/me/profile', authMiddleware, async (req, res) => {
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
const { displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone } = req.body;
try {
if (displayName) {
const conflict = await queryOne(req.schema,
@@ -333,12 +390,14 @@ router.patch('/me/profile', authMiddleware, async (req, res) => {
);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
const dob = dateOfBirth || null;
const isMinor = isMinorFromDOB(dob);
await exec(req.schema,
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, date_of_birth=$5, is_minor=$6, phone=$7, updated_at=NOW() WHERE id=$8',
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, dob, isMinor, phone?.trim() || null, req.user.id]
);
const user = await queryOne(req.schema,
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm,date_of_birth,phone FROM users WHERE id=$1',
[req.user.id]
);
res.json({ user });
@@ -376,4 +435,173 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (
}
});
// ── Guardian alias routes (Guardian Only mode) ──────────────────────────────
// List current user's aliases
router.get('/me/aliases', authMiddleware, async (req, res) => {
try {
const aliases = await query(req.schema,
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name',
[req.user.id]
);
res.json({ aliases });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Create alias
router.post('/me/aliases', authMiddleware, async (req, res) => {
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
try {
const r = await queryResult(req.schema,
'INSERT INTO guardian_aliases (guardian_id,first_name,last_name,email,date_of_birth,phone) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id',
[req.user.id, firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null]
);
const aliasId = r.rows[0].id;
// Auto-add alias to players group if designated
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
const playersGroupId = parseInt(playersRow?.value);
if (playersGroupId) {
await exec(req.schema,
'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[playersGroupId, aliasId]
);
}
const alias = await queryOne(req.schema,
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
[aliasId]
);
res.json({ alias });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update alias
router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
const aliasId = parseInt(req.params.aliasId);
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
try {
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Alias not found' });
await exec(req.schema,
'UPDATE guardian_aliases SET first_name=$1,last_name=$2,email=$3,date_of_birth=$4,phone=$5,updated_at=NOW() WHERE id=$6',
[firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null, aliasId]
);
const alias = await queryOne(req.schema,
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
[aliasId]
);
res.json({ alias });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Delete alias
router.delete('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
const aliasId = parseInt(req.params.aliasId);
try {
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Alias not found' });
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE id=$1', [aliasId]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload alias avatar
router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.single('avatar'), async (req, res) => {
const aliasId = parseInt(req.params.aliasId);
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Alias not found' });
const sharp = require('sharp');
const filePath = req.file.path;
const MAX_DIM = 256;
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
let avatarUrl;
if (req.file.size >= 500 * 1024 || needsResize) {
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
require('fs').unlinkSync(filePath);
avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
} else {
avatarUrl = `/uploads/avatars/${req.file.filename}`;
}
await exec(req.schema, 'UPDATE guardian_aliases SET avatar=$1,updated_at=NOW() WHERE id=$2', [avatarUrl, aliasId]);
res.json({ avatarUrl });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Search minor users (Mixed Age — for Add Child in profile)
router.get('/search-minors', authMiddleware, async (req, res) => {
const { q } = req.query;
try {
const users = await query(req.schema,
`SELECT id,name,first_name,last_name,date_of_birth,avatar,phone FROM users
WHERE is_minor=TRUE AND status='suspended' AND guardian_user_id IS NULL AND status!='deleted'
AND (name ILIKE $1 OR first_name ILIKE $1 OR last_name ILIKE $1)
ORDER BY name ASC LIMIT 20`,
[`%${q || ''}%`]
);
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Approve guardian link (Mixed Age — manager+ sets guardian, clears approval flag, unsuspends)
router.patch('/:id/approve-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
const id = parseInt(req.params.id);
try {
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
if (!minor) return res.status(404).json({ error: 'User not found' });
if (!minor.guardian_approval_required) return res.status(400).json({ error: 'No pending approval' });
await exec(req.schema,
"UPDATE users SET guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$1",
[id]
);
await addUserToPublicGroups(req.schema, id);
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
[id]
);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Deny guardian link (Mixed Age — clears guardian, keeps suspended)
router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
const id = parseInt(req.params.id);
try {
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
if (!minor) return res.status(404).json({ error: 'User not found' });
await exec(req.schema,
'UPDATE users SET guardian_approval_required=FALSE,guardian_user_id=NULL,updated_at=NOW() WHERE id=$1',
[id]
);
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
[id]
);
res.json({ user });
} 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);
try {
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
if (!minor) return res.status(404).json({ error: 'Minor user not found' });
if (!minor.is_minor) return res.status(400).json({ error: 'User is not flagged as a minor' });
if (minor.guardian_user_id && !minor.guardian_approval_required)
return res.status(400).json({ error: 'This minor already has an approved guardian' });
await exec(req.schema,
'UPDATE users SET guardian_user_id=$1,guardian_approval_required=TRUE,updated_at=NOW() WHERE id=$2',
[req.user.id, minorId]
);
res.json({ success: true, pendingApproval: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;