Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 3x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 11x 11x 11x 11x 11x 11x 11x 11x 11x 1x | import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isEmpty, isNil, keyBy, merge, pick } from 'lodash-es';
import { Repository } from 'typeorm';
import { User, type Company } from '@amalia/core/models';
import { type DataRefreshmentValidationError } from '@amalia/core/types';
import {
DATA_CONNECTOR_DIRECTORY_MATCHABLE_FIELDS,
DATA_CONNECTOR_REQUIRED_MATCHING_FIELDS_IF_SYNC_EXISTING,
DataConnectorTypes,
} from '@amalia/data-capture/connectors/types';
import { assert, toError } from '@amalia/ext/typescript';
import { UserExternalIdSource, UserHrisIdSource, type SyncUserRequest } from '@amalia/tenants/users/types';
import { toHrisIdSource } from '../mappers/users-id-source.mapper';
import { formatZodError, userSyncRequestSchema } from './user-sync-request-schema';
@Injectable()
export class UserConnectorService {
public constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
/**
* Find user list matching values for a given key.
* Here we need to find on the whole user databases (as some salesforce emails may be used on different companies).
* @param idValues
* @param idKey
*/
private async findUsersByCriteria(idValues: string[], idKey: string = 'email'): Promise<User[]> {
assert(
[
...DATA_CONNECTOR_DIRECTORY_MATCHABLE_FIELDS,
...DATA_CONNECTOR_REQUIRED_MATCHING_FIELDS_IF_SYNC_EXISTING,
].includes(idKey),
'Invalid idKey',
);
if (isEmpty(idValues)) {
return [];
}
return this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.company', 'company')
.where(`lower(user.${idKey}) IN (:...idValues)`, { idValues: idValues.map((v) => v.toLowerCase()) })
.getMany();
}
public async bulkSave(
company: Company,
users: SyncUserRequest[],
userIdField: keyof SyncUserRequest = 'email',
synchronizeOnlyExistingUser?: boolean,
limitToCurrentConnector?: UserExternalIdSource,
connectorType?: DataConnectorTypes,
): Promise<{ users: User[]; errors: DataRefreshmentValidationError[] }> {
const filteredUsers = users
// Remove users with no foreign key value.
.filter((u) => !isNil(u[userIdField]) && !isEmpty(u[userIdField]))
.map((u) => ({
...u,
// Normalize the foreign keys.
[userIdField]: UserConnectorService.normalizeForeignKey(u, userIdField),
}));
if (filteredUsers.length === 0) {
return { users: [], errors: [] };
}
const existingUsers = await this.findUsersByCriteria(
filteredUsers.map((u) => u[userIdField]!),
userIdField,
);
const { users: mergedUsers, errors } = UserConnectorService.mergeUsersToSaveWithExistingUsers(
company,
users,
existingUsers,
userIdField,
limitToCurrentConnector,
connectorType,
);
const usersToSave = UserConnectorService.filterUsersToSave(company, mergedUsers, synchronizeOnlyExistingUser);
return { users: await this.userRepository.save(usersToSave), errors };
}
/**
* Some users may miss required field, or are from another company.
* We need to filter them out.
* @param company
* @param users
* @param synchronizeOnlyExistingUser
* @private
*/
private static filterUsersToSave(company: Company, users: User[], synchronizeOnlyExistingUser?: boolean) {
return (
users
// We don't want to save users which exist on another company.
.filter(
(u) =>
// We don't want to sync technical users, that may have no firstname or lastname.
u.company.id === company.id &&
// We don't want to sync technical users, that may have no firstname or lastname.
!!u.firstName &&
!!u.lastName &&
// If we want to synchronize only existing users, we keep
// only users that already have an id.
(!synchronizeOnlyExistingUser || !!u.id),
)
);
}
/**
* Merge users to save with existing users.
* @param company
* @param users
* @param existingUsers
* @param userIdField
* @param limitToCurrentConnector
* @private
*/
private static mergeUsersToSaveWithExistingUsers(
company: Company,
users: SyncUserRequest[],
existingUsers: User[] = [],
userIdField: keyof SyncUserRequest = 'email',
limitToCurrentConnector?: UserExternalIdSource,
connectorType?: DataConnectorTypes,
): { users: User[]; errors: DataRefreshmentValidationError[] } {
// Make a hashmap of existing users by foreign key.
const existingUsersByForeignKey: Record<User['id'], User | undefined> = keyBy(existingUsers, (userSyncRequest) =>
UserConnectorService.normalizeForeignKey(userSyncRequest, userIdField),
);
const errors: DataRefreshmentValidationError[] = [];
// For each user, eventually update the existing user in database.
const usersToReturn = users.map((userSyncRequest) => {
let userForeignKey: string;
try {
userForeignKey = UserConnectorService.normalizeForeignKey(userSyncRequest, userIdField);
} catch (error) {
errors.push({
row: userSyncRequest,
error: `Cannot sync user with directory: ${toError(error).message}`,
});
return null;
}
const existingUser = existingUsersByForeignKey[userForeignKey];
if (existingUser && existingUser.company.id !== company.id) {
errors.push({
row: { email: existingUser.email },
error: `User ${userForeignKey} is from another company (${existingUser.company.name} instead of ${company.name})`,
} satisfies DataRefreshmentValidationError);
return null;
}
// validate user props
const { error, data: validUserProps } = userSyncRequestSchema.safeParse(userSyncRequest);
if (error) {
errors.push({
row: userSyncRequest,
error: `Cannot sync user with directory: ${formatZodError(error)}`,
});
return null;
}
// If the user is from another connector, we don't want to update it.
if (
limitToCurrentConnector &&
existingUser &&
existingUser.externalIdSource !== UserExternalIdSource.NONE &&
existingUser.externalIdSource !== limitToCurrentConnector
) {
// However, we may want to update HRIS fields if it's not already synced.
if (validUserProps.hrisId && !existingUser.hrisId && existingUser.hrisIdSource === UserHrisIdSource.NONE) {
const userToSave = merge({}, existingUser, {
hrisId: validUserProps.hrisId,
hrisIdSource: connectorType ? toHrisIdSource(connectorType) : UserHrisIdSource.OTHERSOURCE,
} as Partial<User>) as User;
userToSave.company = company;
return userToSave;
}
return existingUser;
}
const userToSave = merge(
{},
existingUser || new User(),
pick(validUserProps, [...DATA_CONNECTOR_DIRECTORY_MATCHABLE_FIELDS, 'externalIdSource']),
) as User;
userToSave.company = company;
return userToSave;
});
return { users: usersToReturn.filter(Boolean), errors };
}
private static normalizeForeignKey(user: SyncUserRequest | User, field: keyof SyncUserRequest) {
const value = user[field];
assert(
value,
`No user field could be used to index the collection. field: ${field}. user: ${user.email ?? `${user.firstName} ${user.lastName}`}`,
);
return typeof value === 'string' ? value.toLowerCase() : value;
}
}
|