Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invite Learner API #77

Merged
merged 15 commits into from
Dec 4, 2024
2 changes: 1 addition & 1 deletion backend/middlewares/validators/authValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const signupRequestValidator = async (
return next();
};

export const inviteAdminRequestValidator = async (
export const inviteUserRequestValidator = async (
req: Request,
res: Response,
next: NextFunction,
Expand Down
19 changes: 15 additions & 4 deletions backend/models/user.mgmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export interface User extends Document {
status: Status;
}

export interface Learner extends User {
facilitator: ObjectId;
}

export interface Facilitator extends User {
learners: Array<ObjectId>;
}

const baseOptions = {
discriminatorKey: "role",
timestamps: true,
Expand Down Expand Up @@ -75,12 +83,15 @@ const LearnerSchema = new Schema({
},
});

const Administrator = UserModel.discriminator(
const AdministratorModel = UserModel.discriminator(
"Administrator",
AdministratorSchema,
);
const Facilitator = UserModel.discriminator("Facilitator", FacilitatorSchema);
const Learner = UserModel.discriminator("Learner", LearnerSchema);
const FacilitatorModel = UserModel.discriminator<Facilitator>(
"Facilitator",
FacilitatorSchema,
);
const LearnerModel = UserModel.discriminator<Learner>("Learner", LearnerSchema);

export { Administrator, Facilitator, Learner };
export { AdministratorModel, FacilitatorModel, LearnerModel };
export default UserModel;
37 changes: 35 additions & 2 deletions backend/rest/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import {
loginRequestValidator,
signupRequestValidator,
inviteAdminRequestValidator,
inviteUserRequestValidator,
forgotPasswordRequestValidator,
updateTemporaryPasswordRequestValidator,
updateUserStatusRequestValidator,
Expand Down Expand Up @@ -173,7 +173,7 @@ authRouter.post("/isUserVerified/:email", async (req, res) => {

authRouter.post(
"/inviteAdmin",
inviteAdminRequestValidator,
inviteUserRequestValidator,
isAuthorizedByRole(new Set(["Administrator"])),
async (req, res) => {
try {
Expand All @@ -197,6 +197,39 @@ authRouter.post(
},
);

authRouter.post(
"/inviteLearner",
inviteUserRequestValidator,
isAuthorizedByRole(new Set(["Facilitator"])),
async (req, res) => {
try {
const temporaryPassword = generate({
length: 20,
numbers: true,
});
const invitedLearnerUser = await userService.createLearner(
{
firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email,
role: "Learner",
password: temporaryPassword,
status: "Invited",
},
req.body.facilitatorId,
);
await authService.sendLearnerInvite(
req.body.firstName,
req.body.email,
temporaryPassword,
);
res.status(200).json(invitedLearnerUser);
} catch (error: unknown) {
res.status(500).json({ error: getErrorMessage(error) });
}
},
);

// /* Reset password through a "Forgot Password" option */
authRouter.post(
"/forgotPassword",
Expand Down
39 changes: 39 additions & 0 deletions backend/services/implementations/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,45 @@ class AuthService implements IAuthService {
}
}

async sendLearnerInvite(
firstName: string,
email: string,
temporaryPassword: string,
): Promise<void> {
if (!this.emailService) {
const errorMessage =
"Attempted to call sendLearnerInvite but this instance of AuthService does not have an EmailService instance";
Logger.error(errorMessage);
throw new Error(errorMessage);
}

const emailVerificationLink = await firebaseAdmin
.auth()
.generateEmailVerificationLink(email);

const emailBody = `Hello ${firstName},
<br><br>
Welcome to Smart Saving, Smart Spending!
<br><br>
Please click the link to confirm your account: <a href=${emailVerificationLink}>Confirm my account</a>
<br>
If the link has expired, ask your facilitator to invite you again.
<br><br>
To log in for the first time, use the following email and password:
<br><br>
Email: <strong>${email}</strong>
<br>
Password: <strong>${temporaryPassword}</strong>
<br><br>
Happy learning!`;

await this.emailService.sendEmail(
email,
"Welcome to Smart Saving, Smart Spending!",
emailBody,
);
}

async isAuthorizedByRole(
accessToken: string,
roles: Set<Role>,
Expand Down
58 changes: 56 additions & 2 deletions backend/services/implementations/userService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import * as firebaseAdmin from "firebase-admin";

import { ObjectId } from "mongoose";
import IUserService from "../interfaces/userService";
import MgUser, { User } from "../../models/user.mgmodel";
import MgUser, {
Learner,
User,
LearnerModel,
FacilitatorModel,
} from "../../models/user.mgmodel";
import {
CreateUserDTO,
LearnerDTO,
Role,
Status,
UpdateUserDTO,
Expand Down Expand Up @@ -184,6 +189,55 @@ class UserService implements IUserService {
};
}

async createLearner(
user: CreateUserDTO,
facilitatorId: string,
): Promise<LearnerDTO> {
let newLearner: Learner;
let firebaseUser: firebaseAdmin.auth.UserRecord;
try {
firebaseUser = await firebaseAdmin.auth().createUser({
email: user.email,
password: user.password,
});
try {
newLearner = await LearnerModel.create({
...user,
authId: firebaseUser.uid,
facilitator: facilitatorId,
});
await FacilitatorModel.findByIdAndUpdate(
facilitatorId,
{ $push: { learners: newLearner.id } },
{ runValidators: true },
);
} catch (mongoError) {
try {
await firebaseAdmin.auth().deleteUser(firebaseUser.uid);
} catch (firebaseError: unknown) {
const errorMessage = [
"Failed to rollback Firebase user creation after MongoDB user creation failure. Reason =",
getErrorMessage(firebaseError),
"Orphaned authId (Firebase uid) =",
firebaseUser.uid,
];
Logger.error(errorMessage.join(" "));
}
throw mongoError;
}
} catch (err: unknown) {
Logger.error(
`Failed to create learner. Reason = ${getErrorMessage(err)}`,
);
throw err;
}

return {
...newLearner.toObject(),
email: firebaseUser.email ?? "",
};
}

async updateUserById(
userId: ObjectId | string,
user: UpdateUserDTO,
Expand Down
12 changes: 12 additions & 0 deletions backend/services/interfaces/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ interface IAuthService {
*/
sendAdminInvite(email: string, temporaryPassword: string): Promise<void>;

/**
* Sends an email invitation to an invited learner with the temporary password specified
* @param email email of new learner invited
* @param temporaryPassword the new learner's temporary password
* @throws Error if unable to generate link or send email
*/
sendLearnerInvite(
firstName: string,
email: string,
temporaryPassword: string,
): Promise<void>;

/**
* Changes a user's password
* @param email the user's email address
Expand Down
9 changes: 9 additions & 0 deletions backend/services/interfaces/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ interface IUserService {
*/
createUser(user: CreateUserDTO, authId?: string): Promise<UserDTO>;

/**
* Create a learner, link them to their facilitator, and add them to their facilitator's learner list
* @param user the user to be created
* @param facilitatorId the auth ID of the facilitator to link the new learner to
* @returns a UserDTO with the created learner's information
* @throws Error if user creation fails
*/
createLearner(user: CreateUserDTO, facilitatorId: string): Promise<UserDTO>;

/**
* Update a user.
* Note: the password cannot be updated using this method, use IAuthService.resetPassword instead
Expand Down
1 change: 0 additions & 1 deletion frontend/src/theme/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { TypographyStyleOptions } from "@mui/material/styles/createTypography";
import { error, learner, administrator, facilitator, neutral } from "./palette";
import "@fontsource/lexend-deca";


// adding custom attributes to palette
declare module "@mui/material/styles" {
// allow configuration using `createTheme`
Expand Down
Loading