Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
223ee7f
wrap in transaction and save custom fields with updater
gabriellsh Feb 13, 2025
b351d4e
oops
gabriellsh Feb 13, 2025
a6fc69b
Pass session around
gabriellsh Feb 17, 2025
3f4375d
Use updater in place of Accounts setPasswordAsync
gabriellsh Feb 17, 2025
4ab2b03
pass session to avatar updates
gabriellsh Feb 17, 2025
51d5c8f
fix ts
gabriellsh Feb 18, 2025
c9de44b
fix unit
gabriellsh Feb 18, 2025
840a8ca
fix session passed to deferred operation
gabriellsh Feb 18, 2025
133152c
Move return clause in wrapInSessionTransaction
gabriellsh Feb 19, 2025
5800b2e
fix optional chaining and typo
gabriellsh Feb 19, 2025
902819f
Inline type in updateGroupDMsName
gabriellsh Feb 19, 2025
0ae5e05
accept only session in models methods options
gabriellsh Feb 19, 2025
3a462e0
Merge branch 'develop' into chore/updateUserTransaction
gabriellsh Feb 21, 2025
869f81e
helper for once session ended
gabriellsh Mar 6, 2025
3e8aafa
attach customfield side effect to event
gabriellsh Mar 6, 2025
a95661c
session in setUserAvatar
gabriellsh Mar 6, 2025
a59e1f0
session in setRealName
gabriellsh Mar 6, 2025
509a155
sideEffects after transaction in saveUserIdentity
gabriellsh Mar 6, 2025
0b860e4
session in setUsername
gabriellsh Mar 6, 2025
3c0015d
session in setEmail
gabriellsh Mar 6, 2025
ea103bd
session in setStatusText
gabriellsh Mar 6, 2025
ab64c3f
sideEffects after transaction commited in saveUser
gabriellsh Mar 6, 2025
05b1e6d
Update users model
gabriellsh Mar 6, 2025
152bfe4
Update Subscriptions model
gabriellsh Mar 6, 2025
cc44839
update BaseUploadModel
gabriellsh Mar 6, 2025
388d838
Update BaseRaw
gabriellsh Mar 6, 2025
59bf8aa
pass session to fileStore
gabriellsh Mar 6, 2025
ab6d7cb
Fix use of expired transaction
gabriellsh Mar 6, 2025
b455092
fix setUserAvatar
gabriellsh Mar 6, 2025
9d5fc45
Removed unused session from models
gabriellsh Mar 6, 2025
b420f80
Change module declaration
gabriellsh Mar 6, 2025
ef5d8c8
fix unit
gabriellsh Mar 6, 2025
a39484e
Merge branch 'develop' into chore/updateUserTransaction
gabriellsh Mar 12, 2025
cb7a812
Merge branch 'develop' into chore/updateUserTransaction
gabriellsh Mar 13, 2025
7104d0f
transaction success proxy event
gabriellsh Mar 13, 2025
8ad3063
Log errored side effects
gabriellsh Mar 13, 2025
e07b09b
wrap transaction error
gabriellsh Mar 13, 2025
979e9cf
Add flag to disable transaction
gabriellsh Mar 13, 2025
69d9a65
Merge branch 'develop' into chore/updateUserTransaction
gabriellsh Mar 13, 2025
ee6793a
Merge branch 'develop' into chore/updateUserTransaction
gabriellsh Mar 17, 2025
8238e17
Merge branch 'develop' into chore/updateUserTransaction
gabriellsh Mar 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,6 @@ API.v1.addRoute(

await saveUser(this.userId, userData);

if (this.bodyParams.data.customFields) {
await saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields);
}

