Skip to content

Commit

Permalink
Support user invites for multiple participants
Browse files Browse the repository at this point in the history
- Allow for existing user to be added to a brand new participant
- Allow for existing user to be added to an existing participant
- New email template for existing user being invited
  • Loading branch information
alex-yau-ttd committed Sep 3, 2024
1 parent 5b776c4 commit 490f8db
Show file tree
Hide file tree
Showing 4 changed files with 183 additions and 87 deletions.
62 changes: 62 additions & 0 deletions emailTemplates/inviteExistingUserToParticipant.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<style type="text/css">
body {
padding: 70px;
text-align: center;
font-family: 'Inter', sans-serif;
font-size: 18px;
color: #000000;
background-color: #fff;
}
.logo {
height: 30px;
}
.content {
margin-top: 50px;
width: 500px;
display: inline-block;
color: #030a40;
}
.button {
border-radius: 5px;
font-weight: 600;
font-size: 18px;
padding: 11px 42px;
background-color: #cdf200;
color: #030a40 !important;
text-decoration: none;
}
.content-text {
text-align: left;
font-size: 18px;
}
.footer {
margin: 50px 0 20px;
text-align: center;
}
</style>
</head>
<body>
<div>
<a href="{{baseUrl}}"><img src="{{baseUrl}}/uid2-logo.png" alt="UID2 logo" class="logo" /></a>
</div>

<div class="content">
<p class="content-text">
Hi {{firstName}},<br /><br />
You've been invited to join {{participantName}} in the UID2 Portal.<br /><br />
To get started, simply log in using the button below.
</p>
<div class="footer">
<a href="{{link}}" class="button">Log In</a>
</div>
</div>
</body>
</html>
86 changes: 38 additions & 48 deletions src/api/routers/participants/participantsCreation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AuditAction, AuditTrailEvents } from '../../entities/AuditTrail';
import {
Participant,
ParticipantCreationPartial,
ParticipantStatus,
ParticipantStatus
} from '../../entities/Participant';
import { User, UserCreationPartial } from '../../entities/User';
import { UserRoleId } from '../../entities/UserRole';
Expand All @@ -16,68 +16,61 @@ import { getKcAdminClient } from '../../keycloakAdminClient';
import { addSite, getSiteList, setSiteClientTypes } from '../../services/adminServiceClient';
import {
mapClientTypesToAdminEnums,
SiteCreationRequest,
SiteCreationRequest
} from '../../services/adminServiceHelpers';
import {
constructAuditTrailObject,
performAsyncOperationWithAuditTrail,
performAsyncOperationWithAuditTrail
} from '../../services/auditTrailService';
import {
assignApiParticipantMemberRole,
createNewUser,
sendInviteEmailToNewUser,
doesUserExistInKeycloak
} from '../../services/kcUsersService';
import {
getParticipantTypesByIds,
ParticipantRequest,
sendNewParticipantEmail,
sendNewParticipantEmail
} from '../../services/participantsService';
import { findUserByEmail } from '../../services/usersService';
import {
createAndInviteKeycloakUser,
findUserByEmail,
sendInviteEmailToExistingUser
} from '../../services/usersService';
import {
ParticipantCreationAndApprovalPartial,
ParticipantCreationRequest,
ParticipantCreationRequest
} from './participantClasses';

export async function validateParticipantCreationRequest(
participantRequest: z.infer<typeof ParticipantCreationRequest>
) {
let errorMessage = null;
const existingParticipant = await Participant.query().findOne(
'name',
participantRequest.participantName
);
if (existingParticipant) {
errorMessage = 'Duplicate participant name';
}
const existingUser = await findUserByEmail(participantRequest.email);
if (existingUser) {
errorMessage = 'Duplicate requesting user';
}
const kcAdminClient = await getKcAdminClient();
const existingKcUser = await kcAdminClient.users.find({ email: participantRequest.email });
if (existingKcUser.length > 0) {
errorMessage = 'Requesting user already exists in Keycloak';
return 'Duplicate participant name';
}

if (!participantRequest.siteId) {
// check for duplicate site in admin
const { siteName } = participantRequest;
// this is inefficient but we'd need a new endpoint in admin to search by name
const sites = await getSiteList();
if (sites.filter((site) => site.name === siteName).length > 0) {
errorMessage = 'Requested site name already exists';
return 'Requested site name already exists';
}
}
return errorMessage;
return null;
}

