diff --git a/.gitignore b/.gitignore index fab8049..56e1dce 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ backend/typescript/serviceAccount.json package-lock.json .gitignore package.json +backend/typescript/graphql/sampleData/users.json diff --git a/backend/typescript/graphql/index.ts b/backend/typescript/graphql/index.ts index a924a62..4c7ba55 100644 --- a/backend/typescript/graphql/index.ts +++ b/backend/typescript/graphql/index.ts @@ -87,6 +87,7 @@ const graphQLMiddlewares = { deleteUserByEmail: authorizedByAdmin(), logout: isAuthorizedByUserId("userId"), resetPassword: isAuthorizedByEmail("email"), + sendSignInLink: authorizedByAllRoles(), }, }; diff --git a/backend/typescript/graphql/resolvers/authResolvers.ts b/backend/typescript/graphql/resolvers/authResolvers.ts index ecc6ce9..fa8a500 100644 --- a/backend/typescript/graphql/resolvers/authResolvers.ts +++ b/backend/typescript/graphql/resolvers/authResolvers.ts @@ -1,5 +1,4 @@ -import { CookieOptions, Request, Response } from "express"; - +// import { CookieOptions } from "express"; import * as firebaseAdmin from "firebase-admin"; import nodemailerConfig from "../../nodemailer.config"; import AuthService from "../../services/implementations/authService"; @@ -15,15 +14,18 @@ import IReviewService from "../../services/interfaces/reviewService"; import ReviewService from "../../services/implementations/reviewService"; const userService: IUserService = new UserService(); -const emailService: IEmailService = new EmailService(nodemailerConfig); +const emailService: IEmailService = new EmailService( + nodemailerConfig, + "UW Blueprint Internal Tools Team", +); const authService: IAuthService = new AuthService(userService, emailService); const reviewService: IReviewService = new ReviewService(); -const cookieOptions: CookieOptions = { - httpOnly: true, - sameSite: process.env.PREVIEW_DEPLOY ? "none" : "strict", - secure: process.env.NODE_ENV === "production", -}; +// const cookieOptions: CookieOptions = { +// httpOnly: true, +// sameSite: process.env.PREVIEW_DEPLOY ? "none" : "strict", +// secure: process.env.NODE_ENV === "production", +// }; // Object to pass back when frontend queries a login request // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -142,6 +144,15 @@ const authResolvers = { await authService.resetPassword(email); return true; }, + sendSignInLink: async ( + _parent: undefined, + { email }: { email: string }, + ): Promise => { + await authService.sendSignInLink(email).catch((err) => { + throw err; + }); + return true; + }, }, }; diff --git a/backend/typescript/graphql/resolvers/dashboardResolvers.ts b/backend/typescript/graphql/resolvers/dashboardResolvers.ts index 2f0dad3..cdaf3b9 100644 --- a/backend/typescript/graphql/resolvers/dashboardResolvers.ts +++ b/backend/typescript/graphql/resolvers/dashboardResolvers.ts @@ -26,13 +26,12 @@ const dashboardResolvers = { firstChoice, ); return applications; - },applicationsById: async ( + }, + applicationsById: async ( _parent: undefined, { id }: { id: number }, ): Promise => { - const application = await dashboardService.getApplicationsById( - id, - ); + const application = await dashboardService.getApplicationsById(id); return application; }, dashboardsByApplicationId: async ( diff --git a/backend/typescript/graphql/types/authType.ts b/backend/typescript/graphql/types/authType.ts index 8ec335c..c9c4992 100644 --- a/backend/typescript/graphql/types/authType.ts +++ b/backend/typescript/graphql/types/authType.ts @@ -38,6 +38,7 @@ const authType = gql` refresh(refreshToken: String!): String! logout(userId: ID!): ID resetPassword(email: String!): Boolean! + sendSignInLink(email: String!): Boolean! } `; diff --git a/backend/typescript/migrations/2023.04.05T17.43.42.ts b/backend/typescript/migrations/2023.04.05T17.43.42.ts index a2157ae..7478e99 100644 --- a/backend/typescript/migrations/2023.04.05T17.43.42.ts +++ b/backend/typescript/migrations/2023.04.05T17.43.42.ts @@ -2,10 +2,10 @@ import { DataType } from "sequelize-typescript"; // import ApplicationDashboardTable from "../models/applicationDashboard.model"; // import User from "../models/user.model"; +import { DataTypes } from "sequelize"; import { Migration } from "../umzug"; import allApplications from "./applicationlist.json"; -import { DataTypes } from "sequelize"; -import { statusType, secondChoiceStatusType } from "../types"; +import { StatusType, SecondChoiceStatusType } from "../types"; const TABLE_NAME = "applicantresponse"; @@ -36,10 +36,10 @@ const importApplicationData = () => { status: currApplication.status, term: currApplication.term, timesApplied: currApplication.timesApplied, - timestamp: currApplication.timestamp + timestamp: currApplication.timestamp, }; }); - + return seededData; }; @@ -98,7 +98,7 @@ export const up: Migration = async ({ context: sequelize }) => { }, resumeUrl: { type: DataType.STRING(4000), - allowNull: true, + allowNull: true, }, roleSpecificQuestions: { type: DataType.ARRAY(DataType.STRING(4000)), @@ -113,14 +113,14 @@ export const up: Migration = async ({ context: sequelize }) => { allowNull: true, }, status: { - type: DataType.ENUM(...Object.values(statusType)), + type: DataType.ENUM(...Object.values(StatusType)), allowNull: false, - defaultValue: statusType.PENDING + defaultValue: StatusType.PENDING, }, secondChoiceStatus: { - type: DataTypes.ENUM(...Object.values(secondChoiceStatusType)), + type: DataTypes.ENUM(...Object.values(SecondChoiceStatusType)), allowNull: false, - defaultValue: secondChoiceStatusType.NOT_APPLICABLE + defaultValue: SecondChoiceStatusType.NOT_APPLICABLE, }, term: { type: DataType.STRING(4000), @@ -141,7 +141,6 @@ export const up: Migration = async ({ context: sequelize }) => { const SEEDED_DATA = importApplicationData(); await sequelize.getQueryInterface().bulkInsert(TABLE_NAME, SEEDED_DATA); - }; export const down: Migration = async ({ context: sequelize }) => { diff --git a/backend/typescript/models/application.model.ts b/backend/typescript/models/application.model.ts index 6d2e446..3a9c568 100644 --- a/backend/typescript/models/application.model.ts +++ b/backend/typescript/models/application.model.ts @@ -1,10 +1,9 @@ /* eslint import/no-cycle: 0 */ import { Column, DataType, HasMany, Model, Table } from "sequelize-typescript"; +import { DataTypes } from "sequelize"; import ApplicationDashboardTable from "./applicationDashboard.model"; -import { DataTypes, Sequelize } from "sequelize"; -import { statusType, secondChoiceStatusType } from "../types"; - +import { StatusType, SecondChoiceStatusType } from "../types"; @Table({ tableName: "applicantresponse" }) export default class Application extends Model { @@ -56,17 +55,17 @@ export default class Application extends Model { @Column({ type: DataType.ARRAY(DataType.STRING) }) shortAnswerQuestions!: string[]; - @Column({ - type: DataType.ENUM(...Object.values(statusType)), - defaultValue: statusType.PENDING + @Column({ + type: DataType.ENUM(...Object.values(StatusType)), + defaultValue: StatusType.PENDING, }) - status!: statusType; + status!: StatusType; @Column({ - type: DataTypes.ENUM(...Object.values(secondChoiceStatusType)), - defaultValue: secondChoiceStatusType.NOT_APPLICABLE + type: DataTypes.ENUM(...Object.values(SecondChoiceStatusType)), + defaultValue: SecondChoiceStatusType.NOT_APPLICABLE, }) - secondChoiceStatus!: secondChoiceStatusType; + secondChoiceStatus!: SecondChoiceStatusType; @Column({ type: DataType.STRING }) term!: string; diff --git a/backend/typescript/server.ts b/backend/typescript/server.ts index 053ddb6..5b2e1f4 100644 --- a/backend/typescript/server.ts +++ b/backend/typescript/server.ts @@ -6,7 +6,8 @@ import { ApolloServer } from "apollo-server-express"; import { sequelize } from "./models"; import schema from "./graphql"; import Application from "./models/application.model"; - +import memeberData from "./graphql/sampleData/members.json"; +import firebaseAuthUsers from "./graphql/sampleData/users.json"; const CORS_ALLOW_LIST = [ "http://localhost:3000", @@ -64,16 +65,79 @@ admin.initializeApp({ const db = admin.database(); const ref = db.ref("studentApplications"); +app.get("/diff", async (req, res) => { + const currentTerm = memeberData.term; + const currentTermMembers: string[] = []; + + // const teamToMembers : Record = {}; + memeberData.members.forEach((member) => { + if (member.term === currentTerm) { + currentTermMembers.push(member.name); + // if (teamToMembers[member.teams[0]]) { + // teamToMembers[member.teams[0]].push(member.name); + // } else { + // teamToMembers[member.teams[0]] = [member.name]; + // } + } + }); + + // const teamToMemberSize : Record = {}; + // (Object.keys(teamToMembers)).forEach((team) => { + // teamToMemberSize[team] = teamToMembers[team].length; + // } + // ) + + const firebaseUsers: Record = {}; + firebaseAuthUsers.forEach((user) => { + firebaseUsers[user.uid] = user.displayName; + }); + + // see if all currentTermMembers have their name in firebase_users + const missingMembersFromFirebaseAuth: string[] = []; + + currentTermMembers.forEach((member) => { + if (!Object.values(firebaseUsers).includes(member)) { + missingMembersFromFirebaseAuth.push(member); + } + }); + + res.status(200).json({ + currentTerm, + currentTermMembers, + firebaseUsers, + missingMembersFromFirebaseAuth, + }); +}); + +app.get("/authUsers", async (req, res) => { + try { + admin + .auth() + .listUsers() + .then((data) => { + res.status(200).json(data.users); + }); + } catch (error) { + res + .status(500) + .send("An error occurred while retrieving the applications."); + } +}); app.get("/termApplications", async (req, res) => { - ref.orderByChild("term").equalTo("Fall 2023").once("value", function (snapshot) { - const applications: Application[] = []; - snapshot.forEach((childSnapshot) => { - applications.push(childSnapshot.val()); + ref + .orderByChild("term") + .equalTo("Fall 2023") + // eslint-disable-next-line func-names + .once("value", function (snapshot) { + const applications: Application[] = []; + snapshot.forEach((childSnapshot) => { + applications.push(childSnapshot.val()); + }); + res.status(200).json(applications); }); - res.status(200).json(applications); - })}); - +}); + app.get("/applications", async (req, res) => { try { const snapshot = await ref.once("value"); @@ -83,7 +147,6 @@ app.get("/applications", async (req, res) => { }); res.status(200).json(applications); } catch (error) { - console.error(error); res .status(500) .send("An error occurred while retrieving the applications."); @@ -101,7 +164,6 @@ app.get("/applications/:id", async (req, res) => { res.status(404).send("Student application not found."); } } catch (error) { - console.error(error); res .status(500) .send("An error occurred while retrieving the student application."); @@ -109,5 +171,6 @@ app.get("/applications/:id", async (req, res) => { }); app.listen({ port: process.env.PORT || 5000 }, () => { + // eslint-disable-next-line no-console console.info(`Server is listening on port ${process.env.PORT || 5000}!`); }); diff --git a/backend/typescript/services/implementations/appDashboardService.ts b/backend/typescript/services/implementations/appDashboardService.ts index 68ce803..6d41672 100644 --- a/backend/typescript/services/implementations/appDashboardService.ts +++ b/backend/typescript/services/implementations/appDashboardService.ts @@ -85,7 +85,7 @@ class AppDashboardService implements IAppDashboardService { secondChoiceStatus: application.secondChoiceStatus, term: application.term, timesApplied: application.timesApplied, - timestamp: application.timestamp + timestamp: application.timestamp, }; }); } catch (error: unknown) { @@ -99,20 +99,22 @@ class AppDashboardService implements IAppDashboardService { return applicationsByRoleDTO; } -//Takes in an application id and returns an array of applicants with same id -async getApplicationsById(id: number): Promise { + // Takes in an application id and returns an array of applicants with same id + async getApplicationsById(id: number): Promise { let applications: Array | null; - let applicationById: Application |undefined; + let applicationById: Application | undefined; let applicationByIdDTO: ApplicationDTO; try { applications = await Application.findAll(); - applicationById = applications.find(application => application.id == id); + applicationById = applications.find( + (application) => application.id === id, + ); if (applicationById === undefined) { // Handle the case when no application is found throw new Error(`Application with id ${id} not found`); } - + applicationByIdDTO = { id: applicationById.id, academicOrCoop: applicationById.academicOrCoop, @@ -134,8 +136,8 @@ async getApplicationsById(id: number): Promise { status: applicationById.status, term: applicationById.term, timesApplied: applicationById.timesApplied, - timestamp: applicationById.timestamp - }; + timestamp: applicationById.timestamp, + }; } catch (error: unknown) { Logger.error( `Failed to get applications by id = ${id}. Reason = ${getErrorMessage( @@ -144,7 +146,7 @@ async getApplicationsById(id: number): Promise { ); throw error; } - return applicationByIdDTO; + return applicationByIdDTO; } async getDashboardsByApplicationId( diff --git a/backend/typescript/services/implementations/authService.ts b/backend/typescript/services/implementations/authService.ts index 6f5ce78..4caecd5 100644 --- a/backend/typescript/services/implementations/authService.ts +++ b/backend/typescript/services/implementations/authService.ts @@ -226,6 +226,55 @@ class AuthService implements IAuthService { return false; } } + + async sendSignInLink(email: string): Promise { + if (!this.emailService) { + const errorMessage = + "Attempted to call sendEmailVerificationLink but this instance of AuthService does not have an EmailService instance"; + Logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!email.endsWith("@uwblueprint.org")) { + const errorMessage = `Attempted to call sendEmailVerificationLink with an email, ${email}, that does not end with @uwblueprint.org`; + Logger.error(errorMessage); + throw new Error(errorMessage); + } + + try { + await firebaseAdmin + .auth() + .generateSignInWithEmailLink(email, { + url: `${process.env.FIREBASE_REQUEST_URI}/admin`, + handleCodeInApp: true, + }) + .then((link) => { + const emailBody = ` + Hello, +