if (typeof this.bodyParams.data.active !== 'undefined') {
const {
userId,
Expand Down
12 changes: 10 additions & 2 deletions apps/meteor/app/lib/server/functions/saveCustomFields.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import type { ClientSession } from 'mongodb';

import { saveCustomFieldsWithoutValidation } from './saveCustomFieldsWithoutValidation';
import { validateCustomFields } from './validateCustomFields';
import { trim } from '../../../../lib/utils/stringUtils';
import { settings } from '../../../settings/server';

export const saveCustomFields = async function (userId: string, formData: Record<string, any>): Promise<void> {
export const saveCustomFields = async function (
userId: string,
formData: Record<string, any>,
options?: { _updater?: Updater<IUser>; session?: ClientSession },
): Promise<void> {
if (trim(settings.get('Accounts_CustomFields')).length === 0) {
return;
}

validateCustomFields(formData);
return saveCustomFieldsWithoutValidation(userId, formData);
return saveCustomFieldsWithoutValidation(userId, formData, options);
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { IUser } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Subscriptions, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';

import { trim } from '../../../../lib/utils/stringUtils';
import { settings } from '../../../settings/server';
Expand All @@ -12,7 +15,14 @@ const getCustomFieldsMeta = function (customFieldsMeta: string) {
throw new Meteor.Error('error-invalid-customfield-json', 'Invalid JSON for Custom Fields');
}
};
export const saveCustomFieldsWithoutValidation = async function (userId: string, formData: Record<string, any>): Promise<void> {
export const saveCustomFieldsWithoutValidation = async function (
userId: string,
formData: Record<string, any>,
options?: {
_updater?: Updater<IUser>;
session?: ClientSession;
},
): Promise<void> {
const customFieldsSetting = settings.get<string>('Accounts_CustomFields');
if (!customFieldsSetting || trim(customFieldsSetting).length === 0) {
return;
Expand All @@ -29,7 +39,9 @@ export const saveCustomFieldsWithoutValidation = async function (userId: string,
{},
);

const updater = Users.getUpdater();
const { _updater, session } = options || {};

const updater = _updater || Users.getUpdater();

updater.set('customFields', customFields);

Expand All @@ -48,10 +60,12 @@ export const saveCustomFieldsWithoutValidation = async function (userId: string,
}
});

await Users.updateFromUpdater({ _id: userId }, updater);
if (!_updater) {
await Users.updateFromUpdater({ _id: userId }, updater, { session });
}

// Update customFields of all Direct Messages' Rooms for userId
const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields);
const setCustomFieldsResponse = await Subscriptions.setCustomFieldsDirectMessagesByUserId(userId, customFields, { session });
if (setCustomFieldsResponse.modifiedCount) {
void notifyOnSubscriptionChangedByUserIdAndRoomType(userId, 'd');
}
Expand Down
246 changes: 131 additions & 115 deletions apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@ import { Apps, AppEvents } from '@rocket.chat/apps';
import { isUserFederated } from '@rocket.chat/core-typings';
import type { IUser, IRole, IUserSettings, RequiredField } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';

import { callbacks } from '../../../../../lib/callbacks';
import { wrapInSessionTransaction } from '../../../../../server/database/utils';
import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { safeGetMeteorUser } from '../../../../utils/server/functions/safeGetMeteorUser';
import { generatePassword } from '../../lib/generatePassword';
import { notifyOnUserChange } from '../../lib/notifyListener';
import { passwordPolicy } from '../../lib/passwordPolicy';
import { saveCustomFields } from '../saveCustomFields';
import { saveUserIdentity } from '../saveUserIdentity';
import { setEmail } from '../setEmail';
import { setStatusText } from '../setStatusText';
import { handleBio } from './handleBio';
import { handleNickname } from './handleNickname';
import { saveNewUser } from './saveNewUser';
import { sendPasswordEmail } from './sendUserEmail';
import { setPasswordUpdater } from './setPasswordUpdater';
import { validateUserData } from './validateUserData';
import { validateUserEditing } from './validateUserEditing';

Expand All @@ -44,135 +47,148 @@ export type SaveUserData = {

joinDefaultChannels?: boolean;
sendWelcomeEmail?: boolean;

customFields?: Record<string, any>;
};
export type UpdateUserData = RequiredField<SaveUserData, '_id'>;
export const isUpdateUserData = (params: SaveUserData): params is UpdateUserData => '_id' in params && !!params._id;

export const saveUser = async function (userId: IUser['_id'], userData: SaveUserData) {
const oldUserData = userData._id && (await Users.findOneById(userData._id));
if (oldUserData && isUserFederated(oldUserData)) {
throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user');
}
const _saveUser = (session?: ClientSession) =>
async function (userId: IUser['_id'], userData: SaveUserData) {
const oldUserData = userData._id && (await Users.findOneById(userData._id));
if (oldUserData && isUserFederated(oldUserData)) {
throw new Meteor.Error('Edit_Federated_User_Not_Allowed', 'Not possible to edit a federated user');
}

await validateUserData(userId, userData);

await validateUserData(userId, userData);
await callbacks.run('beforeSaveUser', {
user: userData,
oldUser: oldUserData,
});

await callbacks.run('beforeSaveUser', {
user: userData,
oldUser: oldUserData,
});
let sendPassword = false;

let sendPassword = false;
if (userData.hasOwnProperty('setRandomPassword')) {
if (userData.setRandomPassword) {
userData.password = generatePassword();
userData.requirePasswordChange = true;
sendPassword = true;
}

if (userData.hasOwnProperty('setRandomPassword')) {
if (userData.setRandomPassword) {
userData.password = generatePassword();
userData.requirePasswordChange = true;
sendPassword = true;
delete userData.setRandomPassword;
}

delete userData.setRandomPassword;
}
if (!isUpdateUserData(userData)) {
// pass session?
return saveNewUser(userData, sendPassword);
}

if (!isUpdateUserData(userData)) {
return saveNewUser(userData, sendPassword);
}
await validateUserEditing(userId, userData);

// update user
const updater = Users.getUpdater();

if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) {
if (
!(await saveUserIdentity({
_id: userData._id,
username: userData.username,
name: userData.name,
updateUsernameInBackground: true,
updater,
session,
}))
) {
throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', {
method: 'saveUser',
});
}
}

await validateUserEditing(userId, userData);
if (typeof userData.statusText === 'string') {
await setStatusText(userData._id, userData.statusText, updater);
}

// update user
const updater = Users.getUpdater();
if (userData.email) {
const shouldSendVerificationEmailToUser = userData.verified !== true;
await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser, userData.verified === true, updater);
}

if (userData.hasOwnProperty('username') || userData.hasOwnProperty('name')) {
if (
!(await saveUserIdentity({
_id: userData._id,
username: userData.username,
name: userData.name,
updateUsernameInBackground: true,
updater,
}))
userData.password?.trim() &&
(await hasPermissionAsync(userId, 'edit-other-user-password')) &&
passwordPolicy.validate(userData.password)
) {
throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', {
method: 'saveUser',
});
await setPasswordUpdater(updater, userData.password.trim());
} else {
sendPassword = false;
}
}

