const prisma = require('../config/db'); const { generateToken, hashPassword, comparePassword } = require('../config/auth'); const { v4: uuidv4 } = require('uuid'); const { safeErrorMessage } = require('../utils/errorUtils'); const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents'); const { getSettingSync } = require('../utils/settingsCache'); const axios = require('axios'); // ─── Helpers ──────────────────────────────────────────────────────────────── // Org name for email subjects — falls back to Cross Code (the platform vendor) // rather than any specific customer's name when unconfigured. function getOrgName() { return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'); } // Resolve a client IP from the request (works behind proxies) function getClientIp(req) { const forwarded = req.headers['x-forwarded-for']; if (forwarded) return forwarded.split(',')[0].trim(); return req.socket?.remoteAddress || 'unknown'; } const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/; // Fire-and-forget: send a login notification email with approximate geo location async function sendLoginNotification(user, req) { try { const ip = getClientIp(req); const userAgent = req.headers['user-agent'] || 'Unknown device'; const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' }); let location = 'Unknown location'; if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) { try { const geo = await axios.get( `http://ip-api.com/json/${ip}?fields=status,city,regionName,country`, { timeout: 3000 } ); if (geo.data?.status === 'success') { const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean); if (parts.length) location = parts.join(', '); } } catch { /* geo lookup failure is non-fatal */ } } const { sendMail, buildLoginNotificationEmail } = require('../utils/email'); const { buildWALogin } = require('../utils/waMessages'); const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent }); // Security: always email; also WhatsApp if preferred await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content }); const { waText } = require('../utils/notify'); await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {}); } catch (e) { console.warn('[login notification] Failed:', e?.message || e); } } // Fire-and-forget: send welcome email with next upcoming events async function sendWelcomeEmail(user) { try { const events = await prisma.event.findMany({ where: { isActive: true, startDate: { gt: new Date() } }, orderBy: { startDate: 'asc' }, take: 3, select: { title: true, startDate: true }, }); const { sendMail, buildWelcomeEmail } = require('../utils/email'); const { buildWAWelcome } = require('../utils/waMessages'); const content = buildWelcomeEmail({ name: user.name, events }); const { shouldEmail, waText } = require('../utils/notify'); // Welcome is always sent via email; also via WhatsApp if preferred await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content }); await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {}); } catch (e) { console.warn('[welcome email] Failed:', e?.message || e); } } // @desc Register a new user // @route POST /api/users // @access Public const registerUser = async (req, res) => { try { const { name, email, password, phoneNumber, notificationPreference } = req.body; // Normalize phone using SA-aware normalisation const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp'); const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null); // Validate preference — WhatsApp requires a valid phone number const allowedPrefs = ['email', 'whatsapp', 'both']; let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email'; if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) { pref = 'email'; // silently fall back if no valid number } // Check if user already exists const userExists = await prisma.user.findFirst({ where: { OR: [ { email }, ...(phone ? [{ phoneNumber: phone }] : []) ] } }); if (userExists) { res.status(400); throw new Error('User already exists'); } // Hash password const hashedPassword = await hashPassword(password); // Create user const user = await prisma.user.create({ data: { id: uuidv4(), name, email, password: hashedPassword, phoneNumber: phone, notificationPreference: pref, updatedAt: new Date() } }); if (user) { // Send welcome email in the background — don't block the response sendWelcomeEmail(user).catch(() => {}); res.status(201).json({ id: user.id, name: user.name, email: user.email, role: user.role, token: generateToken(user.id, user.role, user.tokenVersion) }); } else { res.status(400); throw new Error('Invalid user data'); } } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Auth user & get token // @route POST /api/users/login // @access Public const loginUser = async (req, res) => { try { const {email, password} = req.body; const rawInput = email; // Normalize phone input — convert SA local format (0xx) → international (27xx) const { normalizeZAPhone } = require('../utils/whatsapp'); const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, ''); // Check for user email or phone const user = await prisma.user.findFirst({ where: { OR: [ { email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []), ] } }); if (!user) { res.status(401); throw new Error('User does not exist'); } // Check if user is active if (!user.isActive) { // If the account has a real email (not a guest placeholder), send an activation link via email if (user.email && !user.email.endsWith('@guest.local')) { try { const token = uuidv4(); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h await prisma.passwordReset.updateMany({ where: { userId: user.id, used: false }, data: { used: true } }); await prisma.passwordReset.create({ data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } }); const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`; const { sendMail, buildAccountActivationEmail } = require('../utils/email'); const content = buildAccountActivationEmail({ name: user.name, activationUrl }); sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content }) .catch(e => console.warn('[activation email] Failed:', e?.message || e)); } catch (e) { console.warn('[activation token] Failed to create activation token:', e?.message || e); } res.status(401); throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.'); } // No real email — if they have a phone number, send the activation link via WhatsApp if (user.phoneNumber) { try { const token = uuidv4(); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h await prisma.passwordReset.updateMany({ where: { userId: user.id, used: false }, data: { used: true } }); await prisma.passwordReset.create({ data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } }); const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`; const orgName = getOrgName(); const waMessage = [ `🔓 *Activate your ${orgName} account*`, '', `Hi ${user.name || 'there'},`, '', `Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`, '', activationUrl, '', `_This link expires in 24 hours._`, ].join('\n'); const { waTextAny } = require('../utils/notify'); waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e)); } catch (e) { console.warn('[activation token WA] Failed to create activation token:', e?.message || e); } res.status(401); throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.'); } res.status(401); throw new Error('Your account has been deactivated'); } const MAX_ATTEMPTS = 5; const LOCK_TIME = 5 * 60 * 1000; // 5 min // Check lock if (user.lockUntil && user.lockUntil > new Date()) { const now = new Date().getTime(); const lockTime = new Date(user.lockUntil).getTime(); const diffMs = lockTime - now; const diffMinutes = Math.ceil(diffMs / (1000 * 60)); throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`); } // Check password const isMatch = await comparePassword(password, user.password); if (!isMatch) { const attempts = user.failedAttempts + 1; await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: attempts, lockUntil: attempts >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_TIME) : null, }, }); throw new Error("Invalid email or password"); } // ✅ SUCCESS → reset attempts const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true }, }); // Send login notification in the background sendLoginNotification(updated, req).catch(() => {}); logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); res.json({ id: updated.id, name: updated.name, email: updated.email, role: updated.role, token: generateToken(updated.id, updated.role, updated.tokenVersion) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get user profile // @route GET /api/users/profile // @access Private const getUserProfile = async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { id: true, name: true, email: true, role: true, phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true } }); if (user) { res.json(user); } else { res.status(404); throw new Error('User not found'); } } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Update user profile // @route PUT /api/users/profile // @access Private const updateUserProfile = async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.user.id } }); if (!user) { res.status(404); throw new Error('User not found'); } const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {}; // If a password change is requested, verify current password for safety let newHashedPassword = undefined; if (typeof password === 'string' && password.trim().length > 0) { const newPw = password.trim(); if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) { res.status(400); throw new Error('Current password is required to set a new password'); } const matches = await comparePassword(currentPassword, user.password); if (!matches) { res.status(400); throw new Error('Current password is incorrect'); } if (newPw.length < 8) { res.status(400); throw new Error('New password must be at least 8 characters long'); } newHashedPassword = await hashPassword(newPw); } // Normalize phone const { normalizeZAPhone } = require('../utils/whatsapp'); let newPhone = user.phoneNumber; if (phoneNumber !== undefined) { newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null; } // Validate notification preference — WhatsApp requires a valid SA phone number const { resolveNotificationPreference } = require('../utils/notificationPreference'); const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference); // Update user data const updatedUser = await prisma.user.update({ where: { id: req.user.id }, data: { name: name || user.name, email: email || user.email, password: newHashedPassword ? newHashedPassword : user.password, phoneNumber: newPhone, notificationPreference: newPref, updatedAt: new Date() }, select: { id: true, name: true, email: true, role: true, phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true, tokenVersion: true } }); // If the password was changed, send a security alert email (fire-and-forget) if (newHashedPassword) { const { sendMail, buildPasswordChangedEmail } = require('../utils/email'); const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || ''); const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail }); sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content }) .catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e)); const { waText } = require('../utils/notify'); waText(updatedUser, content.text).catch(() => {}); logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); } res.json({ ...updatedUser, token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get all users // @route GET /api/users // @access Private/Admin const getUsers = async (req, res) => { try { const page = Math.max(1, parseInt(req.query.page) || 1); const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100)); const skip = (page - 1) * limit; const select = { id: true, name: true, email: true, role: true, phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true }; // Build filters const where = {}; if (req.query.isActive !== undefined) { where.isActive = req.query.isActive === 'true'; } if (req.query.role) { where.role = req.query.role; } if (req.query.search) { const s = req.query.search.trim(); where.OR = [ { name: { contains: s, mode: 'insensitive' } }, { email: { contains: s, mode: 'insensitive' } }, { phoneNumber: { contains: s, mode: 'insensitive' } }, ]; } const [users, total] = await prisma.$transaction([ prisma.user.findMany({ where, select, orderBy: { name: 'asc' }, skip, take: limit }), prisma.user.count({ where }) ]); res.json({ data: users, total, page, limit, pages: Math.ceil(total / limit) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Check whether an account already exists for a given email and/or phone // @route GET /api/users/check-exists // @access Private/Supervisor const checkUserExists = async (req, res) => { try { const email = typeof req.query.email === 'string' ? req.query.email.trim() : ''; const rawPhone = typeof req.query.phone === 'string' ? req.query.phone.trim() : ''; const { normalizeZAPhone } = require('../utils/whatsapp'); const phone = normalizeZAPhone(rawPhone) || (rawPhone ? rawPhone.replace(/\D/g, '') : ''); const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null); const searchClauses = [ ...(email ? [{ email }] : []), ...(phone ? [{ phoneNumber: phone }] : []), ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []), ]; if (searchClauses.length === 0) { return res.json({ exists: false }); } const existingUser = await prisma.user.findFirst({ where: { OR: searchClauses }, select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true }, }); const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local'); const hasPhone = !!existingUser?.phoneNumber; res.json({ exists: !!existingUser, hasEmail, hasPhone, // Safe-to-display fields only, for autofilling a lookup form — never the password. // Guest placeholder emails are withheld the same way hasEmail already treats them. // `id` lets the kiosk tell two different matched accounts apart (e.g. when the // typed email and phone number resolve to different people) — it's never shown, // only compared client-side, and this endpoint is already Private/Supervisor. user: existingUser ? { id: existingUser.id, name: existingUser.name, email: hasEmail ? existingUser.email : null, phoneNumber: hasPhone ? existingUser.phoneNumber : null, notificationPreference: existingUser.notificationPreference, } : null, }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get user by ID // @route GET /api/users/:id // @access Private/Admin const getUserById = async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.params.id }, select: { id: true, name: true, email: true, role: true, phoneNumber: true, createdAt: true, updatedAt: true, isActive: true } }); if (user) { res.json(user); } else { res.status(404); throw new Error('User not found'); } } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Update user // @route PUT /api/users/:id // @access Private/Admin const updateUser = async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.params.id } }); if (!user) { res.status(404); throw new Error('User not found'); } const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body; const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber; const { resolveNotificationPreference } = require('../utils/notificationPreference'); const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference); // Prepare data update, allow admin to set a new password const data = { name: name || user.name, email: email || user.email, role: role || user.role, isActive: isActive !== undefined ? isActive : user.isActive, phoneNumber: newPhone, notificationPreference: newPref, updatedAt: new Date() }; if (password && typeof password === 'string' && password.trim().length > 0) { data.password = await hashPassword(password.trim()); } const updatedUser = await prisma.user.update({ where: { id: req.params.id }, data, select: { id: true, name: true, email: true, role: true, phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true } }); res.json(updatedUser); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Delete user // @route DELETE /api/users/:id // @access Private/Admin const deleteUser = async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.params.id } }); if (!user) { res.status(404); throw new Error('User not found'); } // Instead of deleting, we deactivate the user await prisma.user.update({ where: { id: req.params.id }, data: { isActive: false, updatedAt: new Date() } }); res.json({ message: 'User deactivated' }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Anonymize user data (GDPR / "right to be forgotten") // @route POST /api/users/:id/anonymize // @access Private/Admin const anonymizeUser = async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.params.id } }); if (!user) { res.status(404); throw new Error('User not found'); } await prisma.user.update({ where: { id: req.params.id }, data: { name: 'Deleted User', email: `deleted-${req.params.id}@deleted.local`, phoneNumber: null, isActive: false, tokenVersion: { increment: 1 }, updatedAt: new Date() } }); res.json({ message: 'User data deleted' }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Request password reset // @route POST /api/users/forgot // @access Public const requestPasswordReset = async (req, res) => { try { const { email } = req.body; if (!email || typeof email !== 'string') { res.status(400); throw new Error('Email is required'); } const user = await prisma.user.findUnique({ where: { email } }); // Explicitly check existence as requested if (!user) { return res.status(404).json({ message: 'Email not found' }); } // Create token valid for 1 hour const token = uuidv4(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // If PasswordReset model isn't available (migration not run), return clear error if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') { console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.'); return res.status(500).json({ message: 'Password reset is not available. Please contact support.' }); } // Invalidate previous tokens (optional) await prisma.passwordReset.updateMany({ where: { userId: user.id, used: false, expiresAt: { gt: new Date() } }, data: { used: true } }); await prisma.passwordReset.create({ data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } }); const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`; const { sendMail, buildPasswordResetEmail } = require('../utils/email'); const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl }); // Security: always email sendMail({ to: user.email, subject: 'Reset your password', ...emailContent }) .catch(e => console.warn('[password reset email] Failed:', e?.message || e)); // Also WhatsApp if preferred (security message — sent in addition to email) const { waText } = require('../utils/notify'); waText(user, emailContent.text).catch(() => {}); return res.json({ message: 'If that email exists, a password reset link has been sent.' }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Reset password using token // @route POST /api/users/reset // @access Public const resetPassword = async (req, res) => { try { const { token, password } = req.body; if (!token || !password) { res.status(400); throw new Error('Token and new password are required'); } // If PasswordReset model isn't available, avoid crashing and return a generic error if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function' || typeof prisma.passwordReset.update !== 'function') { res.status(400); throw new Error('Invalid or expired token'); } const reset = await prisma.passwordReset.findUnique({ where: { token } }); if (!reset || reset.used || reset.expiresAt < new Date()) { res.status(400); throw new Error('Invalid or expired token'); } const user = await prisma.user.findUnique({ where: { id: reset.userId } }); if (!user || !user.isActive) { res.status(400); throw new Error('User not found or inactive'); } const newHashed = await hashPassword(password); await prisma.$transaction([ prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }), prisma.passwordReset.update({ where: { token }, data: { used: true } }) ]); logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); res.json({ message: 'Password has been reset successfully' }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Activate account using token (for inactive/guest accounts) // @route POST /api/users/activate // @access Public const activateAccount = async (req, res) => { try { const { token, password } = req.body; if (!token || !password) { res.status(400); throw new Error('Token and password are required'); } if (typeof password !== 'string' || password.trim().length < 8) { res.status(400); throw new Error('Password must be at least 8 characters'); } if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function') { res.status(400); throw new Error('Invalid or expired token'); } const reset = await prisma.passwordReset.findUnique({ where: { token } }); if (!reset || reset.used || reset.expiresAt < new Date()) { res.status(400); throw new Error('Invalid or expired activation link'); } const user = await prisma.user.findUnique({ where: { id: reset.userId } }); if (!user) { res.status(400); throw new Error('User not found'); } const newHashed = await hashPassword(password.trim()); await prisma.$transaction([ prisma.user.update({ where: { id: user.id }, data: { password: newHashed, isActive: true, updatedAt: new Date() } }), prisma.passwordReset.update({ where: { token }, data: { used: true } }) ]); const updated = await prisma.user.findUnique({ where: { id: user.id }, select: { id: true, name: true, email: true, role: true, tokenVersion: true } }); // Send welcome email (skip guest/placeholder accounts) if (updated.email && !updated.email.endsWith('@guest.local')) { sendWelcomeEmail(updated).catch(() => {}); } res.json({ message: 'Account activated successfully.', id: updated.id, name: updated.name, email: updated.email, role: updated.role, token: generateToken(updated.id, updated.role, updated.tokenVersion) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Revoke all active sessions for the logged-in user (increments tokenVersion) // @route POST /api/users/revoke-sessions // @access Private const revokeMySession = async (req, res) => { try { const updated = await prisma.user.update({ where: { id: req.user.id }, data: { tokenVersion: { increment: 1 } }, select: { id: true, role: true, tokenVersion: true }, }); // Bumping tokenVersion invalidates every existing token, including the one // this request just used. Issue a fresh token for the current device so it // isn't logged out too. res.json({ message: 'All other sessions have been signed out.', token: generateToken(updated.id, updated.role, updated.tokenVersion), }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Admin: revoke all sessions for a specific user // @route POST /api/users/:id/revoke-sessions // @access Private/Admin const adminRevokeUserSessions = async (req, res) => { try { const target = await prisma.user.findUnique({ where: { id: req.params.id } }); if (!target) { res.status(404); throw new Error('User not found'); } await prisma.user.update({ where: { id: req.params.id }, data: { tokenVersion: { increment: 1 } }, }); res.json({ message: `Sessions revoked for ${target.name}.` }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Close (and optionally anonymise) the logged-in user's own account // @route POST /api/users/close-account // @access Private const closeAccount = async (req, res) => { try { const { deleteData = false, password } = req.body || {}; // Re-verify password before allowing account closure const user = await prisma.user.findUnique({ where: { id: req.user.id } }); if (!user) { res.status(404); throw new Error('User not found'); } const passwordOk = await comparePassword(password, user.password); if (!passwordOk) { res.status(400); throw new Error('Incorrect password'); } // Capture real details before any anonymisation so the email goes to the right address const realEmail = user.email; const realName = user.name; if (deleteData) { // Anonymise: wipe all personal data while keeping the row intact for referential integrity await prisma.user.update({ where: { id: req.user.id }, data: { name: 'Deleted User', email: `deleted-${uuidv4()}@deleted.invalid`, password: '', phoneNumber: null, isActive: false, tokenVersion: { increment: 1 }, updatedAt: new Date(), }, }); // Send closure confirmation to the real address (before it was wiped) if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) { const { sendMail, buildAccountClosedEmail } = require('../utils/email'); const { waText } = require('../utils/notify'); const { buildWAAccountClosed } = require('../utils/waMessages'); const content = buildAccountClosedEmail({ name: realName, dataDeleted: true }); sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {}); // WhatsApp while we still have phone (send before data wipe completes in-flight) waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {}); } return res.json({ message: 'Your account and personal data have been removed.' }); } else { // Soft-deactivate only await prisma.user.update({ where: { id: req.user.id }, data: { isActive: false, tokenVersion: { increment: 1 }, updatedAt: new Date(), }, }); // Send closure confirmation if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) { const { sendMail, buildAccountClosedEmail } = require('../utils/email'); const { waText } = require('../utils/notify'); const { buildWAAccountClosed } = require('../utils/waMessages'); const content = buildAccountClosedEmail({ name: realName, dataDeleted: false }); sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {}); waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {}); } return res.json({ message: 'Your account has been deactivated.' }); } } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Recent account activity (logins, password changes) for the current user // @route GET /api/users/activity // @access Private const getMyActivity = async (req, res) => { try { const events = await getRecentSecurityEvents(req.user.id, 10); res.json(events); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; module.exports = { registerUser, loginUser, getUserProfile, updateUserProfile, getUsers, checkUserExists, getUserById, updateUser, deleteUser, anonymizeUser, requestPasswordReset, resetPassword, activateAccount, revokeMySession, adminRevokeUserSessions, closeAccount, getMyActivity, };