const createUserAndAssociatedParticipant = async (
const createParticipantWithUser = async (
parsedUser: z.infer<typeof UserCreationPartial>,
participantData: z.infer<typeof ParticipantCreationAndApprovalPartial>
) => {
await User.transaction(async (trx) => {
// create user
const newPortalUser = await User.query(trx).insertAndFetch(parsedUser);
): Promise<Participant | undefined> => {
const participant = await User.transaction(async (trx) => {
let user = await findUserByEmail(parsedUser.email);
if (!user) {
user = await User.query(trx).insertAndFetch(parsedUser);
}

// create participant
const newParticipant = await Participant.query(trx)
Expand All @@ -88,15 +81,17 @@ const createUserAndAssociatedParticipant = async (

// update user/participant/role mapping
await UserToParticipantRole.query(trx).insert({
userId: newPortalUser.id,
userId: user.id,
participantId: newParticipant?.id!,
userRoleId: UserRoleId.Admin,
});
return newParticipant;
});
return participant;
};

async function createParticipant(
email: string,
requestorEmail: string,
participantRequest: z.infer<typeof ParticipantCreationRequest>,
user: z.infer<typeof UserCreationPartial>,
traceId: string
Expand Down Expand Up @@ -126,7 +121,7 @@ async function createParticipant(
crmAgreementNumber: participantRequest.crmAgreementNumber,
});

const requestingUser = await findUserByEmail(email);
const requestingUser = await findUserByEmail(requestorEmail);
const auditTrailInsertObject = constructAuditTrailObject(
requestingUser!,
AuditTrailEvents.ManageParticipant,
Expand All @@ -151,22 +146,17 @@ async function createParticipant(
approverId: requestingUser?.id,
dateApproved: new Date(),
};
await createUserAndAssociatedParticipant(user, participantData);

// create keyCloak user
const kcAdminClient = await getKcAdminClient();
const newKcUser = await createNewUser(
kcAdminClient,
participantRequest.firstName,
participantRequest.lastName,
participantRequest.email
);

// assign proper api access
await assignApiParticipantMemberRole(kcAdminClient, user.email);
const newParticipant = await createParticipantWithUser(user, participantData);

// send email
await sendInviteEmailToNewUser(kcAdminClient, newKcUser);
const kcAdminClient = await getKcAdminClient();
const isExistingKcUser = await doesUserExistInKeycloak(kcAdminClient, user.email);
if (!isExistingKcUser) {
await createAndInviteKeycloakUser(user.firstName, user.lastName, user.email);
} else {
const existingPortalUser = await findUserByEmail(user.email);
sendInviteEmailToExistingUser(newParticipant!.name, existingPortalUser!, traceId);
}
});
}

Expand All @@ -178,12 +168,12 @@ export async function handleCreateParticipant(req: ParticipantRequest, res: Resp
if (validationError) {
return res.status(400).send(validationError);
}
const user = UserCreationPartial.parse({
const userRequest = UserCreationPartial.parse({
...req.body,
acceptedTerms: false,
});
const email = req.auth?.payload?.email as string;
await createParticipant(email, participantRequest, user, traceId);
const requestorEmail = req.auth?.payload?.email as string;
await createParticipant(requestorEmail, participantRequest, userRequest, traceId);

return res.sendStatus(200);
}
Expand Down
41 changes: 9 additions & 32 deletions src/api/routers/participants/participantsUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,20 @@ import { z } from 'zod';
import { AuditAction, AuditTrailEvents } from '../../entities/AuditTrail';
import { UserJobFunction } from '../../entities/User';
import { getTraceId } from '../../helpers/loggingHelpers';
import { getKcAdminClient } from '../../keycloakAdminClient';
import {
constructAuditTrailObject,
performAsyncOperationWithAuditTrail,
} from '../../services/auditTrailService';
import { createNewUser, sendInviteEmailToNewUser } from '../../services/kcUsersService';
import { ParticipantRequest, UserParticipantRequest } from '../../services/participantsService';
import {
createUserInPortal,
findUserByEmail,
getAllUserFromParticipant,
} from '../../services/usersService';
import { getAllUserFromParticipant, inviteUserToParticipant } from '../../services/usersService';

export async function getParticipantUsers(req: ParticipantRequest, res: Response) {
const { participant } = req;
const users = await getAllUserFromParticipant(participant!);
return res.status(200).json(users);
}

const invitationParser = z.object({
export const UserInvitationParser = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string(),
Expand All @@ -33,39 +27,22 @@ const invitationParser = z.object({
export async function handleInviteUserToParticipant(req: UserParticipantRequest, res: Response) {
try {
const { participant, user } = req;
const { firstName, lastName, email, jobFunction } = invitationParser.parse(req.body);
const userPartial = UserInvitationParser.parse(req.body);
const traceId = getTraceId(req);
// TODO: UID2-3878 - support user belonging to multiple participants by not 400ing here if the user already exists.
const existingUser = await findUserByEmail(email);
if (existingUser) {
return res.status(400).send('Error inviting user');
}
const kcAdminClient = await getKcAdminClient();
const auditTrailInsertObject = constructAuditTrailObject(
user!,
AuditTrailEvents.ManageTeamMembers,
{
action: AuditAction.Add,
firstName,
lastName,
email,
jobFunction,
},
participant!.id
firstName: userPartial.firstName,
lastName: userPartial.lastName,
email: userPartial.email,
jonFunction: userPartial.jobFunction,
}
);

await performAsyncOperationWithAuditTrail(auditTrailInsertObject, traceId, async () => {
const newUser = await createNewUser(kcAdminClient, firstName, lastName, email);
await createUserInPortal(
{
email,
jobFunction,
firstName,
lastName,
},
participant!.id
);
await sendInviteEmailToNewUser(kcAdminClient, newUser);
await inviteUserToParticipant(userPartial, participant!, traceId);
});

return res.sendStatus(201);
Expand Down
Loading

0 comments on commit 490f8db

Please sign in to comment.