Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,934 @@
|
||||
const prisma = require('../config/db');
|
||||
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 Hope Events 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 Hope Events!', ...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 Hope Events 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 = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
|
||||
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(() => {});
|
||||
|
||||
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, isValidZAPhone } = 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 allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let newPref = user.notificationPreference;
|
||||
if (notificationPreference !== undefined) {
|
||||
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
|
||||
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
|
||||
newPref = 'email';
|
||||
}
|
||||
}
|
||||
|
||||
// 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 { getSettingSync } = require('../utils/settingsCache');
|
||||
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 Hope Events 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(() => {});
|
||||
}
|
||||
|
||||
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: { email: true, phoneNumber: true },
|
||||
});
|
||||
|
||||
res.json({
|
||||
exists: !!existingUser,
|
||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
||||
hasPhone: !!existingUser?.phoneNumber,
|
||||
});
|
||||
} 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 } = req.body;
|
||||
|
||||
// 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: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
|
||||
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,
|
||||
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 } })
|
||||
]);
|
||||
|
||||
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 Hope Events 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 Hope Events 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) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
checkUserExists,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
anonymizeUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
activateAccount,
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
};
|
||||
Reference in New Issue
Block a user