From bceb535990f1a5bbfd8f2edeb345f68f1cd6f2b6 Mon Sep 17 00:00:00 2001 From: Matthew Liu Date: Sun, 3 Sep 2023 19:26:10 +1000 Subject: [PATCH 1/2] implemented observer for company watch list --- backend/src/admin.ts | 95 ++++------------ backend/src/company.ts | 152 +++++++++++++++----------- backend/src/entity/company.ts | 10 ++ backend/src/entity/student_profile.ts | 5 + backend/src/helpers.ts | 36 +++--- backend/src/mail.ts | 14 +-- backend/src/student.ts | 72 +++++++++++- backend/src/types/response.ts | 0 backend/src/types/shared.ts | 1 + 9 files changed, 213 insertions(+), 172 deletions(-) delete mode 100644 backend/src/types/response.ts diff --git a/backend/src/admin.ts b/backend/src/admin.ts index de61915a3..1d985b990 100644 --- a/backend/src/admin.ts +++ b/backend/src/admin.ts @@ -17,7 +17,7 @@ import { AdminCreateJobRequest, AdminApprovedJobPostsRequest, } from './types/request'; -import { env } from './environment'; +import CompanyFunctions from './company'; const LM = new LogModule('ADMIN'); @@ -379,7 +379,11 @@ You job post request titled "${jobToReject.role}" has been rejected as it does n

CSESoc Jobs Board Administrator

`, ); - Logger.Info(LM, `Admin ID=${req.adminID} unverified COMPANY=${req.params.companyAccountID}`); + Logger.Info( + LM, + `Admin ID=${req.adminID} unverified COMPANY=${req.params.companyAccountID}`, + ); + return { status: 200, msg: { @@ -447,38 +451,7 @@ You job post request titled "${jobToReject.role}" has been rejected as it does n res, async (): Promise => { const { companyID } = req.params; - Logger.Info(LM, `Admin ID=${req.adminID} attempting to find company ID=${companyID}`); - const company = await Helpers.doSuccessfullyOrFail( - async () => AppDataSource.getRepository(Company) - .createQueryBuilder() - .where('Company.id = :id', { id: companyID }) - .getOne(), - `Couldn't get request company object ID=${companyID} as Admin ID=${req.adminID}`, - ); - // get it's associated company account to verify - company.companyAccount = await Helpers.doSuccessfullyOrFail( - async () => AppDataSource.createQueryBuilder() - .relation(Company, 'companyAccount') - .of(company) - .loadOne(), - `Could not get the related company account for company ID=${company.id}`, - ); - - company.jobs = await Helpers.doSuccessfullyOrFail( - async () => AppDataSource.createQueryBuilder().relation(Company, 'jobs').of(company).loadMany(), - `Failed to find jobs for COMPANY_ACCOUNT=${companyID}`, - ); - - // verify whether the associated company account is verified - if (!company.companyAccount.verified) { - throw new Error( - `Admin ID=${req.adminID} attempted to create a job post for company ID=${companyID} however it was not a verified company`, - ); - } - - // create the job now - // ensure required parameters are present const msg = { applicationLink: req.body.applicationLink.trim(), description: req.body.description.trim(), @@ -493,20 +466,21 @@ You job post request titled "${jobToReject.role}" has been rejected as it does n isPaid: req.body.isPaid, }; - Helpers.requireParameters(msg.role); - Helpers.requireParameters(msg.description); - Helpers.requireParameters(msg.applicationLink); - Helpers.requireParameters(msg.expiry); - Helpers.requireParameters(msg.isPaid); + Logger.Info(LM, `Admin ID=${req.adminID} attempting to find company ID=${companyID}`); + + const company = await AppDataSource.getRepository(Company) + .createQueryBuilder() + .leftJoinAndSelect('Company.companyAccount', 'companyAccount') + .where('Company.id = :id', { id: companyID }) + .andWhere('companyAccount.verified = :verified', { verified: true }) + .getOne(); + + if (!company) { + throw Error(`Could not find COMPANY=${companyID}`); + } - Helpers.isValidJobMode(msg.jobMode); - Helpers.isValidStudentDemographic(msg.studentDemographic); - Helpers.isValidJobType(msg.jobType); - Helpers.isValidWorkingRights(msg.workingRights); - Helpers.isValidWamRequirement(msg.wamRequirements); + const companyAccountID = company.companyAccount.id; - Helpers.isDateInTheFuture(msg.expiry); - Helpers.validApplicationLink(msg.applicationLink); Logger.Info( LM, `Attempting to create job for COMPANY=${companyID} with ROLE=${msg.role} DESCRIPTION=${msg.description} applicationLink=${msg.applicationLink} as adminID=${req.adminID}`, @@ -526,38 +500,9 @@ You job post request titled "${jobToReject.role}" has been rejected as it does n newJob.wamRequirements = msg.wamRequirements; // jobs created by admin are implicitly approved newJob.approved = true; - // mark this job as one that the admin has created newJob.adminCreated = true; - await MailFunctions.AddMailToQueue( - env.MAIL_USERNAME, - 'CSESoc Jobs Board - CSESoc has created a job on your behalf', - ` - Congratulations! CSESoc has create a job post on your behalf titled "${newJob.role}". UNSW CSESoc students are now able to view the posting. -
-

