import moment from 'moment' import { uniqBy } from 'lodash' import * as PropertyController from '../property' import * as bcrypt from 'bcryptjs' import GeneralConstants from '../../constants/general' import { User, UserUpdateInput, UserGetPayload, ProjectGetPayload, } from 'nexus-plugin-prisma/client' import * as EmailUtils from '../../utils/email' import GeneralUtils from '../../utils/general' import { logger } from '../../utils/logger' import { MANAGER_NOTIFICATION_TYPES, send as sendNotificationUtils } from '../../utils/notification' import * as SendBirdController from '../../controllers/sendbird' import * as Twilio from '../../utils/twilio' import { prismaClient as prisma } from '../../prismaClient' const RESIDENT_APP_DOWNLOAD_URL = 'vskq.app.link/LTAFPaYRwT' // const PROJECT_INVITATION_FIELDS = ` // id // name // appInviteEmailHeaderImage { // url // } // managingCompany { // id // shortName // longName // logo { // url // } // emailHeaderImage { // url // } // } // ` // const USER_INVITATION_FRAGMENT = `{ // id // firstName // lastName // email // preferedLanguage // active // cantDeliverEmail // cantDeliverSMS // phone { // number // } // property { // id // address { // apartmentNumber // } // } // properties { // id // address { // apartmentNumber // } // } // currentProject { // ${PROJECT_INVITATION_FIELDS} // } // }` const NORMAL_FIELDS = [ 'email', 'secondaryEmail', 'firstName', 'lastName', 'emergencyContact', 'hasPaymentError', 'cantDeliverSMS', 'cantDeliverEmail', 'hasAppNotificationEnable', 'hasEmailNotificationEnable', 'hasSMSNotificationEnable', 'hasConfirmedEmail', 'hasConfirmedEmailAt', 'confirmEmailToken', 'hasConfirmedPhone', 'hasConfirmedPhoneAt', 'currentAppVersion', 'signedInAt', 'signedInPlatform', 'appNotificationsBadgesNumber', 'isSignedOut', 'role', 'hasResetPassword', 'resetPasswordToken', 'expoNotificationToken', 'notificationToken', 'oneSignalUserId', 'hasInstalledApp', 'hasUninstalledApp', 'operatingSystem', 'preferedLanguage', 'hasRegisterAt', 'hasReceivedOnboarding', 'hasReceivedInvitation', 'hasReceivedInvitationAt', 'hasReceivedInvitationByEmail', 'hasReceivedInvitationByEmailAt', 'hasOpenedEmailInvitation', 'hasOpenedEmailInvitationAt', 'hasClickedOnCTAInEmailInvitation', 'hasClickedOnCTAInEmailInvitationAt', 'hasReceivedInvitationBySMS', 'hasReceivedInvitationBySMSAt', 'hasReceivedSecondInvitation', 'hasReceivedSecondInvitationAt', 'hasReceivedSecondInvitationByEmail', 'hasReceivedSecondInvitationByEmailAt', 'hasReceivedSecondInvitationBySMS', 'hasReceivedSecondInvitationBySMSAt', 'hasOpenedSecondEmailInvitation', 'hasOpenedSecondEmailInvitationAt', 'hasClickedOnCTAInSecondEmailInvitation', 'hasClickedOnCTAInSecondEmailInvitationAt', 'stripeCustomerId', 'waitingForApproval', 'hasConfirmedBasicInformations', 'active', 'isTenant', 'isResident', ] const SINGULAR_CONNECT_FIELDS = ['phone', 'officePhone', 'homePhone', 'createdBy'] const MULTIPLE_CONNECT_FIELDS = [ 'notifications', 'associatedTasks', 'properties', 'paymentMethods', 'packages', 'offenses', 'projects', 'orders', 'projectsBoardMember', 'serviceRequests', 'filesSeen', 'goingEvents', 'interestedEvents', 'cantGoEvents', 'postsSeen', 'eventsSeen', 'packagesSeen', 'receivedOffenses', 'projectsActive', 'projectsTenants', 'condoManagerNomComptes', 'previousProjects', 'emailSyncStatuses', 'firstNameSyncStatuses', 'lastNameSyncStatuses', ] const PLATFORMS = ['APP', 'EMAIL', 'SMS'] function getDefaultNotificationPreferences() { const notificationPreferences = [] PLATFORMS.forEach((platform) => { MANAGER_NOTIFICATION_TYPES.forEach((type) => { notificationPreferences.push({ type, platform, isEnable: platform === 'APP' || platform === 'EMAIL', }) }) }) return notificationPreferences } export const getMoreDetailsInWalterAppMessageFrench = () => `Obtenez plus de détails dans l'application Walter.\n${RESIDENT_APP_DOWNLOAD_URL}` export const getMoreDetailsInWalterAppMessageEnglish = () => `Get more details on the Walter app.\n${RESIDENT_APP_DOWNLOAD_URL}` const getWalterInviteDownloadMessage = ({ user, project }) => { let message = `Offered by your managing company${ project && project.managingCompany && (project.managingCompany.shortName || project.managingCompany.longName) ? `, ${project.managingCompany.shortName || project.managingCompany.longName},` : '' } Walter is your new application for your building. Download the Walter app now by clicking on the link below.` if (user.email || user.phone) { message += `\n\nSign in with your ${ user.email ? `email: ${user.email}` : `phone number: ${user.phone.number}` }${ user.email && user.phone && user.phone.number ? ` or your phone number: ${user.phone.number}` : '' }` } message += `\n\n${RESIDENT_APP_DOWNLOAD_URL}` return message } const getWalterInviteDownloadMessageFrench = ({ user, project }) => { let message = `Votre compagnie de gestion${ project && project.managingCompany && (project.managingCompany.shortName || project.managingCompany.longName) ? `, ${project.managingCompany.shortName || project.managingCompany.longName},` : '' } est fière de vous offrir Walter. Téléchargez l'application dès maintenant en cliquant sur le lien ci-dessous.` if (user.email || user.phone) { message += `\n\nInscrivez-vous en utilisant votre ${ user.email ? `adresse courriel: ${user.email}` : `Numéro de téléphone: ${user.phone.number}` }${ user.email && user.phone && user.phone.number ? ` ou votre numéro de téléphone: ${user.phone.number}` : '' }` } message += `\n\n${RESIDENT_APP_DOWNLOAD_URL}` return message } const getWalterSecondInviteDownloadMessage = ({ user, project }) => { let message = `It looks like you still have not downloaded Walter.\n\nOffered by ${ project && project.managingCompany && (project.managingCompany.shortName || project.managingCompany.longName) ? project.managingCompany.shortName || project.managingCompany.longName : 'your managing company' }, did you know that your building, the ${ project.name || 'building' }, now has its own mobile app?` if (user.email || user.phone) { message += `\n\nClick on the link below and sign in with your ${ user.email ? `email: ${user.email}` : `phone number: ${user.phone.number}` }${ user.email && user.phone && user.phone.number ? ` or your phone number: ${user.phone.number}` : '' }` } message += `\n\n${RESIDENT_APP_DOWNLOAD_URL}` return message } const getWalterSecondInviteDownloadMessageFrench = ({ user, project }) => { let message = `Il semblerait que vous n'ayez toujours pas téléchargé Walter.\n\nOfferte par ${ project && project.managingCompany && (project.managingCompany.shortName || project.managingCompany.longName) ? project.managingCompany.shortName || project.managingCompany.longName : 'votre compagnie de gestion' }, saviez-vous que votre immeuble, le ${ project.name || 'projet' }, possède maintenant sa propre application mobile?` if (user.email || user.phone) { message += `\n\nCliquez sur le lien ci-dessous et enregistrez votre profil en utilisant votre ${ user.email ? `adresse courriel: ${user.email}` : `Numéro de téléphone: ${user.phone.number}` }${ user.email && user.phone && user.phone.number ? ` ou votre numéro de téléphone: ${user.phone.number}` : '' }` } message += `\n\n${RESIDENT_APP_DOWNLOAD_URL}` return message } export const getInviteSMSMessage = ({ user, project }) => { let message = '' if (user.preferedLanguage === 'fr' || !user.preferedLanguage) { message += `Bonjour ${user.firstName || ''},\n\n${getWalterInviteDownloadMessageFrench({ user, project, })}\n\nEnvoyez-nous un message à ce numéro si vous n'arrivez pas à vous inscrire.` } if (!user.preferedLanguage) { message += '\n\n-\n\n' } if (user.preferedLanguage !== 'fr') { message += `Hi ${user.firstName || ''},\n\n${getWalterInviteDownloadMessage({ user, project, })}\n\nReply to this number if you have any problems registering, please reply to this number.` } return message } export const getSecondInviteSMSMessage = ({ user, project }) => { let message = '' if (user.preferedLanguage === 'fr' || !user.preferedLanguage) { message += `Bonjour ${user.firstName || ''},\n\n${getWalterSecondInviteDownloadMessageFrench({ user, project, })}\n\nEnvoyez-nous un message à ce numéro si vous n'arrivez pas à vous inscrire.` } if (!user.preferedLanguage) { message += '\n\n-\n\n' } if (user.preferedLanguage !== 'fr') { message += `Hi ${user.firstName || ''},\n\n${getWalterSecondInviteDownloadMessage({ user, project, })}\n\nReply to this number if you have any problems registering, please reply to this number.` } return message } export const update = ({ db }) => async (req, res) => { const userId = req.params.id try { if (!userId) { throw new Error('Need user id') } await db.mutation.updateUser({ where: { id: userId, }, data: req.body, }) return res.status(200).end() } catch (e) { logger.error(e) return res.status(500).end(e) } } type ProjectWithRelations = ProjectGetPayload<{ select: { name: true managingCompany: { select: { shortName: true longName: true logo: true } } } }> type UserWithRelations = UserGetPayload<{ select: { id: true createdAt: true email: true secondaryEmail: true firstName: true lastName: true emergencyContact: true hasPaymentError: true cantDeliverSMS: true cantDeliverEmail: true hasAppNotificationEnable: true hasEmailNotificationEnable: true hasSMSNotificationEnable: true hasConfirmedEmail: true hasConfirmedEmailAt: true confirmEmailToken: true hasConfirmedPhone: true hasConfirmedPhoneAt: true currentAppVersion: true signedInAt: true signedInPlatform: true appNotificationsBadgesNumber: true isSignedOut: true role: true hasResetPassword: true resetPasswordToken: true expoNotificationToken: true notificationToken: true oneSignalUserId: true hasInstalledApp: true hasUninstalledApp: true operatingSystem: true preferedLanguage: true hasRegisterAt: true hasReceivedOnboarding: true hasReceivedInvitation: true hasReceivedInvitationAt: true hasReceivedInvitationByEmail: true hasReceivedInvitationByEmailAt: true hasOpenedEmailInvitation: true hasOpenedEmailInvitationAt: true hasClickedOnCTAInEmailInvitation: true hasClickedOnCTAInEmailInvitationAt: true hasReceivedInvitationBySMS: true hasReceivedInvitationBySMSAt: true hasReceivedSecondInvitation: true hasReceivedSecondInvitationAt: true hasReceivedSecondInvitationByEmail: true hasReceivedSecondInvitationByEmailAt: true hasReceivedSecondInvitationBySMS: true hasReceivedSecondInvitationBySMSAt: true hasOpenedSecondEmailInvitation: true hasOpenedSecondEmailInvitationAt: true hasClickedOnCTAInSecondEmailInvitation: true hasClickedOnCTAInSecondEmailInvitationAt: true stripeCustomerId: true waitingForApproval: true hasConfirmedBasicInformations: true active: true isTenant: true isResident: true property: { select: { id: true address: { select: { id: true apartmentNumber: true } } building: { select: { id: true project: { select: { id: true } } } } } } currentProject: { select: { id: true building: { select: { id: true project: { select: { id: true } } address: { select: { id: true apartmentNumber: true } } } } } } managingCompany: { select: { id: true projects: { select: { id: true } } } } phone: { select: { id: true number: true } } createdBy: { select: { id: true } } notifications: { select: { id: true } } associatedTasks: { select: { id: true } } properties: { select: { id: true address: { select: { id: true apartmentNumber: true } } building: { select: { id: true project: { select: { id: true } } } } } } paymentMethods: { select: { id: true } } packages: { select: { id: true } } offenses: { select: { id: true } } projects: { select: { id: true building: { select: { id: true } } } } orders: { select: { id: true } } serviceRequests: { select: { id: true } } filesSeen: { select: { id: true } } goingEvents: { select: { id: true } } interestedEvents: { select: { id: true } } cantGoEvents: { select: { id: true } } postsSeen: { select: { id: true } } eventsSeen: { select: { id: true } } packagesSeen: { select: { id: true } } receivedOffenses: { select: { id: true } } projectsActive: { select: { id: true } } projectsTenants: { select: { id: true } } condoManagerNomComptes: { select: { id: true project: { select: { id: true } } } } projectsBoardMember: { select: { id: true } } previousProjects: { select: { id: true } } emailSyncStatuses: { select: { id: true } } firstNameSyncStatuses: { select: { id: true } } lastNameSyncStatuses: { select: { id: true } } } }> export const mergeUsersAndGetNewUpdatedOne = async ({ users }) => { try { if (!users) { throw new Error('Need users to merge users...') } if (users.some((u) => u.role !== users[0].role)) { throw new Error( `Can't merge users with not the same role ${users.map((u) => u.id).join(', ')}` ) } const usersToMerge: Partial[] = await Promise.all( users.map((u: Partial) => prisma.user.findOne({ where: { id: u.id }, select: { id: true, createdAt: true, email: true, secondaryEmail: true, firstName: true, lastName: true, emergencyContact: true, hasPaymentError: true, cantDeliverSMS: true, cantDeliverEmail: true, hasAppNotificationEnable: true, hasEmailNotificationEnable: true, hasSMSNotificationEnable: true, hasConfirmedEmail: true, hasConfirmedEmailAt: true, confirmEmailToken: true, hasConfirmedPhone: true, hasConfirmedPhoneAt: true, currentAppVersion: true, signedInAt: true, signedInPlatform: true, appNotificationsBadgesNumber: true, isSignedOut: true, role: true, hasResetPassword: true, resetPasswordToken: true, expoNotificationToken: true, notificationToken: true, oneSignalUserId: true, hasInstalledApp: true, hasUninstalledApp: true, operatingSystem: true, preferedLanguage: true, hasRegisterAt: true, hasReceivedOnboarding: true, hasReceivedInvitation: true, hasReceivedInvitationAt: true, hasReceivedInvitationByEmail: true, hasReceivedInvitationByEmailAt: true, hasOpenedEmailInvitation: true, hasOpenedEmailInvitationAt: true, hasClickedOnCTAInEmailInvitation: true, hasClickedOnCTAInEmailInvitationAt: true, hasReceivedInvitationBySMS: true, hasReceivedInvitationBySMSAt: true, hasReceivedSecondInvitation: true, hasReceivedSecondInvitationAt: true, hasReceivedSecondInvitationByEmail: true, hasReceivedSecondInvitationByEmailAt: true, hasReceivedSecondInvitationBySMS: true, hasReceivedSecondInvitationBySMSAt: true, hasOpenedSecondEmailInvitation: true, hasOpenedSecondEmailInvitationAt: true, hasClickedOnCTAInSecondEmailInvitation: true, hasClickedOnCTAInSecondEmailInvitationAt: true, stripeCustomerId: true, waitingForApproval: true, hasConfirmedBasicInformations: true, active: true, isTenant: true, isResident: true, phone: { select: { id: true, number: true, }, }, createdBy: { select: { id: true, }, }, notifications: { select: { id: true, }, }, associatedTasks: { select: { id: true, }, }, properties: { select: { id: true, }, }, paymentMethods: { select: { id: true, }, }, packages: { select: { id: true, }, }, offenses: { select: { id: true, }, }, projects: { select: { id: true, }, }, orders: { select: { id: true, }, }, projectsBoardMember: { select: { id: true, }, }, serviceRequests: { select: { id: true, }, }, filesSeen: { select: { id: true, }, }, goingEvents: { select: { id: true, }, }, interestedEvents: { select: { id: true, }, }, cantGoEvents: { select: { id: true, }, }, postsSeen: { select: { id: true, }, }, eventsSeen: { select: { id: true, }, }, packagesSeen: { select: { id: true, }, }, receivedOffenses: { select: { id: true, }, }, projectsActive: { select: { id: true, }, }, projectsTenants: { select: { id: true, }, }, condoManagerNomComptes: { select: { id: true, }, }, previousProjects: { select: { id: true, }, }, emailSyncStatuses: { select: { id: true, }, }, firstNameSyncStatuses: { select: { id: true, }, }, lastNameSyncStatuses: { select: { id: true, }, }, }, }) ) ) const activeAllUsers: Partial[] = usersToMerge.filter( (u: Partial) => u && u.active ) // Primary user is the one we will mix all the other accounts into let primaryUser: Partial = activeAllUsers.find((u) => u.hasInstalledApp) || activeAllUsers.find((u) => u.hasReceivedInvitation) || activeAllUsers.find((u) => u.email) || activeAllUsers.find((u) => u.phone && u.phone.number) || usersToMerge.find((u) => u.hasInstalledApp) || usersToMerge.find((u) => u.hasReceivedInvitation) || usersToMerge.find((u) => u.email) || usersToMerge.find((u) => u.phone && u.phone.number) // In last case if we can't find anyone... just pick the first one created if (!primaryUser) { primaryUser = usersToMerge.sort( (a: Partial, b: Partial) => moment(a.createdAt).unix() - moment(b.createdAt).unix() )[0] } if (primaryUser) { const otherUsers: Partial[] = usersToMerge.filter( (u: Partial) => u.id !== primaryUser.id ) const { id, ...newUpdatedUserData } = primaryUser // Delete field that we don't want to update delete newUpdatedUserData.createdAt delete newUpdatedUserData.phone // Merge all multiple connected fields MULTIPLE_CONNECT_FIELDS.forEach((fieldToConnect) => { newUpdatedUserData[fieldToConnect] = { connect: otherUsers .map((u) => u[fieldToConnect]) // Maybe we should edit Typescript config? -> https://stackoverflow.com/questions/53556409/typescript-flatmap-flat-flatten-doesnt-exist-on-type-any .flat() .map((p) => ({ id: p.id })), } }) // Merge all singular connected fields SINGULAR_CONNECT_FIELDS.forEach((fieldToConnect) => { // Only if the primary user doesn't have any value for this field if (!newUpdatedUserData[fieldToConnect]) { // Ex: { id: '123', phone: { id: '321', number: '438-498-9283' } } const otherUsersWithCurrentFieldValueExisting = otherUsers.find((u) => u[fieldToConnect]) if (otherUsersWithCurrentFieldValueExisting) { // Ex: { id: '321', number: '438-498-9283' } const otherUsersValueForCurrentField = otherUsersWithCurrentFieldValueExisting[fieldToConnect] newUpdatedUserData[fieldToConnect] = { connect: { id: otherUsersValueForCurrentField.id, }, } } } else { newUpdatedUserData[fieldToConnect] = { connect: { id: newUpdatedUserData[fieldToConnect].id, }, } } }) // Merge all normal fields NORMAL_FIELDS.forEach((normalField) => { // Only if the primary user doesn't have any value for this field. Ex firstName doesn't exist for primary user // Don't user !newUpdatedUserData[normalField] because the value could be "False" and that's what we wanted if ( typeof newUpdatedUserData[normalField] === 'undefined' || newUpdatedUserData[normalField] === '' || newUpdatedUserData[normalField] === null ) { // Ex: { id: '123', firstName: 'Thierry' } const otherUsersWithCurrentFieldValueExisting = otherUsers.find((u) => u[normalField]) if (otherUsersWithCurrentFieldValueExisting) { // Ex: 'Thierry' const otherUsersValueForCurrentField = otherUsersWithCurrentFieldValueExisting[normalField] newUpdatedUserData[normalField] = otherUsersValueForCurrentField } } }) const completeUserData: Partial = >( newUpdatedUserData ) // Update the master users with all the other users projects, notifications, etc... const newUpdatedUser = await prisma.user.update({ where: { id, }, data: completeUserData, }) // Make sure the new user is in all Sendbird channel of his properties prisma.user .findOne({ where: { id: newUpdatedUser.id }, select: { id: true, property: { select: { id: true, }, }, properties: { select: { id: true, }, }, }, }) .then((user) => { const propertiesOfUser = GeneralUtils.uniq([user.property, ...user.properties], 'id') propertiesOfUser.map((property) => SendBirdController.addUsersToPropertyChannelIfExists({ userIds: [user.id], propertyId: property.id, }) ) }) .catch(logger.error) // Delete the old users await Promise.all(otherUsers.map((userToDelete) => deleteUser(userToDelete.id))) return newUpdatedUser } } catch (error) { logger.error(error) throw new Error(error) } } export async function verifyAndUpdateThirdPartySyncStatus( userId: string, dataToUpdate: UserUpdateInput ) { if (!userId || !dataToUpdate) throw new Error('userId and dataToUpdate are required') if (dataToUpdate?.email) { const userUpdated = await prisma.user.findOne({ where: { id: userId }, select: { id: true, role: true, email: true, emailSyncStatuses: { select: { id: true, }, }, }, }) // Problem here. Let's say there's an existing user with email thierry@usewalter.com (User 1) // If user 2 change his email to thierry@usewalter.com, the 2 users will merge but here // userUpdated.email will be EQUAL to dataToUpdate.email so it will not be marked as CHANGED_ON_WALTER // Only if we call verifyAndUpdateThirdPartySyncStatus before the update is happening and we use async await // To make sure to finish this if (userUpdated.role === 'RESIDENT' && dataToUpdate.email) { await Promise.all( userUpdated.emailSyncStatuses.map((emailSyncStatus) => prisma.syncStatus.update({ where: { id: emailSyncStatus.id }, data: { status: 'CHANGED_ON_WALTER', }, }) ) ) } } } export function addUserToProject({ projectId, userId }) { SendBirdController.addUsersToPropertiesChannelOfProject({ projectId, userIds: [userId], }).catch(logger.error) prisma.user .update({ where: { id: userId, }, data: { projects: { connect: [ { id: projectId, }, ], }, }, select: { id: true, currentProject: { select: { id: true, }, }, }, }) .then((user) => { if (!user?.currentProject) { prisma.user.update({ where: { id: user.id }, data: { currentProject: { connect: { id: projectId } } }, }) } }) .catch(logger.error) } export function userHasBeenRemovedFromProject({ projectId, userId }) { SendBirdController.removeUsersFromPropertiesChannelOfProject({ projectId, userIds: [userId], }).catch(logger.error) prisma.user .update({ where: { id: userId, }, data: { previousProjects: { connect: [ { id: projectId, }, ], }, }, }) .catch(logger.error) } export async function deleteUser(userId: string): Promise { const deletedUser = await prisma.user.delete({ where: { id: userId } }) // Delete user in other third parties // Stripe? Maybe just put inactive/deleted variable // DON'T delete sendbird user as this will delete the user messages... // SendBirdController.deleteUser(userId).catch(logger.error) return deletedUser } type UserWithRelationsForCreateUser = UserGetPayload<{ include: { properties: true notificationPreferences: true currentProject: true projectsTenants: true } }> export async function createUser(data: any) { if (data.password) { data.password = await bcrypt.hash(data.password, 10) } // Add default notification preferences if (data.role === 'MANAGER') { data.notificationPreferences = { create: getDefaultNotificationPreferences() } } // Because at walter, we want all our email clean if (data.email) { data.email = data.email.toLowerCase().trim() } // The new way we say a user is a tenant if (data.isResident && data.currentProject?.id) { data.projectsTenants = { connect: [ { id: data.currentProject.connect.id, }, ], } } // Make sure we save a good formatted phone number if (data.phone && data.phone.create) { const result = await Twilio.getFormattedNumber(data.phone.create.number) if (result) { const { phoneNumber, countryCode, nationalFormat } = result data.phone = { create: { ...data.phone.create, number: phoneNumber, countryCode, nationalFormat, }, } } } // Make sure someone with phone number and email doesn't already exists // Not now because what if we have a user like Michèle is in Yoo and Murray if (data.email || data.phone) { const existingUser = await querySingleUser({ email: data.email, phoneNumber: data.phone?.create?.number || null, }) if (existingUser) { // If there's an existing user. Make sure to merge with the one that installed the app if (existingUser.role === 'RESIDENT' && (data.role === 'RESIDENT' || !data.role)) { return prisma.user.update({ where: { id: existingUser.id, }, data: { ...data, projects: { connect: [ { id: data.currentProject.connect.id, }, ], }, }, }) } else { if (data.email && data.phone) { throw new Error( `There's already an existing user with this email address: ${existingUser.email} and phone number ${data.phone?.create?.number}` ) } else if (data.email) { throw new Error( `There's already an existing user with this email address: ${existingUser.email}` ) } else if (data.phone?.create?.number) { throw new Error( `There's already an existing user with this phone number: ${data.phone?.create?.number}` ) } } } } const createdUser = await prisma.user.create({ data: data }) // MANAGER // Make sure manager is in all private conversations of each projects and each properties if (createdUser.role === 'MANAGER') { try { const user = await prisma.user.findOne({ where: { id: createdUser.id }, select: { managingCompany: { select: { projects: { select: { id: true, managingCompany: { select: { id: true, }, }, building: { select: { properties: { select: { id: true, }, }, }, }, }, }, }, }, }, }) for await (const project of user.managingCompany.projects) { const channels = await SendBirdController.getChannelsForUserId({ userId: project.managingCompany.id, name_startswith: project.id, }) await GeneralUtils.loopChunksPromise(channels, 5, async (channel) => { const [, propertyId] = channel.name.split('-') await SendBirdController.addManagerToPrivatePropertyChannelIfExist({ managerUserId: createdUser.id, projectId: project.id, propertyId, }) }) } } catch (err) { logger.error(err) } } // RESIDENT // Make sure that new user is added to the property conversation if it exists if (createdUser.role === 'RESIDENT') { if (data.property || data.properties) { const newProperties = [ data.property?.connect, ...GeneralUtils.convertToArray(data.properties?.connect), ].filter(Boolean) newProperties.map((property) => SendBirdController.addUsersToPropertyChannelIfExists({ userIds: [createdUser.id], propertyId: property.id, }).catch(logger.error) ) } // Invite the resident if project is launched and is from CM // DONT invite finally because CM is sometimes pushing the wrong residents in some projects... -_- // if (data.currentProject?.connect?.id && data.condoManagerNomComptes) { // prisma // .project({ id: data.currentProject.connect.id }) // .then(project => { // if (project.isLaunched && project.automaticallyInviteResidentCreatedFromThirdParty) { // return inviteUsersToDownloadWalter({ // users: [createdUser], // projectId: data.currentProject.connect.id // }) // } // }) // .catch(logger.error) // } } return createdUser } export async function updateUser({ where, data }) { const oldUser = await prisma.user.findOne({ where: where, select: { id: true, firstName: true, lastName: true, waitingForApproval: true, role: true, email: true, property: { select: { id: true, }, }, }, }) // New phone number. Format + reset can't deliver boolean if (data?.phone?.update || data?.phone?.create) { const result = await Twilio.getFormattedNumber( data.phone.create?.number || data.phone.update?.number ) if (result) { const { phoneNumber: number, countryCode, nationalFormat } = result data.phone = { [data.phone.create ? 'create' : 'update']: { ...(data.phone.create || data.phone.update), number, countryCode, nationalFormat, }, } data.cantDeliverSMS = false } else { data.cantDeliverSMS = true } } // New password if (data.password) { data.password = await bcrypt.hash(data.password, 10) } if (data.email) { data.email = data.email.trim().toLowerCase() } // User changed email. We can reset the can't deliver boolean if (data.email && oldUser.email !== data.email) { data.cantDeliverEmail = false } // Need to be before update, go look in user controller try { await verifyAndUpdateThirdPartySyncStatus(where.id, data) } catch (error) { logger.error(error) } let existingUser let updatedUser // Make sure user doens't exist before updating if (data.email || data.phone?.update?.number || data.phone?.create?.number) { existingUser = await querySingleUser({ email: data.email, phoneNumber: data.phone?.update?.number || data.phone?.create?.number, }) if (existingUser && existingUser.role !== 'RESIDENT') { throw new Error( "There's already someone with this email/phone number and it's not a resident" ) } updatedUser = await prisma.user.update({ where: where, data: data, }) // Merge if resident exists if ( existingUser && existingUser.role === 'RESIDENT' && existingUser.id !== oldUser.id && oldUser.role === 'RESIDENT' ) { updatedUser = await mergeUsersAndGetNewUpdatedOne({ users: [oldUser, existingUser], }) } } else { updatedUser = await prisma.user.update({ where: where, data: data, }) } // User is now approved. Tell him! if ( data.waitingForApproval === false && oldUser.waitingForApproval // Not this because user won't receive the email when we will approve him // && // (updatedUser.hasInstalledApp || updatedUser.hasReceivedInvitation) ) { prisma.user .findOne({ where: where, select: { id: true, currentProject: { select: { id: true, }, }, }, }) .then((userToSendNotification) => sendNotificationUtils({ forceSend: true, // Resident need to receive this notification that he's been approved skipValidating: true, // Because the user haven't hasInstalledApp to true and haven't receivedInvitation users: [userToSendNotification], title: "You've been approved!", titleFr: 'Vous avez été approuvé!', message: "You can now login into your Walter's account and access your profile", messageFr: 'Vous pouvez maintenant vous connecter sur Walter et accéder à votre profil', type: 'NEW_RESIDENT_APPROVED', projectId: userToSendNotification.currentProject.id, data: userToSendNotification, }) ) .catch(logger.error) } // Check difference between projects of old user and new user // Check difference of properties too if (data?.projects?.disconnect) { const projectsToRemove = GeneralUtils.convertToArray(data?.projects?.disconnect) projectsToRemove.map((project) => userHasBeenRemovedFromProject({ projectId: project.id, userId: updatedUser.id, }) ) } // Doing this after here because in case the user has been put active AND also has been added to some properties // We want to also add him to the properties channels if (data.projects?.connect) { const projects = GeneralUtils.convertToArray(data?.projects?.connect) projects.map((p) => SendBirdController.addUsersToPropertiesChannelOfProject({ userIds: [updatedUser.id], projectId: p.id, }).catch(logger.error) ) } // New property/properties to the user if (data.property || data.properties) { const newProperties = [ data.property?.connect, ...GeneralUtils.convertToArray(data.properties?.connect), ].filter(Boolean) newProperties.map((property) => SendBirdController.addUsersToPropertyChannelIfExists({ userIds: [updatedUser.id], propertyId: property.id, }).catch(logger.error) ) const removedProperties = GeneralUtils.convertToArray(data.properties?.disconnect).filter( Boolean ) if (data.property?.disconnect && oldUser.property) { removedProperties.push(oldUser.property) } removedProperties.map((property) => SendBirdController.removeUsersFromPropertyChannel({ propertyId: property.id, userIds: [updatedUser.id], }) ) } // Update sendbird user info if user exists if ( (updatedUser.role === 'RESIDENT' || updatedUser.role === 'MANAGER') && (data.firstName || data.lastName || data.avatar) ) { SendBirdController.updateUserInfoIfExist({ userId: updatedUser.id, nickname: GeneralUtils.getUserName(updatedUser), profileUrl: updatedUser.avatar?.url, }).catch(logger.error) } return updatedUser } export async function removeResidentFromProject(userId: string, projectId: string) { if (!userId || !projectId) throw new Error('userId and projectId are mandatory.') const user = await prisma.user.findOne({ where: { id: userId }, select: { id: true, currentProject: { select: { id: true, building: { select: { id: true, project: { select: { id: true, }, }, }, }, }, }, projects: { select: { id: true, building: { select: { id: true, }, }, }, }, property: { select: { id: true, building: { select: { id: true, project: { select: { id: true, }, }, }, }, }, }, properties: { select: { id: true, building: { select: { id: true, project: { select: { id: true, }, }, }, }, }, }, projectsTenants: { select: { id: true, }, }, condoManagerNomComptes: { select: { id: true, project: { select: { id: true, }, }, }, }, projectsBoardMember: { select: { id: true, }, }, }, }) const condoManagerNomComptesToRemove = user.condoManagerNomComptes.filter( (cM) => cM.project?.id === projectId ) const propertiesOfUser = GeneralUtils.uniq([user.property, ...user.properties], 'id') const propertiesToRemove = propertiesOfUser.filter( (property) => property.building?.project?.id === projectId ) // Only execute this if user still has properties or projects associated // if (propertiesToRemove.length || user.projects.some(project => project.id === projectId)) { await Promise.all([ // Remove project updateUser({ where: { id: userId, }, data: { ...(condoManagerNomComptesToRemove.length && { condoManagerNomComptes: { delete: condoManagerNomComptesToRemove.map(({ id }) => ({ id })), }, }), ...(user.projectsTenants.some((p) => p.id === projectId) && { projectsTenants: { disconnect: [ { id: projectId, }, ], }, }), ...(user.currentProject?.id === projectId && { currentProject: user.projects.some((p) => p.id !== projectId) ? { connect: { id: user.projects.find((p) => p.id !== projectId).id, }, } : { disconnect: true, }, }), ...(user.projects.some((p) => p.id === projectId) && { projects: { disconnect: [ { id: projectId, }, ], }, previousProjects: { connect: [ { id: projectId, }, ], }, }), ...(user.projectsBoardMember.some(({ id }) => id === projectId) && { projectsBoardMember: { disconnect: [ { id: projectId, }, ], }, }), }, }), // Remove properties Promise.all( propertiesToRemove.map((property) => PropertyController.removePropertyFromUser(user.id, property.id) ) ), ]) // } } export async function sendSMSAppInvitationToResident({ userId, type = 'FIRST_INVITATION', projectId = undefined, }) { const user: Partial = await prisma.user.findOne({ where: { id: userId }, // select: { // // USER_INVITATION_FRAGMENT // } }) if (!user.active) { throw new Error("Can't send app invitation SMS to unactive user") } if (!user.phone?.number) { throw new Error("Can't send app invitation SMS to user without mobile phone number") } if (!user.cantDeliverSMS) { throw new Error('User has an invalid mobile phone number') } const project = projectId ? await prisma.project.findOne({ where: { id: projectId }, // select: { // // PROJECT_INVITATION_FIELDS // } }) : user.currentProject if (!project) { throw new Error( `Can't send app invitation email to user since we can't find the project with id: ${projectId} or user (${user.id}) doesn't have current project` ) } const message = type === 'FIRST_INVITATION' ? getInviteSMSMessage({ user, project }) : getSecondInviteSMSMessage({ user, project }) return Twilio.sendSMS({ /* @ts-ignore */ from: process.env.TWILIO_PHONE_NUMBER_SUPPORT, to: user.phone.number, body: message, }) } export async function sendEmailAppInvitationToResident({ userId, emailTemplateName, projectId, }: { userId: string emailTemplateName?: EmailUtils.TemplateNameTypes projectId?: string }) { const user = await prisma.user.findOne({ where: { id: userId }, include: { property: { include: { address: true, }, }, properties: { include: { address: true, }, }, phone: true, currentProject: { include: { managingCompany: { include: { logo: true, }, }, }, }, }, }) if (!user.active) { throw new Error("Can't send app invitation SMS to unactive user") } if (!user.email) { throw new Error("User doesn't have any email") } if (user.cantDeliverEmail) { throw new Error("Can't deliver email to that email address") } const project = projectId ? await prisma.project.findOne({ where: { id: projectId }, include: { managingCompany: { include: { logo: true, }, }, }, }) : user.currentProject if (!project) { // throw new Error( // `Can't send app invitation email to user since we can't find the project with id: ${projectId}` // ) return } if (!user.email) { // throw new Error(`Can't send email app invitation without user email`) return } const managingCompanyName = project.managingCompany.shortName || project.managingCompany.longName || 'Your managing company' return EmailUtils.send({ template: emailTemplateName || (user.preferedLanguage === 'fr' ? 'RESIDENT_FIRST_INVITE_FR' : 'RESIDENT_FIRST_INVITE'), from: { email: GeneralConstants.EMAIL_COMMUNICATION_PARSE_FROM_EMAIL, name: project.managingCompany.shortName || project.managingCompany.longName, }, to: [{ email: user.email }], subject: user.preferedLanguage === 'fr' ? `Nouvelle application pour le ${project.name}` : `New application for the ${project.name}`, customVariables: { preheader: user.preferedLanguage === 'fr' ? `${managingCompanyName} invites you to download Walter! Offered by ${managingCompanyName}, your building now has its own mobile application.` : `${managingCompanyName} vous invite à télécharger Walter! Offert par ${managingCompanyName}, votre immeuble a maintenant sa propre application mobile.`, firstName: user.firstName || user.lastName, managingCompanyName: project.managingCompany.shortName || project.managingCompany.longName || 'your managing company', managingCompanyLogoUrl: project && project.managingCompany && project.managingCompany.logo ? project.managingCompany.logo.url : null, buildingName: project.name, unitNumber: user.property ? user.property.address.apartmentNumber : user.properties && user.properties.length ? user.properties[0].address.apartmentNumber : '', userEmail: user.email, userPhoneNumber: user.phone ? user.phone.number : null, loginInfoTextFrench: `${ user.email ? `adresse courriel: ${user.email}` : `Numéro de téléphone: ${user.phone.number}` }${ user.email && user.phone && user.phone.number ? ` ou votre numéro de téléphone: ${user.phone.number}` : '' }`, loginInfoTextEnglish: `${ user.email ? `email: ${user.email}` : `phone number: ${user.phone.number}` }${ user.email && user.phone && user.phone.number ? ` or your phone number: ${user.phone.number}` : '' }`, }, }).catch(logger.error) } export const inviteUsersToDownloadWalter = async ({ users, noEmail = false, noSMS = false, projectId, }): Promise => { const residentThatReceivedInvitation: string[] = [] await Promise.all( users.map(async (user) => { if (!noEmail) { try { await sendEmailAppInvitationToResident({ userId: user.id, projectId }) await prisma.user.update({ where: { id: user.id, }, data: { hasReceivedInvitation: true, hasReceivedInvitationAt: new Date(), hasReceivedInvitationByEmail: true, hasReceivedInvitationByEmailAt: new Date(), }, }) residentThatReceivedInvitation.push(user.id) } catch {} } if (!noSMS) { try { await sendSMSAppInvitationToResident({ userId: user.id, projectId }) await prisma.user.update({ where: { id: user.id, }, data: { hasReceivedInvitation: true, hasReceivedInvitationAt: new Date(), hasReceivedInvitationBySMS: true, hasReceivedInvitationBySMSAt: new Date(), }, }) residentThatReceivedInvitation.push(user.id) } catch {} } }) ) return GeneralUtils.uniq(residentThatReceivedInvitation, 'id') } type QuerySingleUserInputType = { email?: string emails?: string[] phoneNumber?: string phoneNumbers?: string[] moreWhereAnd?: Record moreWhereOr?: Record include?: Record } export async function querySingleUser({ email, emails, phoneNumber, phoneNumbers, moreWhereAnd, moreWhereOr, include, }: QuerySingleUserInputType): Promise { try { const formattedPhoneNumberResult = await Twilio.getFormattedNumber(phoneNumber) const formattedEmail = email ? GeneralUtils.firstLetterToLowerCase(email.trim()) : null phoneNumber = formattedPhoneNumberResult?.phoneNumber || phoneNumber if (!phoneNumber && !formattedEmail && !phoneNumbers && !emails) { throw new Error("Can't query user without any phone number or email") } const getUsers = (wheres) => { const orWhere = moreWhereOr ? [moreWhereOr, ...wheres] : wheres const finalWhere = moreWhereAnd ? { AND: [moreWhereAnd, { OR: orWhere }] } : { OR: orWhere } const query = prisma.user.findMany({ take: 1, where: finalWhere, include, }) return query } let users = [] // Prioritize email if we have one. if (formattedEmail) { users = await getUsers([{ email: formattedEmail }]) } // Maybe with phone number ? if (!users.length && phoneNumber) { users = await getUsers([{ phone: { number: phoneNumber } }]) } if (!users.length && emails) { users = await getUsers([ { email_in: emails .filter(Boolean) .map((email) => GeneralUtils.firstLetterToLowerCase(email.trim())), }, ]) } if (!users.length && phoneNumbers) { const formattedPhoneNumberResults = await Promise.all( phoneNumbers .filter(GeneralUtils.isValidPhoneNumber) .map((phoneNumber) => Twilio.getFormattedNumber(phoneNumber)) ) users = await getUsers([ { phone: { number_in: formattedPhoneNumberResults .filter(Boolean) .map((result) => result.phoneNumber || result), }, }, ]) } if (users.length > 1) { // logger.error( // `More than 1 users (${JSON.stringify( // users.map(u => u.id) // )}) with email: ${formattedEmail} or phone number: ${phoneNumber} or emails: ${ // emails ? JSON.stringify(emails) : [] // } or phoneNumbers: ${phoneNumbers ? JSON.stringify(phoneNumbers) : []}` // ) mergeUsersAndGetNewUpdatedOne({ users }).catch(logger.error) } return Promise.resolve(users[0]) } catch (e) { logger.error(e) return Promise.resolve(null) } } export const getMoreDetailsInWalterAppMessage = ({ language }) => { return `${ language ? language === 'fr' ? `${getMoreDetailsInWalterAppMessageFrench()}` : `${getMoreDetailsInWalterAppMessageEnglish()}` : `${getMoreDetailsInWalterAppMessageFrench()}\n/\n${getMoreDetailsInWalterAppMessageEnglish()}` }` } export const getDownloadWalterMessage = async ({ user, projectId }) => { const project = projectId ? await prisma.project.findOne({ where: { id: projectId }, // select: { // // PROJECT_INVITATION_FIELDS // } }) : user.currentProject if (user.preferedLanguage === undefined || user.preferedLanguage === null) { return `--------\n${getWalterInviteDownloadMessageFrench({ user, project, })}\n\n/\n\n${getWalterInviteDownloadMessage({ user, project, })}\n--------` } if (user.preferedLanguage === 'fr') { return `--------\n${getWalterInviteDownloadMessageFrench({ user, project })}\n--------` } return `--------\n${getWalterInviteDownloadMessage({ user, project })}\n--------` } // export async function testInviteMessage() { // prisma // .users({ where: { email: 'thierry@usewalter.com' } }) // .$fragment(USER_INVITATION_FRAGMENT) // .then(users => { // try { // // GENERAL DOWNLOAD // const walterDownloadMessage = getDownloadWalterMessage({ user: users[0] }) // console.log('\n\n-----walterDownloadMessage:\n\n', walterDownloadMessage) // // FIRST INVITE // const messageInvite = getWalterInviteDownloadMessage({ user: users[0] }) // console.log('\n\n-----messageInvite:\n\n', messageInvite) // const messageInviteFrench = getWalterInviteDownloadMessageFrench({ user: users[0] }) // console.log('\n\n-----messageInviteFrench:\n\n', messageInviteFrench) // // SECOND INVITE // const messageSecondInvite = getWalterSecondInviteDownloadMessage({ user: users[0] }) // console.log('\n\n-----messageSecondInvite:\n\n', messageSecondInvite) // const messageSecondInviteFrench = getWalterSecondInviteDownloadMessageFrench({ // user: users[0] // }) // console.log('\n\n-----messageSecondInviteFrench:\n\n', messageSecondInviteFrench) // } catch (error) { // console.log('error', error) // } // }) // .catch(logger.error) // } export const clearResidentWithoutUnits = async ({ projectId }: { projectId: string }) => { logger.info(`Clearing residents with no units for project ${projectId}`) const projectResidents = await prisma.user.findMany({ where: { projects: { some: { id: projectId } } }, }) for await (const resident of projectResidents) { const residentPropertyForProject = await prisma.property.findMany({ where: { building: { project: { id: projectId, }, }, OR: [ { owners: { some: { id: resident.id, }, }, }, { users: { some: { id: resident.id, }, }, }, ], }, }) if (residentPropertyForProject.length === 0) { logger.condoManager( `Resident "${resident.id} - ${resident.email}" doesn't have any unit associated with current project. Remove it!` ) await removeResidentFromProject(resident.id, projectId) } } logger.info(`Done clearing residents with no units for project ${projectId}`) }