if (typeof userData.statusText === 'string') {
await setStatusText(userData._id, userData.statusText, updater);
}

if (userData.email) {
const shouldSendVerificationEmailToUser = userData.verified !== true;
await setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser, userData.verified === true, updater);
}

if (
userData.password?.trim() &&
(await hasPermissionAsync(userId, 'edit-other-user-password')) &&
passwordPolicy.validate(userData.password)
) {
await Accounts.setPasswordAsync(userData._id, userData.password.trim());
} else {
sendPassword = false;
}

handleBio(updater, userData.bio);
handleNickname(updater, userData.nickname);

if (userData.roles) {
updater.set('roles', userData.roles);
}
if (userData.settings) {
updater.set('settings', { preferences: userData.settings.preferences });
}

if (userData.language) {
updater.set('language', userData.language);
}

if (typeof userData.requirePasswordChange !== 'undefined') {
updater.set('requirePasswordChange', userData.requirePasswordChange);
if (!userData.requirePasswordChange) {
updater.unset('requirePasswordChangeReason');

handleBio(updater, userData.bio);
handleNickname(updater, userData.nickname);

if (userData.roles) {
updater.set('roles', userData.roles);
}
}

if (typeof userData.verified === 'boolean' && !userData.email) {
updater.set('emails.0.verified', userData.verified);
}

await Users.updateFromUpdater({ _id: userData._id }, updater);

// App IPostUserUpdated event hook
const userUpdated = await Users.findOneById(userData._id);

await callbacks.run('afterSaveUser', {
user: userUpdated,
oldUser: oldUserData,
});

await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, {
user: userUpdated,
previousUser: oldUserData,
performedBy: await safeGetMeteorUser(),
});

if (sendPassword) {
await sendPasswordEmail(userData);
}

if (typeof userData.verified === 'boolean') {
delete userData.verified;
}
void notifyOnUserChange({
clientAction: 'updated',
id: userData._id,
diff: {
...userData,
emails: userUpdated?.emails,
},
});

return true;
};
if (userData.settings) {
updater.set('settings', { preferences: userData.settings.preferences });
}

if (userData.language) {
updater.set('language', userData.language);
}

if (typeof userData.requirePasswordChange !== 'undefined') {
updater.set('requirePasswordChange', userData.requirePasswordChange);
if (!userData.requirePasswordChange) {
updater.unset('requirePasswordChangeReason');
}
}

if (typeof userData.verified === 'boolean' && !userData.email) {
updater.set('emails.0.verified', userData.verified);
}

if (userData.customFields) {
await saveCustomFields(userData._id, userData.customFields, { _updater: updater, session });
}

await Users.updateFromUpdater({ _id: userData._id }, updater, { session });

// App IPostUserUpdated event hook
// We need to pass the session here to ensure this record is fetched
// with the uncommited transaction data.
const userUpdated = await Users.findOneById(userData._id, { session });

await callbacks.run('afterSaveUser', {
user: userUpdated,
oldUser: oldUserData,
});

await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, {
user: userUpdated,
previousUser: oldUserData,
performedBy: await safeGetMeteorUser(),
});

if (sendPassword) {
await sendPasswordEmail(userData);
}

if (typeof userData.verified === 'boolean') {
delete userData.verified;
}
void notifyOnUserChange({
clientAction: 'updated',
id: userData._id,
diff: {
...userData,
emails: userUpdated?.emails,
},
});

return true;
};

export const saveUser = wrapInSessionTransaction(_saveUser);
Loading
Loading