Best regards,

-

CSESoc Jobs Board Administrator

- `, - ); - - company.jobs.push(newJob); - - await AppDataSource.manager.save(company); - - const newJobID: number = company.jobs[company.jobs.length - 1].id; - Logger.Info( - LM, - `Created JOB=${newJobID} for COMPANY_ACCOUNT=${companyID} as adminID=${req.adminID}`, - ); - - // check to see if that job is queryable - await Helpers.doSuccessfullyOrFail( - async () => AppDataSource.getRepository(Job) - .createQueryBuilder() - .where('Job.id = :id', { id: newJobID }) - .getOne(), - `Failed to fetch the newly created JOB=${newJobID}`, - ); + await CompanyFunctions.createJob(newJob, companyAccountID, true); return { status: StatusCodes.OK, diff --git a/backend/src/company.ts b/backend/src/company.ts index bba039450..d8dfac2e4 100644 --- a/backend/src/company.ts +++ b/backend/src/company.ts @@ -203,23 +203,94 @@ export default class CompanyFunctions { ); } - public static async CreateJob( - this: void, - req: CreateJobRequest, - res: Response, - next: NextFunction, - ) { + public static async createJob(job: Job, companyAccountID: number, adminCreated: boolean) { + const companyAccount = await AppDataSource.getRepository(CompanyAccount) + .createQueryBuilder() + .leftJoinAndSelect('CompanyAccount.company', 'company') + .leftJoinAndSelect('company.jobs', 'job') + .where('CompanyAccount.id = :id', { id: companyAccountID }) + .andWhere('CompanyAccount.verified = :verified', { verified: true }) + .getOne(); + + if (!companyAccount) { + throw new Error(`Could not find a verified COMPANY=${companyAccountID} to create a job for`); + } + + const { company } = companyAccount; + const companyJobs = company.jobs; + + companyJobs.push(job); + await AppDataSource.manager.save(companyAccount); + + const newJobID = companyJobs.at(-1).id; + const newJob = await AppDataSource.getRepository(Job) + .createQueryBuilder() + .where('Job.id = :id', { id: newJobID }) + .getOne(); + + if (!newJob) { + throw new Error(`Failed to create a new job for COMPANY=${companyAccountID}`); + } + + if (adminCreated) { + await MailFunctions.AddMailToQueue( + companyAccount.username, + 'CSESoc Jobs Board - CSESoc has created a job on your behalf', + ` + Congratulations! CSESoc has create a job post on your behalf titled "${newJob.role}". UNSW CSESoc students are now able to view the posting. +
+

Best regards,

+

CSESoc Jobs Board Administrator

+ `, + ); + } else { + await MailFunctions.AddMailToQueue( + companyAccount.username, + 'CSESoc Jobsboard - Job Post request submitted', + ` + Thank you for adding a job post to the CSESoc Jobs Board. As part of our aim to ensure student safety, we check all job posting requests to ensure they follow our guidelines, as the safety of our students is our utmost priority. +
+ A result will be sent to you shortly. +
+

