v0.12.43 minor protection added
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.12.42",
|
||||
"version": "0.12.43",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
41
backend/src/models/migrations/015_minor_age_protection.sql
Normal file
41
backend/src/models/migrations/015_minor_age_protection.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user