'use strict'; const User = require('../model/user'), UserModelFactory = require('../model/user_model_factory'), App = require('../model/application'), cache = require('../helpers/mem_cache'), assert = require('assert'), { verifyAsync, sign } = require('../helpers/jwt_async.js'), moment = require('moment'), ObjectId = require('mongodb').ObjectId, { Errors, UserTypes, DEFAULT_LANG, TrialTypes, emailRegex } = require('../helpers/constants'), { AppError, AppParamError, AppAuthError, AppInputError } = require('../helpers/app_error'), { ensureSingleBillingAddress } = require('../helpers/user_helper'), utils = require('../helpers/utils.js'), mailer = require('../helpers/mailer'), env = require('../helpers/env'), subscriptionCtl = require("./subscription"), mongoUtil = require('../helpers/mongo'), debug = require('debug')('agm:user'), Country = require('../model/country'); async function ensureParentExists(user) { if (!user || user.kind <= 1 || !user.parent || !ObjectId.isValid(user.parent)) return; const pUser = await User.findById(ObjectId(user.parent), null, { lean: true }); if (!pUser) AppError.throw(Errors.PARENT_NOT_EXIST); return pUser; } async function clearTempData(uid) { if (ObjectId.isValid(uid)) await App.deleteMany({ byUser: ObjectId(uid), $or: [{ status: -1 }, { status: 0 }] }); } async function createUser_post(req, res) { const _user = req.body; assert(_user, AppParamError.create()); delete _user._id; await ensureParentExists(_user); const newUser = new User(_user); const user = await newUser.save(); res.json(user); } async function getUser_get(req, res) { if (!ObjectId.isValid(req.params.id)) AppParamError.throw(Errors.INVALID_PARAM); const view = req.query.view; const legacyWithAddresses = utils.stringToBoolean(req.query.withAddresses) || false; let query = User.findById(req.params.id, null, { lean: true }) .populate({ path: 'Country', select: 'code name -_id' }); if (view === 'profile') { query = query.select('_id username contact name phone email country'); } else if (view === 'edit') { query = query.select('_id kind active username password name address country phone fax email contact licence parent'); } else if (view === 'billing') { query = query.select('_id name address country'); } else if (legacyWithAddresses) { // Full document including addresses — no select restriction } else { // Default: full document without addresses query = query.select('-addresses'); } const _user = await query; // For Partner System Users, populate partner and parent (customer) fields if (_user && _user.kind === UserTypes.PARTNER_SYSTEM_USER) { await User.populate(_user, [ { path: 'partner', select: 'name partnerCode' }, { path: 'parent', select: 'name username' } ]); } res.json(_user); } async function updateUser_put(req, res) { const _user = req.body, id = req.params.id; if (!_user || !ObjectId.isValid(id)) AppParamError.throw(); delete req.body._id; if (utils.isBlank(_user.username) && !utils.isBlank(_user.password)) _user.password = undefined; if (!_user.hasOwnProperty('active')) _user.active = true; // Handle cases where addresses contain country objects and billing address logic if (_user.addresses && Array.isArray(_user.addresses)) { _user.addresses = _user.addresses.map(address => { if (address.country && typeof address.country === 'object' && address.country.code) { return { ...address, country: address.country.code }; } return address; }); // Ensure only one billing address exists using reusable utility _user.addresses = ensureSingleBillingAddress(_user.addresses); } let kind = _user.kind; if (!kind) { const theUser = await User.findById(id, 'kind', { lean: true }); if (!theUser) AppError.throw(Errors.USER_NOT_FOUND); kind = theUser.kind; } const UserModel = UserModelFactory.create(kind); const user = await UserModel.findOneAndUpdate({ _id: ObjectId(id) }, _user, { new: true, lean: true }); res.json(user); } async function deleteUser(req, res) { const _id = req?.params?.id; if (!_id || !ObjectId.isValid(_id)) AppParamError.throw(); // Partner System Users are soft-deleted (active: false) to preserve audit trail const kindDoc = await User.findById(_id, 'kind', { lean: true }); if (!kindDoc) AppParamError.throw(Errors.USER_NOT_FOUND); if (kindDoc.kind === UserTypes.PARTNER_SYSTEM_USER) { await User.findByIdAndUpdate(_id, { active: false }, { new: true }).lean(); cache.delete(_id); return res.json({ ok: true }); } const user = await User.findByIdAndRemove({ _id: ObjectId(_id) }); if (user) await user.remove(); cache.delete(_id); res.json({ ok: true }); } async function login_post(req, res) { const username = req.body?.username?.trim(); const password = req.body?.password; if (!username || !password) AppAuthError.throw(Errors.WRONG_CREDENTIAL); const _options = { password, ...mongoUtil.getTextFieldFilter('username', username) }; const _user = await User.findOne(_options, null, { lean: true }); if (!_user) AppAuthError.throw(Errors.WRONG_CREDENTIAL); if (_user.kind == UserTypes.DEVICE && (req.body.dev && req.body.dev == 'web')) AppAuthError.throw(Errors.INVALID_ACCOUNT); const hasParent = Boolean(_user && _user.parent && _user.kind >= 2); // The applicator user let _parentUser; if (hasParent) { _parentUser = await User.findOne({ _id: _user.parent }, null, { lean: true }); } else if (_user.kind === UserTypes.ADMIN || _user.kind === UserTypes.APP) { _parentUser = _user; } if (hasParent && !_parentUser) AppAuthError.throw(Errors.WRONG_CREDENTIAL); await clearTempData(_user._id); const isActive = hasParent ? (_parentUser.active && _user.active) : _user.active; let authUser; if (isActive) { const premium = _parentUser.premium || 0; // To be removed soon const token = sign({ uid: _user._id, ut: _user.kind }, env.TOKEN_SECRET, {}); // const memberInfo = {}; const memberInfo = _user.kind !== UserTypes.ADMIN ? await subscriptionCtl.resolvePaymentUser(_parentUser) : {}; let userLang = _user.lang; // #1518, Make sure only appy the language not it has not ever selected. if (!userLang) { const inUserLang = req.body.lang && req.body.lang.toLowerCase(); userLang = inUserLang && req.body.lang && ['en', 'pt', 'es'].includes(inUserLang) ? inUserLang : DEFAULT_LANG; } if (_user.kind === UserTypes.DEVICE) { authUser = { token: token }; } else { authUser = { _id: _user._id, token: token, roles: [_user.kind], pui: _parentUser ? _parentUser._id : _user._id, lang: userLang, billable: _parentUser.billable, pre: premium, membership: memberInfo, contact: _user.contact }; } // Update user last login info await User.updateOne({ _id: _user._id }, { loggedInAt: Date.now(), lang: userLang }, { timestamps: false }); // Set the authenticated user info into the session cache cache.set(_user._id.toHexString(), { puid: _parentUser._id.toHexString(), billable: _parentUser.billable, premium: premium, membership: memberInfo, lang: userLang || DEFAULT_LANG, ts: moment.utc().unix(), kind: _user.kind }); } else { AppAuthError.throw(Errors.ACC_INACTIVE); } res.json(authUser); } async function isUserNameExists_post(req, res) { if (!req.body || !req.body.username) AppParamError.throw(); const user = await User.findOne(mongoUtil.getTextFieldFilter('username', req.body.username), '_id', { lean: true }); res.json(user ? 1 : 0); } async function search_post(req, res) { const _options = { markedDelete: { $ne: true } }; const { byPuid, kind } = req.body; if (byPuid && ObjectId.isValid(byPuid)) { _options.parent = ObjectId(byPuid); } else { AppParamError.throw(); } if (kind) _options.kind = kind; else _options.kind = { $in: [UserTypes.APP_ADM, UserTypes.OFFICER, UserTypes.INSPECTOR, UserTypes.PARTNER_SYSTEM_USER] }; const users = await User.find(_options, '-password').lean(); res.json(users || []); } async function clearTempData_post(req, res) { await clearTempData(req.uid); if (cache.get(req.uid)) cache.delete(req.uid); res.json({ ok: true }); } async function setUserLanguage_post(req, res) { const lang = req.body.lang || DEFAULT_LANG; const user = await User.findByIdAndUpdate(ObjectId(req.uid), { $set: { lang: String(lang).trim() } }, { new: true, lean: true }); // Update user language in the cache const cUser = cache.get(req.uid); if (cUser && cUser.lang && cUser.lang != user.lang) { cUser.lang = user.lang; cache.set(req.uid, cUser); } res.json(user); } async function getUserDetail_post(req, res) { const username = req.body?.username; if (!req.body || !username) return res.json(null); const user = await User.findOne(mongoUtil.getTextFieldFilter('username', username), { password: 0 }) .populate('parent', 'username') .populate({ path: 'Country', select: 'code name -_id' }) .lean(); res.json(user); } function getHostUrlFromReq(req) { let host = `https://${req.hostname}`; if (!req.app.isProd) host += ':4200'; return host; } async function mailPwdReset_post(req, res) { const userEmail = req.body?.email?.trim(); if (!userEmail || emailRegex.test(userEmail) === false) AppParamError.throw(Errors.INVALID_EMAIL); const user = await User.findOne({ ...mongoUtil.getTextFieldFilter('username', userEmail) }).lean(); try { if (!user) AppError.throw(Errors.USER_NOT_FOUND); if (user.active === false || user.markedDelete === true) AppError.throw(Errors.ACC_INACTIVE); } catch (error) { // Only throw these errors in development mode for debugging but not in production to avoid exposing sensitive information if (!env.PRODUCTION) throw error; else return res.json(null); // return res.status(500).json(null); // Testing only } let expireHrs = env.PWD_RESET_VALID_HRS; if (!expireHrs.endsWith('h')) expireHrs += 'h'; const resetEmailResult = await mailer.sendResetPasswordEmail( { locale: user?.lang || DEFAULT_LANG, baseUrl: getHostUrlFromReq(req), expireHrs: expireHrs.replace('h', ''), id: user?._id, name: user?.contact || user?.username, token: createUserToken(user, expireHrs) }, userEmail ); // debug(`Password reset email sending result: ${JSON.stringify(resetEmailResult)}`); if (!resetEmailResult || !resetEmailResult.success) { // In production, we might want to be less specific about the error if (!env.PRODUCTION) { AppParamError.throw(Errors.INVALID_EMAIL); } } // Even on failure in production, return success to avoid user enumeration res.json({ result: 1 }); } function createUserToken(user, expiresIn, secret) { // Only require username, make id and password optional assert(user && user.username, AppInputError.create(Errors.INVALID_ACCOUNT)); const payload = { email: user.username }; // Add id to payload if it exists user._id && (payload.id = user._id); user.createdAt && (payload.createdAt = user.createdAt); user.verified && (payload.verified = user.verified); // If a specific secret was provided, use it // Otherwise, try to create a user-specific secret if password and createdAt exist // If not, fall back to the application's TOKEN_SECRET let _secret = secret; if (!_secret && user.password && user.createdAt) { _secret = `${user.password}-${user.createdAt.getTime()}`; } _secret = _secret || env.TOKEN_SECRET; const token = sign(payload, _secret, { expiresIn: expiresIn || '3h' }); return token; } async function validateToken(token, secret) { try { if (!token) AppParamError.throw(Errors.INVALID_TOKEN); // Define the secret key (ensure it matches the one used to sign the token) const _secret = secret || env.TOKEN_SECRET; // Verify the token const payload = await verifyAsync(token, _secret); // Ensure the token has at least an email if (!payload || !payload.email) AppParamError.throw(Errors.INVALID_TOKEN); return payload; } catch (error) { // debug('Token validation error:', token, error.message); // Handle token validation errors if (error.name === 'TokenExpiredError') { AppParamError.throw(Errors.TOKEN_EXPIRED); } else if (error.name === 'JsonWebTokenError') { AppParamError.throw(Errors.INVALID_TOKEN); } else { throw error; } } } async function validateResetPwdToken_post(req, res) { const { id, token } = req.body; if (!req || !id || !token) AppParamError.throw(Errors.INVALID_TOKEN); const user = await User.findOne({ _id: ObjectId(id) }).lean(); if (!user) AppError.throw(Errors.INVALID_TOKEN); const secret = `${user.password}-${user.createdAt.getTime()}`; const payload = await validateToken(token, secret); if (!payload || !payload.id || payload.id.toString() !== id.toString()) { AppParamError.throw(Errors.INVALID_TOKEN); } res.json({ id: payload.id, token }); } async function resetPassword_post(req, res) { const { id, password, token } = req.body; if (!id || !password || !token) AppParamError.throw(); const user = await User.findOne({ _id: ObjectId(id) }); if (!user) AppError.throw(Errors.INVALID_TOKEN); const _user = user; const secret = `${user.get('password')}-${user.get('createdAt').getTime()}`; const payload = await validateToken(token, secret); if (!payload || !payload.id || payload.id.toString() !== id.toString()) { AppParamError.throw(Errors.INVALID_TOKEN); } _user.password = password; await mongoUtil.runInTransaction(async (session) => { await _user.save(session); await mailer.sendPasswordResetEmail( { locale: _user.lang || DEFAULT_LANG, baseUrl: getHostUrlFromReq(req), name: _user?.contact || _user?.username, username: _user.username, }, _user?.username ); }); res.json({ ok: true }); } async function signup_post(req, res) { const input = req.body; if (!input || !input.contactEmail || !emailRegex.test(input.contactEmail) || !input.password || !input.contactName || !input.companyName || !input.country) AppParamError.throw(); // Check for the email verification token const verifyToken = input.emailToken || input.token; if (!verifyToken) AppParamError.throw(Errors.EMAIL_VERIFICATION_REQUIRED); try { // Verify the token is valid and contains the verified email const payload = await verifyAsync(verifyToken, env.TOKEN_SECRET); // Make sure the token contains the verified flag and matches the email in the form if (!payload || !payload.email || !payload.verified || payload.email !== input.contactEmail) { AppParamError.throw(Errors.EMAIL_VERIFICATION_REQUIRED); } } catch (error) { env.LOG_ALL_ERRORS && debug('Email verification token error:', error.message); AppParamError.throw(Errors.EMAIL_VERIFICATION_REQUIRED); } const existingUser = await User.findOne({ ...mongoUtil.getTextFieldFilter('username', input.contactEmail) }).lean(); if (existingUser) AppParamError.throw(Errors.USER_EXIST); // Verify country code is valid const country = await Country.findOne({ code: input.country.toUpperCase() }); if (!country) AppParamError.throw('Invalid country code'); // Create a default address from contact and address fields if no addresses provided let addresses = []; if (input.addresses && Array.isArray(input.addresses) && input.addresses.length > 0) { // Process each address and ensure country codes are uppercase and valid addresses = input.addresses.map(addr => { if (addr.country) { // With our new schema, just ensure the country code is uppercase return { ...addr, country: addr.country.toUpperCase() }; } return addr; }); } else { // Create a default address entry using available information addresses = [{ name: input?.companyName || '', line1: input?.address || '', line2: '', city: input?.city || '', state: input?.state || '', postalCode: input?.postalCode || '', country: input?.country.toUpperCase(), // Store the country code directly phone: input?.contactPhone || '', email: input.contactEmail || '', isBilling: true }]; } const CustomerModel = UserModelFactory.create(UserTypes.APP); const customerData = { name: input.companyName, address: input.address || '', email: input.contactEmail, phone: input.contactPhone, username: input.contactEmail, contact: input.contactName, country: input.country.toUpperCase(), // Ensure country code is uppercase password: input.password, membership: { trials: { type: TrialTypes.DAYS, trialDays: env.NEW_ACC_TRIAL_DAYS, startDate: moment.utc().toDate(), }, }, addresses: addresses, appInfo: input.appInfo || {}, taxId: input.taxId || '', active: true, lang: input.lang || DEFAULT_LANG, selfSignup: true, createdAt: moment.utc().toDate(), }; if (typeof input.partner === 'string' && input.partner?.trim().length !== 0) { customerData.partner = input.partner.trim(); } if (typeof input.dealer === 'string' && input.dealer?.trim().length !== 0) { customerData.dealer = input.dealer.trim(); } const newCustomer = new CustomerModel(customerData); const emailData = { lang: input.lang || DEFAULT_LANG, name: input.contactName, username: input.contactEmail, orgName: input.companyName, baseUrl: getHostUrlFromReq(req), }; try { // Send the validation email and capture the detailed result const mailRes = await mailer.sendWelcomeNewAccEmail(emailData, input.contactEmail); if (!mailRes || !mailRes.success) { AppParamError.throw(Errors.INVALID_EMAIL); } } catch (error) { AppParamError.throw(Errors.EMAIL_ERROR); } await mongoUtil.runInTransaction(async (session) => { await newCustomer.save(session); }); // Send AGM Admin email about this new user try { await mailer.sendAdminNotification( '[AgMission] New Applicator User Signed up', `A new applicator user has signed up:\n\nName: ${input.contactName}\nEmail: ${input.contactEmail}\nCompany: ${input.companyName}\nCountry: ${input.country}` ); } catch (error) { // Log but don't fail the transaction if admin notification fails debug('Error sending admin notification:', error); } res.json({ ok: true, username: input.contactEmail }); } /** * Request email verification. For example: before signup form submission, changing login email, etc. * This function sends a verification link (with JWT token) to the provided email address. * @param {Object} req - Request with email in body * @param {Object} res - Response object */ async function requestEmailVerification_post(req, res) { const email = req.body?.email?.trim(); const name = req.body?.name?.trim(); if (!email || emailRegex.test(email) === false) AppParamError.throw(Errors.INVALID_EMAIL); // Check if the email already exists const existingUser = await User.findOne({ ...mongoUtil.getTextFieldFilter('username', email) }).lean(); if (existingUser) AppParamError.throw(Errors.USER_EXIST); try { // Create a token for email verification const token = createUserToken({ username: email }, env.EMAIL_VER_VALID_TIME, env.TOKEN_SECRET); // Send the verification email with the token const emailResult = await mailer.sendEmailVerificationCode( { username: email, name: name || email, baseUrl: getHostUrlFromReq(req), lang: req.body.lang || DEFAULT_LANG, token: token }, email ); if (!emailResult || !emailResult.success) { AppParamError.throw(Errors.EMAIL_ERROR); } res.json({ ok: true, message: 'Verification email sent' }); } catch (error) { env.LOG_ALL_ERRORS && debug('Error sending verification email:', error); AppParamError.throw(Errors.EMAIL_ERROR); } } /** * Verify email token before allowing signup form submission * @param {Object} req - Request with token in body * @param {Object} res - Response object */ async function verifyEmailCode_post(req, res) { const token = req.body?.token?.trim(); if (!token) AppParamError.throw(Errors.INVALID_TOKEN); try { // Use the existing validateToken function const payload = await validateToken(token, env.TOKEN_SECRET); // Create a temporary user object with verified flag for signup const tempUser = { username: payload.email, createdAt: new Date(), verified: true // Additional property to indicate verification }; // Use createUserToken with a longer expiry time for signup flow const signupToken = createUserToken(tempUser, env.NEW_ACC_VALID_TIME, env.TOKEN_SECRET); res.json({ ok: true, email: payload.email, token: signupToken }); } catch (error) { env.LOG_ALL_ERRORS && debug('Email verification token error:', error.message); // If validateToken didn't throw an error but we caught another one if (error.name === 'TokenExpiredError') { AppParamError.throw(Errors.VERIFICATION_CODE_EXPIRED); } else { AppParamError.throw(Errors.INVALID_VERIFICATION_CODE); } } } module.exports = { isUserNameExists_post, createUser_post, getUser_get, updateUser_put, deleteUser, login_post, search_post, clearTempData_post, setUserLanguage_post, getUserDetail_post, mailPwdReset_post, validateResetPwdToken_post, resetPassword_post, ensureParentExists, clearTempData, getHostUrlFromReq, requestEmailVerification_post, verifyEmailCode_post, signup_post }