Best regards,

+

CSESoc Jobsboard

+ `, + ); + } + + const subscribers = company.studentSubscribers; + await Promise.all( + subscribers.map(async (zID) => { + await MailFunctions.AddMailToQueue( + Helpers.getEmailFromZID(zID), + `CSESoc Jobsboard - Job opening from ${company.name}`, + ` + Hi, + + This is a email to let you kow that ${company.name} to has opened a new position (${job.role}). + Visit https://jobsboard.csesoc.unsw.edu.au to check it out! +
+ + If you no longer wish to receive updates from ${company.name}, please update your subscriptions preferences on Jobsboard. +
+

Best regards,

+

CSESoc Jobsboard

+ `, + ); + }), + ); + + Logger.Info(LM, `New JOB=${newJobID} successfully created for COMPANY=${companyAccountID}`); + + return newJobID; + } + + public static async CreateJob(req: CreateJobRequest, res: Response, next: NextFunction) { await Helpers.catchAndLogError( res, async (): Promise => { - if (req.companyAccountID === undefined) { - return { - status: StatusCodes.UNAUTHORIZED, - msg: { token: req.newJbToken }, - }; - } - // ensure required parameters are present const msg = { + companyAccountID: parseInt(req.companyAccountID, 10), applicationLink: req.body.applicationLink.trim(), description: req.body.description.trim(), role: req.body.role.trim(), @@ -233,18 +304,9 @@ export default class CompanyFunctions { isPaid: req.body.isPaid, }; - // ? double check data sent from frontend are guaranteed valid before removing - Helpers.requireParameters(msg.role); - Helpers.requireParameters(msg.description); - Helpers.requireParameters(msg.applicationLink); - Helpers.requireParameters(msg.expiry); - Helpers.requireParameters(msg.isPaid); - Helpers.isDateInTheFuture(msg.expiry); - Helpers.validApplicationLink(msg.applicationLink); - Logger.Info( LM, - `Attempting to create job for COMPANY=${req.companyAccountID} with ROLE=${msg.role} DESCRIPTION=${msg.description} applicationLink=${msg.applicationLink}`, + `Attempting to create job for COMPANY=${msg.companyAccountID} with ROLE=${msg.role} DESCRIPTION=${msg.description} applicationLink=${msg.applicationLink}`, ); const newJob = new Job(); @@ -260,50 +322,8 @@ export default class CompanyFunctions { newJob.additionalInfo = msg.additionalInfo; newJob.wamRequirements = msg.wamRequirements; - // get the company and the list of its jobs - const companyAccount: CompanyAccount = await AppDataSource.getRepository(CompanyAccount) - .createQueryBuilder() - .leftJoinAndSelect('CompanyAccount.company', 'company') - .leftJoinAndSelect('company.jobs', 'job') - .where('CompanyAccount.id = :id', { id: req.companyAccountID }) - .andWhere('CompanyAccount.verified = :verified', { verified: true }) - .getOne(); - - // prevent job from being posted since the provided company account is not verified - if (companyAccount === null) { - return { - status: StatusCodes.FORBIDDEN, - msg: { token: req.newJbToken }, - }; - } - - // add the new job to the list and commit to db - companyAccount.company.jobs.push(newJob); - await AppDataSource.manager.save(companyAccount); - - // get the supposed id for the new job and check if it's queryable from the db - const newJobID = companyAccount.company.jobs[companyAccount.company.jobs.length - 1].id; + const newJobID = await this.createJob(newJob, msg.companyAccountID, false); - Logger.Info(LM, `Created JOB=${newJobID} for COMPANY_ACCOUNT=${req.companyAccountID}`); - - await AppDataSource.getRepository(Job) - .createQueryBuilder() - .where('Job.id = :id', { id: newJobID }) - .getOne(); - - await MailFunctions.AddMailToQueue( - companyAccount.username, - 'CSESoc Jobs Board - Job Post request submitted', - ` - Thank you for adding a job post to the CSESoc Jobs Board. As part of our aim to ensure student safety, we check all job posting requests to ensure they follow our guidelines, as the safety of our students is our utmost priority. -
- A result will be sent to you shortly. -
-

Best regards,

