agmission/Development/server/controllers/user.js

612 lines
21 KiB
JavaScript

'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();
}
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
}