+ We noticed that you are a current UW Blueprint member but do not have an account in our internal recruitment tool. Please click the following link to sign in with your blueprint email. +

+ Sign into internal recruitment tool`; + + return this.emailService?.sendEmail( + email, + "Sign into internal recruitment tool", + emailBody, + ); + }) + .catch((error) => { + Logger.error("Failed to send email sign in link to user with email"); + throw error; + }); + + return true; + } catch (error) { + Logger.error( + `Failed to generate email sign in link for user with email ${email} ${error}`, + ); + throw error; + } + } } export default AuthService; diff --git a/backend/typescript/services/interfaces/authService.ts b/backend/typescript/services/interfaces/authService.ts index 85d4f36..5972d45 100644 --- a/backend/typescript/services/interfaces/authService.ts +++ b/backend/typescript/services/interfaces/authService.ts @@ -82,6 +82,14 @@ interface IAuthService { accessToken: string, requestedEmail: string, ): Promise; + + /** + * Sends an email to the input with a sign in link to the application. This will be used to create a user in firebase for current term BP members who are not in the database. + * @param email email of user to be created + * @throws Error if unable to generate link or send email + * @returns true if email sent successfully + */ + sendSignInLink(email: string): Promise; } export default IAuthService; diff --git a/backend/typescript/types.ts b/backend/typescript/types.ts index da9a844..362088c 100644 --- a/backend/typescript/types.ts +++ b/backend/typescript/types.ts @@ -1,23 +1,22 @@ export type Role = "User" | "Admin"; - -export enum statusType { +export enum StatusType { ACCEPTED = "accepted", APPLIED = "applied", INTERVIEWED = "interviewed", IN_REVIEW = "in review", PENDING = "pending", - REJECTED = "rejected" + REJECTED = "rejected", } -export enum secondChoiceStatusType { +export enum SecondChoiceStatusType { CONSIDERED = "considered", NOT_CONSIDERED = "not considered", NOT_APPLICABLE = "n/a", RECOMMENDED = "recommended", IN_REVIEW = "in review", INTERVIEW = "interview", - NO_INTERVIEW = "no interview" + NO_INTERVIEW = "no interview", } export type Token = { @@ -68,13 +67,12 @@ export type ApplicationDTO = { resumeUrl: string; roleSpecificQuestions: string[]; secondChoiceRole: string; - shortAnswerQuestions: string[] + shortAnswerQuestions: string[]; status: string; secondChoiceStatus: string; term: string; timesApplied: string; timestamp: bigint; - }; export type ApplicationDashboardRowDTO = {