From 335413fdcdbd8bac014406b13f9755e117e4bcb2 Mon Sep 17 00:00:00 2001 From: BryannYeap Date: Wed, 12 Oct 2022 20:28:12 +0800 Subject: [PATCH] [offers][chore] Validate edit token before allowing deletion of offer profile --- .../pages/offers/profile/[offerProfileId].tsx | 2 +- .../src/pages/offers/test/createProfile.tsx | 7 +- .../src/pages/offers/test/listOffers.tsx | 2 +- apps/portal/src/server/router/index.ts | 4 +- .../server/router/offers-profile-router.ts | 412 ------------------ apps/portal/src/server/router/offers.ts | 342 --------------- .../router/offers/offers-profile-router.ts | 100 ++++- 7 files changed, 94 insertions(+), 775 deletions(-) delete mode 100644 apps/portal/src/server/router/offers-profile-router.ts delete mode 100644 apps/portal/src/server/router/offers.ts diff --git a/apps/portal/src/pages/offers/profile/[offerProfileId].tsx b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx index 1ecfd8ea..85a644af 100644 --- a/apps/portal/src/pages/offers/profile/[offerProfileId].tsx +++ b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx @@ -137,7 +137,7 @@ export default function OfferProfile() { if (isEditable) { deleteMutation.mutate({ id: offerProfileId as string, - // TODO: token: token as string, + token: 'CHANGE THIS PART TO URL PARAM @ ZIQING', // TODO: token: token as string, }); trpcContext.invalidateQueries(['offers.profile.listOne']); router.push('/offers'); diff --git a/apps/portal/src/pages/offers/test/createProfile.tsx b/apps/portal/src/pages/offers/test/createProfile.tsx index 59b3cfb9..3bf485a2 100644 --- a/apps/portal/src/pages/offers/test/createProfile.tsx +++ b/apps/portal/src/pages/offers/test/createProfile.tsx @@ -126,7 +126,7 @@ function Test() { }); }; - const profileId = 'cl93tvejz00bei9qinzmjgy75'; // Remember to change this filed after testing deleting + const profileId = 'cl93vo4w9009ow35mx7gemrzb'; // Remember to change this filed after testing deleting const data = trpc.useQuery([ `offers.profile.listOne`, { @@ -138,7 +138,10 @@ function Test() { const deleteMutation = trpc.useMutation(['offers.profile.delete']); const handleDelete = (id: string) => { - deleteMutation.mutate({ id }); + deleteMutation.mutate({ + id, + token: '6c8d53530163bb765c42bd9f441aa7e345f607c4e1892edbc64e5bbbbe7ee916', + }); }; return ( diff --git a/apps/portal/src/pages/offers/test/listOffers.tsx b/apps/portal/src/pages/offers/test/listOffers.tsx index e226fb97..982dba68 100644 --- a/apps/portal/src/pages/offers/test/listOffers.tsx +++ b/apps/portal/src/pages/offers/test/listOffers.tsx @@ -17,7 +17,7 @@ function Test() { const deleteMutation = trpc.useMutation(['offers.profile.delete']); const handleDelete = (id: string) => { - deleteMutation.mutate({ id }); + deleteMutation.mutate({ id, token: ' dadaadad' }); }; return ( diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 8dd5e08c..f7d156a9 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -2,8 +2,8 @@ import superjson from 'superjson'; import { companiesRouter } from './companies-router'; import { createRouter } from './context'; -import { offersRouter } from './offers'; -import { offersProfileRouter } from './offers-profile-router'; +import { offersRouter } from './offers/offers'; +import { offersProfileRouter } from './offers/offers-profile-router'; import { protectedExampleRouter } from './protected-example-router'; import { questionsAnswerCommentRouter } from './questions-answer-comment-router'; import { questionsAnswerRouter } from './questions-answer-router'; diff --git a/apps/portal/src/server/router/offers-profile-router.ts b/apps/portal/src/server/router/offers-profile-router.ts deleted file mode 100644 index 5d673235..00000000 --- a/apps/portal/src/server/router/offers-profile-router.ts +++ /dev/null @@ -1,412 +0,0 @@ -import crypto, { randomUUID } from 'crypto'; -import { z } from 'zod'; -import { Prisma } from '@prisma/client'; - -import { createRouter } from './context'; - -import type { offersProfile } from '~/types/offers-profile'; - -const valuation = z.object({ - currency: z.string(), - value: z.number(), -}); - -// TODO: handle both full time and intern -const offer = z.object({ - comments: z.string().optional(), - 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().optional(), -}); - -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(), -}); - -type WithIsEditable = T & { - isEditable: boolean -} - -function computeIsEditable( - profileInput: offersProfile, - editToken?: string -): WithIsEditable { - return { - ...profileInput, - isEditable: profileInput.editToken === editToken, - } -} - -function exclude>( - profile: WithIsEditable, - ...keys: Array -): Omit, Key> { - for (const key of keys) { - delete profile[key] - } - return profile -} - -export const offersProfileRouter = createRouter() - .query('listOne', { - input: z.object({ - profileId: z.string(), - token: z.string().optional() - }), - async resolve({ ctx, input }) { - const result = 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, - } - }); - - return result ? exclude(computeIsEditable(result, input.token), 'editToken') : result; - }, - }) - .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 - ) { - if (x.companyId) { - 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, - }, - }, - }; - } - return { - 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 - ) { - if (x.companyId) { - 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, - }; - } - return { - 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.ts b/apps/portal/src/server/router/offers.ts deleted file mode 100644 index 2d48f28c..00000000 --- a/apps/portal/src/server/router/offers.ts +++ /dev/null @@ -1,342 +0,0 @@ -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, - }, - }; - }, -}); diff --git a/apps/portal/src/server/router/offers/offers-profile-router.ts b/apps/portal/src/server/router/offers/offers-profile-router.ts index 9fb016ed..d036e52e 100644 --- a/apps/portal/src/server/router/offers/offers-profile-router.ts +++ b/apps/portal/src/server/router/offers/offers-profile-router.ts @@ -4,6 +4,8 @@ import { Prisma } from '@prisma/client'; import { createRouter } from '../context'; +import type { offersProfile } from '~/types/offers-profile'; + const valuation = z.object({ currency: z.string(), value: z.number(), @@ -11,7 +13,7 @@ const valuation = z.object({ // TODO: handle both full time and intern const offer = z.object({ - comments: z.string(), + comments: z.string().optional(), companyId: z.string(), job: z.object({ base: valuation.optional(), // Full time @@ -28,7 +30,7 @@ const offer = z.object({ jobType: z.string(), location: z.string(), monthYearReceived: z.date(), - negotiationStrategy: z.string(), + negotiationStrategy: z.string().optional(), }); const experience = z.object({ @@ -50,13 +52,38 @@ const education = z.object({ type: z.string().optional(), }); +type WithIsEditable = T & { + isEditable: boolean; +}; + +function computeIsEditable( + profileInput: offersProfile, + editToken?: string, +): WithIsEditable { + return { + ...profileInput, + isEditable: profileInput.editToken === editToken, + }; +} + +function exclude>( + profile: WithIsEditable, + ...keys: Array +): Omit, Key> { + for (const key of keys) { + delete profile[key]; + } + return profile; +} + export const offersProfileRouter = createRouter() .query('listOne', { input: z.object({ profileId: z.string(), + token: z.string().optional(), }), async resolve({ ctx, input }) { - return await ctx.prisma.offersProfile.findFirst({ + const result = await ctx.prisma.offersProfile.findFirst({ include: { background: { include: { @@ -100,6 +127,10 @@ export const offersProfileRouter = createRouter() id: input.profileId, }, }); + + return result + ? exclude(computeIsEditable(result, input.token), 'editToken') + : result; }, }) .mutation('create', { @@ -144,12 +175,27 @@ export const offersProfileRouter = createRouter() x.totalCompensation?.currency !== undefined && x.totalCompensation.value !== undefined ) { - return { - company: { - connect: { - id: x.companyId, + if (x.companyId) { + 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, + }, + }, + }; + } + return { durationInMonths: x.durationInMonths, jobType: x.jobType, level: x.level, @@ -168,12 +214,26 @@ export const offersProfileRouter = createRouter() x.monthlySalary?.currency !== undefined && x.monthlySalary.value !== undefined ) { - return { - company: { - connect: { - id: x.companyId, + if (x.companyId) { + 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, + }; + } + return { durationInMonths: x.durationInMonths, jobType: x.jobType, monthlySalary: { @@ -334,7 +394,6 @@ export const offersProfileRouter = createRouter() }, }, }); - // TODO: add analysis to profile object then return return profile; }, @@ -342,12 +401,23 @@ export const offersProfileRouter = createRouter() .mutation('delete', { input: z.object({ id: z.string(), + token: z.string(), }), async resolve({ ctx, input }) { - return await ctx.prisma.offersProfile.delete({ + const profileToDelete = await ctx.prisma.offersProfile.findFirst({ where: { id: input.id, }, }); + const profileEditToken = profileToDelete?.editToken; + + if (profileEditToken === input.token) { + return await ctx.prisma.offersProfile.delete({ + where: { + id: input.id, + }, + }); + } + // TODO: Throw 401 }, });