diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index d9c273b7..1a4db526 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -362,9 +362,8 @@ model OffersOffer { offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade) offersFullTimeId String? @unique - OffersAnalysis OffersAnalysis? @relation("HighestOverallOffer") - OffersAnalysisTopOverallOffers OffersAnalysis[] @relation("TopOverallOffers") - OffersAnalysisTopCompanyOffers OffersAnalysis[] @relation("TopCompanyOffers") + offersAnalysis OffersAnalysis? @relation("HighestOverallOffer") + offersAnalysisUnit OffersAnalysisUnit[] } model OffersIntern { @@ -405,14 +404,21 @@ model OffersAnalysis { offerId String @unique // OVERALL - overallPercentile Float + overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id]) + overallAnalysisUnitId String + + companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis") +} + +model OffersAnalysisUnit { + id String @id @default(cuid()) + + percentile Float noOfSimilarOffers Int - topOverallOffers OffersOffer[] @relation("TopOverallOffers") + topSimilarOffers OffersOffer[] - // Company - companyPercentile Float - noOfSimilarCompanyOffers Int - topCompanyOffers OffersOffer[] @relation("TopCompanyOffers") + offersAnalysisOverall OffersAnalysis[] @relation("OverallAnalysis") + offersAnalysisCompany OffersAnalysis[] @relation("CompanyAnalysis") } // End of Offers project models. diff --git a/apps/portal/src/mappers/offers-mappers.ts b/apps/portal/src/mappers/offers-mappers.ts index bc8120a1..37751ce4 100644 --- a/apps/portal/src/mappers/offers-mappers.ts +++ b/apps/portal/src/mappers/offers-mappers.ts @@ -1,6 +1,7 @@ import type { Company, OffersAnalysis, + OffersAnalysisUnit, OffersBackground, OffersCurrency, OffersEducation, @@ -18,9 +19,9 @@ import { TRPCError } from '@trpc/server'; import type { AddToProfileResponse, - Analysis, AnalysisHighestOffer, AnalysisOffer, + AnalysisUnit, Background, CreateOfferProfileResponse, DashboardOffer, @@ -111,32 +112,32 @@ const analysisOfferDtoMapper = ( return analysisOfferDto; }; -const analysisDtoMapper = ( - noOfOffers: number, - percentile: number, - topPercentileOffers: Array< - OffersOffer & { - company: Company; - offersFullTime: - | (OffersFullTime & { totalCompensation: OffersCurrency }) - | null; - offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; - profile: OffersProfile & { - background: - | (OffersBackground & { - experiences: Array< - OffersExperience & { company: Company | null } - >; - }) +const analysisUnitDtoMapper = ( + analysisUnit: OffersAnalysisUnit & { + topSimilarOffers: Array< + OffersOffer & { + company: Company; + offersFullTime: + | (OffersFullTime & { totalCompensation: OffersCurrency }) | null; - }; - } - >, + offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; + profile: OffersProfile & { + background: + | (OffersBackground & { + experiences: Array< + OffersExperience & { company: Company | null } + >; + }) + | null; + }; + } + >; + }, ) => { - const analysisDto: Analysis = { - noOfOffers, - percentile, - topPercentileOffers: topPercentileOffers.map((offer) => + const analysisDto: AnalysisUnit = { + noOfOffers: analysisUnit.noOfSimilarOffers, + percentile: analysisUnit.percentile, + topPercentileOffers: analysisUnit.topSimilarOffers.map((offer) => analysisOfferDtoMapper(offer), ), }; @@ -166,6 +167,52 @@ const analysisHighestOfferDtoMapper = ( export const profileAnalysisDtoMapper = ( analysis: | (OffersAnalysis & { + companyAnalysis: Array< + OffersAnalysisUnit & { + topSimilarOffers: Array< + OffersOffer & { + company: Company; + offersFullTime: + | (OffersFullTime & { totalCompensation: OffersCurrency }) + | null; + offersIntern: + | (OffersIntern & { monthlySalary: OffersCurrency }) + | null; + profile: OffersProfile & { + background: + | (OffersBackground & { + experiences: Array< + OffersExperience & { company: Company | null } + >; + }) + | null; + }; + } + >; + } + >; + overallAnalysis: OffersAnalysisUnit & { + topSimilarOffers: Array< + OffersOffer & { + company: Company; + offersFullTime: + | (OffersFullTime & { totalCompensation: OffersCurrency }) + | null; + offersIntern: + | (OffersIntern & { monthlySalary: OffersCurrency }) + | null; + profile: OffersProfile & { + background: + | (OffersBackground & { + experiences: Array< + OffersExperience & { company: Company | null } + >; + }) + | null; + }; + } + >; + }; overallHighestOffer: OffersOffer & { company: Company; offersFullTime: @@ -176,46 +223,6 @@ export const profileAnalysisDtoMapper = ( | null; profile: OffersProfile & { background: OffersBackground | null }; }; - topCompanyOffers: Array< - OffersOffer & { - company: Company; - offersFullTime: - | (OffersFullTime & { totalCompensation: OffersCurrency }) - | null; - offersIntern: - | (OffersIntern & { monthlySalary: OffersCurrency }) - | null; - profile: OffersProfile & { - background: - | (OffersBackground & { - experiences: Array< - OffersExperience & { company: Company | null } - >; - }) - | null; - }; - } - >; - topOverallOffers: Array< - OffersOffer & { - company: Company; - offersFullTime: - | (OffersFullTime & { totalCompensation: OffersCurrency }) - | null; - offersIntern: - | (OffersIntern & { monthlySalary: OffersCurrency }) - | null; - profile: OffersProfile & { - background: - | (OffersBackground & { - experiences: Array< - OffersExperience & { company: Company | null } - >; - }) - | null; - }; - } - >; }) | null, ) => { @@ -224,19 +231,11 @@ export const profileAnalysisDtoMapper = ( } const profileAnalysisDto: ProfileAnalysis = { - companyAnalysis: [ - analysisDtoMapper( - analysis.noOfSimilarCompanyOffers, - analysis.companyPercentile, - analysis.topCompanyOffers, - ), - ], - id: analysis.id, - overallAnalysis: analysisDtoMapper( - analysis.noOfSimilarOffers, - analysis.overallPercentile, - analysis.topOverallOffers, + companyAnalysis: analysis.companyAnalysis.map((analysisUnit) => + analysisUnitDtoMapper(analysisUnit), ), + id: analysis.id, + overallAnalysis: analysisUnitDtoMapper(analysis.overallAnalysis), overallHighestOffer: analysisHighestOfferDtoMapper( analysis.overallHighestOffer, ), @@ -442,6 +441,52 @@ export const profileDtoMapper = ( profile: OffersProfile & { analysis: | (OffersAnalysis & { + companyAnalysis: Array< + OffersAnalysisUnit & { + topSimilarOffers: Array< + OffersOffer & { + company: Company; + offersFullTime: + | (OffersFullTime & { totalCompensation: OffersCurrency }) + | null; + offersIntern: + | (OffersIntern & { monthlySalary: OffersCurrency }) + | null; + profile: OffersProfile & { + background: + | (OffersBackground & { + experiences: Array< + OffersExperience & { company: Company | null } + >; + }) + | null; + }; + } + >; + } + >; + overallAnalysis: OffersAnalysisUnit & { + topSimilarOffers: Array< + OffersOffer & { + company: Company; + offersFullTime: + | (OffersFullTime & { totalCompensation: OffersCurrency }) + | null; + offersIntern: + | (OffersIntern & { monthlySalary: OffersCurrency }) + | null; + profile: OffersProfile & { + background: + | (OffersBackground & { + experiences: Array< + OffersExperience & { company: Company | null } + >; + }) + | null; + }; + } + >; + }; overallHighestOffer: OffersOffer & { company: Company; offersFullTime: @@ -452,46 +497,6 @@ export const profileDtoMapper = ( | null; profile: OffersProfile & { background: OffersBackground | null }; }; - topCompanyOffers: Array< - OffersOffer & { - company: Company; - offersFullTime: - | (OffersFullTime & { totalCompensation: OffersCurrency }) - | null; - offersIntern: - | (OffersIntern & { monthlySalary: OffersCurrency }) - | null; - profile: OffersProfile & { - background: - | (OffersBackground & { - experiences: Array< - OffersExperience & { company: Company | null } - >; - }) - | null; - }; - } - >; - topOverallOffers: Array< - OffersOffer & { - company: Company; - offersFullTime: - | (OffersFullTime & { totalCompensation: OffersCurrency }) - | null; - offersIntern: - | (OffersIntern & { monthlySalary: OffersCurrency }) - | null; - profile: OffersProfile & { - background: - | (OffersBackground & { - experiences: Array< - OffersExperience & { company: Company | null } - >; - }) - | null; - }; - } - >; }) | null; background: diff --git a/apps/portal/src/server/router/offers/offers-analysis-router.ts b/apps/portal/src/server/router/offers/offers-analysis-router.ts index 8176b3c3..ede0c57f 100644 --- a/apps/portal/src/server/router/offers/offers-analysis-router.ts +++ b/apps/portal/src/server/router/offers/offers-analysis-router.ts @@ -14,6 +14,15 @@ import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers'; import { createRouter } from '../context'; +type Offer = OffersOffer & { + company: Company; + offersFullTime: + | (OffersFullTime & { totalCompensation: OffersCurrency }) + | null; + offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; + profile: OffersProfile & { background: OffersBackground | null }; +}; + const searchOfferPercentile = ( offer: OffersOffer & { company: Company; @@ -58,46 +67,62 @@ export const offersAnalysisRouter = createRouter() async resolve({ ctx, input }) { const analysis = await ctx.prisma.offersAnalysis.findFirst({ include: { - overallHighestOffer: { + companyAnalysis: { include: { - company: true, - offersFullTime: { - include: { - totalCompensation: true, - }, - }, - offersIntern: { + topSimilarOffers: { include: { - monthlySalary: true, - }, - }, - profile: { - include: { - background: true, + company: true, + offersFullTime: { + include: { + totalCompensation: true, + }, + }, + offersIntern: { + include: { + monthlySalary: true, + }, + }, + profile: { + include: { + background: { + include: { + experiences: { + include: { + company: true, + }, + }, + }, + }, + }, + }, }, }, }, }, - topCompanyOffers: { + overallAnalysis: { include: { - company: true, - offersFullTime: { - include: { - totalCompensation: true, - }, - }, - offersIntern: { + topSimilarOffers: { include: { - monthlySalary: true, - }, - }, - profile: { - include: { - background: { + company: true, + offersFullTime: { + include: { + totalCompensation: true, + }, + }, + offersIntern: { include: { - experiences: { + monthlySalary: true, + }, + }, + profile: { + include: { + background: { include: { - company: true, + experiences: { + include: { + company: true, + }, + }, }, }, }, @@ -106,7 +131,7 @@ export const offersAnalysisRouter = createRouter() }, }, }, - topOverallOffers: { + overallHighestOffer: { include: { company: true, offersFullTime: { @@ -121,15 +146,7 @@ export const offersAnalysisRouter = createRouter() }, profile: { include: { - background: { - include: { - experiences: { - include: { - company: true, - }, - }, - }, - }, + background: true, }, }, }, @@ -310,11 +327,56 @@ export const offersAnalysisRouter = createRouter() }, }); - let similarCompanyOffers = similarOffers.filter( - (offer) => offer.companyId === overallHighestOffer.companyId, + // COMPANY ANALYSIS + const companyMap = new Map(); + offers.forEach((offer) => { + if (companyMap.get(offer.companyId) == null) { + companyMap.set(offer.companyId, offer); + } + }); + + const companyAnalysis = Array.from(companyMap.values()).map( + (companyOffer) => { + // TODO: Refactor calculating analysis into a function + let similarCompanyOffers = similarOffers.filter( + (offer) => offer.companyId === overallHighestOffer.companyId, + ); + + const companyIndex = searchOfferPercentile( + overallHighestOffer, + similarCompanyOffers, + ); + const companyPercentile = + similarCompanyOffers.length <= 1 + ? 100 + : 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1); + + // Get top offers (excluding user's offer) + similarCompanyOffers = similarCompanyOffers.filter( + (offer) => offer.id !== companyOffer.id, + ); + + const noOfSimilarCompanyOffers = similarCompanyOffers.length; + const similarCompanyOffers90PercentileIndex = Math.ceil( + noOfSimilarCompanyOffers * 0.1, + ); + const topPercentileCompanyOffers = + noOfSimilarCompanyOffers > 2 + ? similarCompanyOffers.slice( + similarCompanyOffers90PercentileIndex, + similarCompanyOffers90PercentileIndex + 2, + ) + : similarCompanyOffers; + + return { + noOfSimilarOffers: noOfSimilarCompanyOffers, + percentile: companyPercentile, + topSimilarOffers: topPercentileCompanyOffers, + }; + }, ); - // CALCULATE PERCENTILES + // OVERALL ANALYSIS const overallIndex = searchOfferPercentile( overallHighestOffer, similarOffers, @@ -324,23 +386,9 @@ export const offersAnalysisRouter = createRouter() ? 100 : 100 - (100 * overallIndex) / (similarOffers.length - 1); - const companyIndex = searchOfferPercentile( - overallHighestOffer, - similarCompanyOffers, - ); - const companyPercentile = - similarCompanyOffers.length <= 1 - ? 100 - : 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1); - - // FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE - // e.g. If there only 4 offers, it gives the 2nd and 3rd offer similarOffers = similarOffers.filter( (offer) => offer.id !== overallHighestOffer.id, ); - similarCompanyOffers = similarCompanyOffers.filter( - (offer) => offer.id !== overallHighestOffer.id, - ); const noOfSimilarOffers = similarOffers.length; const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1); @@ -352,86 +400,100 @@ export const offersAnalysisRouter = createRouter() ) : similarOffers; - const noOfSimilarCompanyOffers = similarCompanyOffers.length; - const similarCompanyOffers90PercentileIndex = Math.ceil( - noOfSimilarCompanyOffers * 0.1, - ); - const topPercentileCompanyOffers = - noOfSimilarCompanyOffers > 2 - ? similarCompanyOffers.slice( - similarCompanyOffers90PercentileIndex, - similarCompanyOffers90PercentileIndex + 2, - ) - : similarCompanyOffers; - const analysis = await ctx.prisma.offersAnalysis.create({ data: { - companyPercentile, - noOfSimilarCompanyOffers, - noOfSimilarOffers, + companyAnalysis: { + create: companyAnalysis.map((analysisUnit) => { + return { + noOfSimilarOffers: analysisUnit.noOfSimilarOffers, + percentile: analysisUnit.percentile, + topSimilarOffers: { + connect: analysisUnit.topSimilarOffers.map((offer) => { + return { id: offer.id }; + }), + }, + }; + }), + }, + overallAnalysis: { + create: { + noOfSimilarOffers, + percentile: overallPercentile, + topSimilarOffers: { + connect: topPercentileOffers.map((offer) => { + return { id: offer.id }; + }), + }, + }, + }, overallHighestOffer: { connect: { id: overallHighestOffer.id, }, }, - overallPercentile, profile: { connect: { id: input.profileId, }, }, - topCompanyOffers: { - connect: topPercentileCompanyOffers.map((offer) => { - return { id: offer.id }; - }), - }, - topOverallOffers: { - connect: topPercentileOffers.map((offer) => { - return { id: offer.id }; - }), - }, }, include: { - overallHighestOffer: { + companyAnalysis: { include: { - company: true, - offersFullTime: { + topSimilarOffers: { include: { - totalCompensation: true, - }, - }, - offersIntern: { - include: { - monthlySalary: true, - }, - }, - profile: { - include: { - background: true, + company: true, + offersFullTime: { + include: { + totalCompensation: true, + }, + }, + offersIntern: { + include: { + monthlySalary: true, + }, + }, + profile: { + include: { + background: { + include: { + experiences: { + include: { + company: true, + }, + }, + }, + }, + }, + }, }, }, }, }, - topCompanyOffers: { + overallAnalysis: { include: { - company: true, - offersFullTime: { + topSimilarOffers: { include: { - totalCompensation: true, - }, - }, - offersIntern: { - include: { - monthlySalary: true, - }, - }, - profile: { - include: { - background: { + company: true, + offersFullTime: { + include: { + totalCompensation: true, + }, + }, + offersIntern: { + include: { + monthlySalary: true, + }, + }, + profile: { include: { - experiences: { + background: { include: { - company: true, + experiences: { + include: { + company: true, + }, + }, }, }, }, @@ -440,7 +502,7 @@ export const offersAnalysisRouter = createRouter() }, }, }, - topOverallOffers: { + overallHighestOffer: { include: { company: true, offersFullTime: { @@ -455,15 +517,7 @@ export const offersAnalysisRouter = createRouter() }, profile: { include: { - background: { - include: { - experiences: { - include: { - company: true, - }, - }, - }, - }, + background: true, }, }, }, 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 70b3c3cb..e6c8d656 100644 --- a/apps/portal/src/server/router/offers/offers-profile-router.ts +++ b/apps/portal/src/server/router/offers/offers-profile-router.ts @@ -128,46 +128,62 @@ export const offersProfileRouter = createRouter() include: { analysis: { include: { - overallHighestOffer: { + companyAnalysis: { include: { - company: true, - offersFullTime: { - include: { - totalCompensation: true, - }, - }, - offersIntern: { + topSimilarOffers: { include: { - monthlySalary: true, - }, - }, - profile: { - include: { - background: true, + company: true, + offersFullTime: { + include: { + totalCompensation: true, + }, + }, + offersIntern: { + include: { + monthlySalary: true, + }, + }, + profile: { + include: { + background: { + include: { + experiences: { + include: { + company: true, + }, + }, + }, + }, + }, + }, }, }, }, }, - topCompanyOffers: { + overallAnalysis: { include: { - company: true, - offersFullTime: { - include: { - totalCompensation: true, - }, - }, - offersIntern: { + topSimilarOffers: { include: { - monthlySalary: true, - }, - }, - profile: { - include: { - background: { + company: true, + offersFullTime: { include: { - experiences: { + totalCompensation: true, + }, + }, + offersIntern: { + include: { + monthlySalary: true, + }, + }, + profile: { + include: { + background: { include: { - company: true, + experiences: { + include: { + company: true, + }, + }, }, }, }, @@ -176,7 +192,7 @@ export const offersProfileRouter = createRouter() }, }, }, - topOverallOffers: { + overallHighestOffer: { include: { company: true, offersFullTime: { @@ -191,15 +207,7 @@ export const offersProfileRouter = createRouter() }, profile: { include: { - background: { - include: { - experiences: { - include: { - company: true, - }, - }, - }, - }, + background: true, }, }, }, @@ -409,7 +417,7 @@ export const offersProfileRouter = createRouter() message: 'Missing fields in background experiences.', }); }), - ) + ), }, specificYoes: { create: input.background.specificYoes.map((x) => { diff --git a/apps/portal/src/types/offers.d.ts b/apps/portal/src/types/offers.d.ts index 4d4c9080..b5a93b80 100644 --- a/apps/portal/src/types/offers.d.ts +++ b/apps/portal/src/types/offers.d.ts @@ -143,14 +143,14 @@ export type OffersDiscussion = { }; export type ProfileAnalysis = { - companyAnalysis: Array; + companyAnalysis: Array; id: string; - overallAnalysis: Analysis; + overallAnalysis: AnalysisUnit; overallHighestOffer: AnalysisHighestOffer; profileId: string; }; -export type Analysis = { +export type AnalysisUnit = { noOfOffers: number; percentile: number; topPercentileOffers: Array;