From 00896853e198297d74e7294f0e284f750aff7319 Mon Sep 17 00:00:00 2001 From: BryannYeap Date: Wed, 12 Oct 2022 15:47:09 +0800 Subject: [PATCH] [offers][refactor] Shift test pages under a route --- .../createProfile.tsx} | 2 +- .../offers/{test.tsx => test/listOffers.tsx} | 0 .../router/offers/offers-profile-router.ts | 353 ++++++++++++++++++ .../portal/src/server/router/offers/offers.ts | 342 +++++++++++++++++ 4 files changed, 696 insertions(+), 1 deletion(-) rename apps/portal/src/pages/offers/{testCreateProfile.tsx => test/createProfile.tsx} (98%) rename apps/portal/src/pages/offers/{test.tsx => test/listOffers.tsx} (100%) create mode 100644 apps/portal/src/server/router/offers/offers-profile-router.ts create mode 100644 apps/portal/src/server/router/offers/offers.ts diff --git a/apps/portal/src/pages/offers/testCreateProfile.tsx b/apps/portal/src/pages/offers/test/createProfile.tsx similarity index 98% rename from apps/portal/src/pages/offers/testCreateProfile.tsx rename to apps/portal/src/pages/offers/test/createProfile.tsx index fea8cdcc..59b3cfb9 100644 --- a/apps/portal/src/pages/offers/testCreateProfile.tsx +++ b/apps/portal/src/pages/offers/test/createProfile.tsx @@ -131,7 +131,7 @@ function Test() { `offers.profile.listOne`, { profileId, - token: "6c8d53530163bb765c42bd9f441aa7e345f607c4e1892edbc64e5bbbbe7ee916" + token: '6c8d53530163bb765c42bd9f441aa7e345f607c4e1892edbc64e5bbbbe7ee916', }, ]); diff --git a/apps/portal/src/pages/offers/test.tsx b/apps/portal/src/pages/offers/test/listOffers.tsx similarity index 100% rename from apps/portal/src/pages/offers/test.tsx rename to apps/portal/src/pages/offers/test/listOffers.tsx diff --git a/apps/portal/src/server/router/offers/offers-profile-router.ts b/apps/portal/src/server/router/offers/offers-profile-router.ts new file mode 100644 index 00000000..9fb016ed --- /dev/null +++ b/apps/portal/src/server/router/offers/offers-profile-router.ts @@ -0,0 +1,353 @@ +import crypto, { randomUUID } from 'crypto'; +import { z } from 'zod'; +import { Prisma } from '@prisma/client'; + +import { createRouter } from '../context'; + +const valuation = z.object({ + currency: z.string(), + value: z.number(), +}); + +// TODO: handle both full time and intern +const offer = z.object({ + comments: z.string(), + companyId: z.string(), + job: z.object({ + base: valuation.optional(), // Full time + bonus: valuation.optional(), // Full time + internshipCycle: z.string().optional(), // Intern + level: z.string().optional(), // Full time + monthlySalary: valuation.optional(), // Intern + specialization: z.string(), + startYear: z.number().optional(), // Intern + stocks: valuation.optional(), // Full time + title: z.string(), + totalCompensation: valuation.optional(), // Full time + }), + jobType: z.string(), + location: z.string(), + monthYearReceived: z.date(), + negotiationStrategy: z.string(), +}); + +const experience = z.object({ + companyId: z.string().optional(), + durationInMonths: z.number().optional(), + jobType: z.string().optional(), + level: z.string().optional(), + monthlySalary: valuation.optional(), + specialization: z.string().optional(), + title: z.string().optional(), + totalCompensation: valuation.optional(), +}); + +const education = z.object({ + endDate: z.date().optional(), + field: z.string().optional(), + school: z.string().optional(), + startDate: z.date().optional(), + type: z.string().optional(), +}); + +export const offersProfileRouter = createRouter() + .query('listOne', { + input: z.object({ + profileId: z.string(), + }), + async resolve({ ctx, input }) { + return await ctx.prisma.offersProfile.findFirst({ + include: { + background: { + include: { + educations: true, + experiences: { + include: { + company: true, + monthlySalary: true, + totalCompensation: true, + }, + }, + specificYoes: true, + }, + }, + discussion: { + include: { + replies: true, + replyingTo: true, + }, + }, + offers: { + include: { + OffersFullTime: { + include: { + baseSalary: true, + bonus: true, + stocks: true, + totalCompensation: true, + }, + }, + OffersIntern: { + include: { + monthlySalary: true, + }, + }, + company: true, + }, + }, + }, + where: { + id: input.profileId, + }, + }); + }, + }) + .mutation('create', { + input: z.object({ + background: z.object({ + educations: z.array(education), + experiences: z.array(experience), + specificYoes: z.array( + z.object({ + domain: z.string(), + yoe: z.number(), + }), + ), + totalYoe: z.number().optional(), + }), + offers: z.array(offer), + }), + async resolve({ ctx, input }) { + // TODO: add more + const token = crypto + .createHash('sha256') + .update(Date.now().toString()) + .digest('hex'); + + const profile = await ctx.prisma.offersProfile.create({ + data: { + background: { + create: { + educations: { + create: input.background.educations.map((x) => ({ + endDate: x.endDate, + field: x.field, + school: x.school, + startDate: x.startDate, + type: x.type, + })), + }, + experiences: { + create: input.background.experiences.map((x) => { + if ( + x.jobType === 'FULLTIME' && + x.totalCompensation?.currency !== undefined && + x.totalCompensation.value !== undefined + ) { + return { + company: { + connect: { + id: x.companyId, + }, + }, + durationInMonths: x.durationInMonths, + jobType: x.jobType, + level: x.level, + specialization: x.specialization, + title: x.title, + totalCompensation: { + create: { + currency: x.totalCompensation?.currency, + value: x.totalCompensation?.value, + }, + }, + }; + } + if ( + x.jobType === 'INTERN' && + x.monthlySalary?.currency !== undefined && + x.monthlySalary.value !== undefined + ) { + return { + company: { + connect: { + id: x.companyId, + }, + }, + durationInMonths: x.durationInMonths, + jobType: x.jobType, + monthlySalary: { + create: { + currency: x.monthlySalary?.currency, + value: x.monthlySalary?.value, + }, + }, + specialization: x.specialization, + title: x.title, + }; + } + + throw Prisma.PrismaClientKnownRequestError; + }), + }, + specificYoes: { + create: input.background.specificYoes.map((x) => { + return { + domain: x.domain, + yoe: x.yoe, + }; + }), + }, + totalYoe: input.background.totalYoe, + }, + }, + editToken: token, + offers: { + create: input.offers.map((x) => { + if ( + x.jobType === 'INTERN' && + x.job.internshipCycle !== undefined && + x.job.monthlySalary?.currency !== undefined && + x.job.monthlySalary.value !== undefined && + x.job.startYear !== undefined + ) { + return { + OffersIntern: { + create: { + internshipCycle: x.job.internshipCycle, + monthlySalary: { + create: { + currency: x.job.monthlySalary?.currency, + value: x.job.monthlySalary?.value, + }, + }, + specialization: x.job.specialization, + startYear: x.job.startYear, + title: x.job.title, + }, + }, + comments: x.comments, + company: { + connect: { + id: x.companyId, + }, + }, + jobType: x.jobType, + location: x.location, + monthYearReceived: x.monthYearReceived, + negotiationStrategy: x.negotiationStrategy, + }; + } + if ( + x.jobType === 'FULLTIME' && + x.job.base?.currency !== undefined && + x.job.base?.value !== undefined && + x.job.bonus?.currency !== undefined && + x.job.bonus?.value !== undefined && + x.job.stocks?.currency !== undefined && + x.job.stocks?.value !== undefined && + x.job.totalCompensation?.currency !== undefined && + x.job.totalCompensation?.value !== undefined && + x.job.level !== undefined + ) { + return { + OffersFullTime: { + create: { + baseSalary: { + create: { + currency: x.job.base?.currency, + value: x.job.base?.value, + }, + }, + bonus: { + create: { + currency: x.job.bonus?.currency, + value: x.job.bonus?.value, + }, + }, + level: x.job.level, + specialization: x.job.specialization, + stocks: { + create: { + currency: x.job.stocks?.currency, + value: x.job.stocks?.value, + }, + }, + title: x.job.title, + totalCompensation: { + create: { + currency: x.job.totalCompensation?.currency, + value: x.job.totalCompensation?.value, + }, + }, + }, + }, + comments: x.comments, + company: { + connect: { + id: x.companyId, + }, + }, + jobType: x.jobType, + location: x.location, + monthYearReceived: x.monthYearReceived, + negotiationStrategy: x.negotiationStrategy, + }; + } + + // Throw error + throw Prisma.PrismaClientKnownRequestError; + }), + }, + profileName: randomUUID().substring(0, 10), + }, + include: { + background: { + include: { + educations: true, + experiences: { + include: { + company: true, + monthlySalary: true, + totalCompensation: true, + }, + }, + specificYoes: true, + }, + }, + offers: { + include: { + OffersFullTime: { + include: { + baseSalary: true, + bonus: true, + stocks: true, + totalCompensation: true, + }, + }, + OffersIntern: { + include: { + monthlySalary: true, + }, + }, + }, + }, + }, + }); + + // TODO: add analysis to profile object then return + return profile; + }, + }) + .mutation('delete', { + input: z.object({ + id: z.string(), + }), + async resolve({ ctx, input }) { + return await ctx.prisma.offersProfile.delete({ + where: { + id: input.id, + }, + }); + }, + }); diff --git a/apps/portal/src/server/router/offers/offers.ts b/apps/portal/src/server/router/offers/offers.ts new file mode 100644 index 00000000..78a3a445 --- /dev/null +++ b/apps/portal/src/server/router/offers/offers.ts @@ -0,0 +1,342 @@ +import assert from 'assert'; +import { z } from 'zod'; + +import { createRouter } from '../context'; + +const yoeCategoryMap: Record = { + 0: 'Internship', + 1: 'Fresh Grad', + 2: 'Mid', + 3: 'Senior', +}; + +const getYoeRange = (yoeCategory: number) => { + return yoeCategoryMap[yoeCategory] === 'Fresh Grad' + ? { maxYoe: 3, minYoe: 0 } + : yoeCategoryMap[yoeCategory] === 'Mid' + ? { maxYoe: 7, minYoe: 4 } + : yoeCategoryMap[yoeCategory] === 'Senior' + ? { maxYoe: null, minYoe: 8 } + : null; +}; + +const ascOrder = '+'; +const descOrder = '-'; +const sortingKeys = ['monthYearReceived', 'totalCompensation', 'totalYoe']; + +const createSortByValidationRegex = () => { + const startsWithPlusOrMinusOnly = '^[+-]{1}'; + const sortingKeysRegex = sortingKeys.join('|'); + return new RegExp(startsWithPlusOrMinusOnly + '(' + sortingKeysRegex + ')'); +}; + +export const offersRouter = createRouter().query('list', { + input: z.object({ + company: z.string().nullish(), + dateEnd: z.date().nullish(), + dateStart: z.date().nullish(), + limit: z.number().nonnegative(), + location: z.string(), + offset: z.number().nonnegative(), + salaryMax: z.number().nullish(), + salaryMin: z.number().nonnegative().nullish(), + sortBy: z.string().regex(createSortByValidationRegex()).nullish(), + title: z.string().nullish(), + yoeCategory: z.number().min(0).max(3), + }), + async resolve({ ctx, input }) { + const yoeRange = getYoeRange(input.yoeCategory); + + let data = !yoeRange + ? await ctx.prisma.offersOffer.findMany({ + // Internship + include: { + OffersFullTime: { + include: { + baseSalary: true, + bonus: true, + stocks: true, + totalCompensation: true, + }, + }, + OffersIntern: { + include: { + monthlySalary: true, + }, + }, + company: true, + profile: { + include: { + background: true, + }, + }, + }, + where: { + AND: [ + { + location: input.location, + }, + { + OffersIntern: { + isNot: null, + }, + }, + { + OffersFullTime: { + is: null, + }, + }, + ], + }, + }) + : yoeRange.maxYoe + ? await ctx.prisma.offersOffer.findMany({ + include: { + OffersFullTime: { + include: { + baseSalary: true, + bonus: true, + stocks: true, + totalCompensation: true, + }, + }, + OffersIntern: { + include: { + monthlySalary: true, + }, + }, + company: true, + profile: { + include: { + background: true, + }, + }, + }, + // Junior, Mid + where: { + AND: [ + { + location: input.location, + }, + { + OffersIntern: { + is: null, + }, + }, + { + OffersFullTime: { + isNot: null, + }, + }, + { + profile: { + background: { + totalYoe: { + gte: yoeRange.minYoe, + }, + }, + }, + }, + { + profile: { + background: { + totalYoe: { + gte: yoeRange.maxYoe, + }, + }, + }, + }, + ], + }, + }) + : await ctx.prisma.offersOffer.findMany({ + // Senior + include: { + OffersFullTime: { + include: { + baseSalary: true, + bonus: true, + stocks: true, + totalCompensation: true, + }, + }, + OffersIntern: { + include: { + monthlySalary: true, + }, + }, + company: true, + profile: { + include: { + background: true, + }, + }, + }, + where: { + AND: [ + { + location: input.location, + }, + { + OffersIntern: { + is: null, + }, + }, + { + OffersFullTime: { + isNot: null, + }, + }, + { + profile: { + background: { + totalYoe: { + gte: yoeRange.minYoe, + }, + }, + }, + }, + ], + }, + }); + + // FILTERING + data = data.filter((offer) => { + let validRecord = true; + + if (input.company) { + validRecord = validRecord && offer.company.name === input.company; + } + + if (input.title) { + validRecord = + validRecord && + (offer.OffersFullTime?.title === input.title || + offer.OffersIntern?.title === input.title); + } + + if (input.dateStart && input.dateEnd) { + validRecord = + validRecord && + offer.monthYearReceived.getTime() >= input.dateStart.getTime() && + offer.monthYearReceived.getTime() <= input.dateEnd.getTime(); + } + + if (input.salaryMin && input.salaryMax) { + const salary = offer.OffersFullTime?.totalCompensation.value + ? offer.OffersFullTime?.totalCompensation.value + : offer.OffersIntern?.monthlySalary.value; + + assert(salary); + + validRecord = + validRecord && salary >= input.salaryMin && salary <= input.salaryMax; + } + + return validRecord; + }); + + // SORTING + data = data.sort((offer1, offer2) => { + const defaultReturn = + offer2.monthYearReceived.getTime() - offer1.monthYearReceived.getTime(); + + if (!input.sortBy) { + return defaultReturn; + } + + const order = input.sortBy.charAt(0); + const sortingKey = input.sortBy.substring(1); + + if (order === ascOrder) { + return (() => { + if (sortingKey === 'monthYearReceived') { + return ( + offer1.monthYearReceived.getTime() - + offer2.monthYearReceived.getTime() + ); + } + + if (sortingKey === 'totalCompensation') { + const salary1 = offer1.OffersFullTime?.totalCompensation.value + ? offer1.OffersFullTime?.totalCompensation.value + : offer1.OffersIntern?.monthlySalary.value; + + const salary2 = offer2.OffersFullTime?.totalCompensation.value + ? offer2.OffersFullTime?.totalCompensation.value + : offer2.OffersIntern?.monthlySalary.value; + + if (salary1 && salary2) { + return salary1 - salary2; + } + } + + if (sortingKey === 'totalYoe') { + const yoe1 = offer1.profile.background?.totalYoe; + const yoe2 = offer2.profile.background?.totalYoe; + + if (yoe1 && yoe2) { + return yoe1 - yoe2; + } + } + + return defaultReturn; + })(); + } + + if (order === descOrder) { + return (() => { + if (sortingKey === 'monthYearReceived') { + return ( + offer2.monthYearReceived.getTime() - + offer1.monthYearReceived.getTime() + ); + } + + if (sortingKey === 'totalCompensation') { + const salary1 = offer1.OffersFullTime?.totalCompensation.value + ? offer1.OffersFullTime?.totalCompensation.value + : offer1.OffersIntern?.monthlySalary.value; + + const salary2 = offer2.OffersFullTime?.totalCompensation.value + ? offer2.OffersFullTime?.totalCompensation.value + : offer2.OffersIntern?.monthlySalary.value; + + if (salary1 && salary2) { + return salary2 - salary1; + } + } + + if (sortingKey === 'totalYoe') { + const yoe1 = offer1.profile.background?.totalYoe; + const yoe2 = offer2.profile.background?.totalYoe; + + if (yoe1 && yoe2) { + return yoe2 - yoe1; + } + } + + return defaultReturn; + })(); + } + return defaultReturn; + }); + + const startRecordIndex: number = input.limit * input.offset; + const endRecordIndex: number = + startRecordIndex + input.limit <= data.length + ? startRecordIndex + input.limit + : data.length; + const paginatedData = data.slice(startRecordIndex, endRecordIndex); + + return { + data: paginatedData, + paging: { + currPage: input.offset, + numOfItemsInPage: paginatedData.length, + numOfPages: Math.ceil(data.length / input.limit), + totalNumberOfOffers: data.length, + }, + }; + }, +});