-

Adam Tizzone

-

CSESoc Jobs Board Administrator

- `, - ); return { status: StatusCodes.OK, msg: { token: req.newJbToken, id: newJobID }, diff --git a/backend/src/entity/company.ts b/backend/src/entity/company.ts index e89a38aa7..657fe715e 100644 --- a/backend/src/entity/company.ts +++ b/backend/src/entity/company.ts @@ -8,9 +8,12 @@ import { CreateDateColumn, UpdateDateColumn, } from 'typeorm'; + import type Job from './job'; import type CompanyAccount from './company_account'; +type ZID = string; + @Entity() export default class Company { @PrimaryGeneratedColumn() @@ -51,6 +54,13 @@ export default class Company { @OneToOne('CompanyAccount', (companyAccount: CompanyAccount) => companyAccount.company) public companyAccount: CompanyAccount; + @Column({ + type: 'text', + array: true, + default: [], + }) + public studentSubscribers: ZID[]; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/entity/student_profile.ts b/backend/src/entity/student_profile.ts index da644ddba..eb839f0e0 100644 --- a/backend/src/entity/student_profile.ts +++ b/backend/src/entity/student_profile.ts @@ -7,6 +7,8 @@ import { } from 'typeorm'; import { WamRequirements, WorkingRights } from '../types/job-field'; +type CompanyID = number; + @Entity() export default class StudentProfile { @PrimaryGeneratedColumn() @@ -29,6 +31,9 @@ export default class StudentProfile { }) public workingRights: WorkingRights; + @Column({ type: 'int', array: true, default: [] }) + public subscribedCompanies: CompanyID[]; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/helpers.ts b/backend/src/helpers.ts index 86b7c8935..bf26b5f1f 100644 --- a/backend/src/helpers.ts +++ b/backend/src/helpers.ts @@ -50,19 +50,17 @@ export default class Helpers { throw new Error(`Invalid mailto or HTTP[S] application link: ${value}`); } - public static async doSuccessfullyOrFail - ( + public static async doSuccessfullyOrFail( func: () => Promise, failMessage: string, - ): Promise - { - const res = await func(); - if (res === undefined) { - Logger.Error(LM, failMessage); - throw new Error(failMessage); - } - return res; + ): Promise { + const res = await func(); + if (res === undefined) { + Logger.Error(LM, failMessage); + throw new Error(failMessage); } + return res; + } public static async catchAndLogError( res: Response, @@ -73,12 +71,10 @@ export default class Helpers { let response: IResponseWithStatus; try { response = await func(); - } - catch (error: unknown) { + } catch (error: unknown) { if (error instanceof Error) { Logger.Error(LM, `EXCEPTION: ${error.name} - ${error.message}\nSTACK:\n${error.stack}`); - } - else { + } else { Logger.Error(LM, 'Unknown error was thrown'); } response = funcOnError(); @@ -86,12 +82,10 @@ export default class Helpers { if (!res.headersSent) { if (response.msg === undefined) { res.sendStatus(response.status); - } - else { + } else { res.status(response.status).send(response.msg); } - } - else { + } else { Logger.Error(LM, 'Not performing any further action as headers are already sent.'); } if (next) next(); @@ -111,7 +105,7 @@ export default class Helpers { public static isValidGradYear(value: number): void { this.requireParameters(value); - if (value < (new Date()).getFullYear()) { + if (value < new Date().getFullYear()) { throw new Error(`Graduation year occurred in the past=${value}`); } } @@ -160,6 +154,10 @@ export default class Helpers { throw new Error(`Invalid WamRequirements=${value} provided.`); } } + + public static getEmailFromZID(zid: string) { + return `${zid}@ad.unsw.edu.au`; + } } export { IResponseWithStatus }; diff --git a/backend/src/mail.ts b/backend/src/mail.ts index dace4d728..5cfb1be4a 100644 --- a/backend/src/mail.ts +++ b/backend/src/mail.ts @@ -114,22 +114,22 @@ export default class MailFunctions { try { Helpers.requireParameters(env.MAIL_USERNAME); } catch (error) { - Logger.Error(LM, '[DEBUG] Mail username parameter checking failed'); + Logger.Error(LM, 'Mail username parameter checking failed'); } try { Helpers.requireParameters(recipient); } catch (error) { - Logger.Error(LM, '[DEBUG] Recipient parameter checking failed'); + Logger.Error(LM, 'Recipient parameter checking failed'); } try { Helpers.requireParameters(subject); } catch (error) { - Logger.Error(LM, '[DEBUG] Subject parameter checking failed'); + Logger.Error(LM, 'Subject parameter checking failed'); } try { Helpers.requireParameters(content); } catch (error) { - Logger.Error(LM, '[DEBUG] Content parameter checking failed'); + Logger.Error(LM, 'Content parameter checking failed'); } const newMailRequest: MailRequest = new MailRequest(); newMailRequest.sender = env.MAIL_USERNAME; @@ -138,7 +138,7 @@ export default class MailFunctions { newMailRequest.content = content; await AppDataSource.manager.save(newMailRequest); - Logger.Info(LM, '[DEBUG] Saved user mail request'); + Logger.Debug(LM, 'Saved user mail request'); // send a copy of this email to the admin const newMailRequestForAdmin: MailRequest = new MailRequest(); @@ -153,7 +153,7 @@ export default class MailFunctions { `; await AppDataSource.manager.save(newMailRequestForAdmin); - Logger.Info(LM, '[DEBUG] Saved admin mail request'); + Logger.Debug(LM, 'Saved admin mail request'); // send a copy of this email to the csesoc admin const newMailRequestForCsesocAdmin: MailRequest = new MailRequest(); @@ -168,7 +168,7 @@ export default class MailFunctions { `; await AppDataSource.manager.save(newMailRequestForCsesocAdmin); - Logger.Info(LM, '[DEBUG] Saved CSESoc admin mail request'); + Logger.Debug(LM, 'Saved CSESoc admin mail request'); return true; } catch (error: unknown) { diff --git a/backend/src/student.ts b/backend/src/student.ts index 50dc002c8..9542ed547 100644 --- a/backend/src/student.ts +++ b/backend/src/student.ts @@ -1,6 +1,7 @@ import { Response, NextFunction } from 'express'; import Fuse from 'fuse.js'; import { StatusCodes } from 'http-status-codes'; +import { In } from 'typeorm'; import { AppDataSource } from './config'; import Job from './entity/job'; import Student from './entity/student'; @@ -20,14 +21,14 @@ import { StudentGetProfileRequest, StudentEditProfileRequest, } from './types/request'; +import Company from './entity/company'; const LM = new LogModule('STUDENT'); -const paginatedJobLimit = 10; - export default class StudentFunctions { + private static paginatedJobLimit = 10; + public static async GetPaginatedJobs( - this: void, req: StudentPaginatedJobsRequest, res: Response, next: NextFunction, @@ -58,7 +59,7 @@ export default class StudentFunctions { 'company.logo', 'company.location', ]) - .take(paginatedJobLimit) + .take(this.paginatedJobLimit) .skip(parseInt(offset, 10)) .orderBy('job.expiry', 'ASC') .getMany(); @@ -318,8 +319,62 @@ export default class StudentFunctions { ); } + private static async updateCompanySubscriptions(studentZID: string, companyIDs: number[]) { + const newSubscriptions = new Set(companyIDs); + + const resp = await AppDataSource.getRepository(Student) + .createQueryBuilder() + .leftJoinAndSelect('Student.studentProfile', 'studentProfile') + .where('Student.zID = :ZID', { zID: studentZID }) + .select('studentProfile.id, studentProfile.subscribedCompanies') + .getOneOrFail(); + + const studentProfileId = resp.studentProfile.id; + const currSubscriptions = new Set(resp.studentProfile.subscribedCompanies); + const toDelete: number[] = []; + const toAdd: number[] = []; + + currSubscriptions.forEach((companyID) => { + if (!newSubscriptions.has(companyID)) { + toDelete.push(companyID); + } + }); + + newSubscriptions.forEach((companyID) => { + if (!currSubscriptions.has(companyID)) { + toAdd.push(companyID); + } + }); + + // remove student from companies they are no longer subscribed to + await AppDataSource.createQueryBuilder() + .update(Company) + .set({ + studentSubscribers: () => `array_remove(studentSubscribers, ${studentZID})`, + }) + .where({ id: In([...toDelete]) }) + .execute(); + + // add student to companies they were previously not subscribed to + await AppDataSource.createQueryBuilder() + .update(Company) + .set({ + studentSubscribers: () => `array_append(studentSubscribers, ${studentZID})`, + }) + .where({ id: In([...toAdd]) }) + .execute(); + + // update the student's list of subscriptions + await AppDataSource.createQueryBuilder() + .update(StudentProfile) + .set({ subscribedCompanies: toAdd }) + .where('id = :id', { id: studentProfileId }) + .execute(); + + Logger.Info(LM, `Successfully updated the company watch list of STUDENT=${studentZID}`); + } + public static async EditStudentProfile( - this: void, req: StudentEditProfileRequest, res: Response, next: NextFunction, @@ -335,6 +390,7 @@ export default class StudentFunctions { gradYear: req.body.gradYear, wam: req.body.wam, workingRights: req.body.workingRights, + subscribedCompanies: req.body.subscribedCompanies, }; Helpers.isValidGradYear(studentProfile.gradYear); @@ -349,6 +405,10 @@ export default class StudentFunctions { .where('Student.zID = :zID', { zID: studentZID }) .getOneOrFail(); + if (!student) { + throw Error(`Could not find profile for STUDENT=${studentZID} to update`); + } + await AppDataSource.getRepository(StudentProfile) .createQueryBuilder() .update(StudentProfile) @@ -360,6 +420,8 @@ export default class StudentFunctions { .where('id = :id', { id: student.studentProfile.id }) .execute(); + await this.updateCompanySubscriptions(studentZID, studentProfile.subscribedCompanies); + Logger.Info(LM, `STUDENT=${studentZID} sucessfully edited their profile`); return { status: StatusCodes.OK, msg: undefined }; diff --git a/backend/src/types/response.ts b/backend/src/types/response.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/src/types/shared.ts b/backend/src/types/shared.ts index 8c8e36065..21f7182fa 100644 --- a/backend/src/types/shared.ts +++ b/backend/src/types/shared.ts @@ -49,6 +49,7 @@ export interface StudentProfileInfo { gradYear: number; wam: WamRequirements; workingRights: WorkingRights; + subscribedCompanies: number[]; } export interface JobID { From 8ffb7fb0b3037fff3b4aa52375e358253f3f288a Mon Sep 17 00:00:00 2001 From: Matthew Liu Date: Sun, 3 Sep 2023 19:33:59 +1000 Subject: [PATCH 2/2] up --- backend/src/admin.ts | 6 ++++++ backend/src/company.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/backend/src/admin.ts b/backend/src/admin.ts index 1d985b990..bf80b6bd7 100644 --- a/backend/src/admin.ts +++ b/backend/src/admin.ts @@ -466,6 +466,12 @@ You job post request titled "${jobToReject.role}" has been rejected as it does n isPaid: req.body.isPaid, }; + Helpers.requireParameters(msg.role); + Helpers.requireParameters(msg.description); + Helpers.requireParameters(msg.applicationLink); + Helpers.requireParameters(msg.expiry); + Helpers.requireParameters(msg.isPaid); + Logger.Info(LM, `Admin ID=${req.adminID} attempting to find company ID=${companyID}`); const company = await AppDataSource.getRepository(Company) diff --git a/backend/src/company.ts b/backend/src/company.ts index d8dfac2e4..65e896b6a 100644 --- a/backend/src/company.ts +++ b/backend/src/company.ts @@ -304,6 +304,12 @@ export default class CompanyFunctions { isPaid: req.body.isPaid, }; + Helpers.requireParameters(msg.role); + Helpers.requireParameters(msg.description); + Helpers.requireParameters(msg.applicationLink); + Helpers.requireParameters(msg.expiry); + Helpers.requireParameters(msg.isPaid); + Logger.Info( LM, `Attempting to create job for COMPANY=${msg.companyAccountID} with ROLE=${msg.role} DESCRIPTION=${msg.description} applicationLink=${msg.applicationLink}`,