diff --git a/apps/portal/src/mappers/offers-mappers.ts b/apps/portal/src/mappers/offers-mappers.ts index f137f2a1..f5b7d29e 100644 --- a/apps/portal/src/mappers/offers-mappers.ts +++ b/apps/portal/src/mappers/offers-mappers.ts @@ -22,6 +22,7 @@ import { TRPCError } from '@trpc/server'; import type { AddToProfileResponse, + AdminDashboardOffer, AnalysisHighestOffer, AnalysisOffer, AnalysisUnit, @@ -30,6 +31,7 @@ import type { DashboardOffer, Education, Experience, + GetAdminOffersResponse, GetOffersResponse, Location, OffersCompany, @@ -942,3 +944,86 @@ const userProfileOfferDtoMapper = ( return mappedOffer; }; + +export const adminDashboardOfferDtoMapper = ( + offer: OffersOffer & { + company: Company; + location: City & { state: State & { country: Country } }; + offersFullTime: + | (OffersFullTime & { + baseSalary: OffersCurrency | null; + bonus: OffersCurrency | null; + stocks: OffersCurrency | null; + totalCompensation: OffersCurrency; + }) + | null; + offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; + profile: OffersProfile & { + background: OffersBackground | null; + offers: Array; + }; + }, +) => { + const adminDashboardOfferDto: AdminDashboardOffer = { + company: offersCompanyDtoMapper(offer.company), + id: offer.id, + income: valuationDtoMapper({ + baseCurrency: '', + baseValue: -1, + currency: '', + id: '', + value: -1, + }), + location: locationDtoMapper(offer.location), + monthYearReceived: offer.monthYearReceived, + numberOfOtherOffers: + offer.profile.offers.length < 2 ? 0 : offer.profile.offers.length - 1, + profileId: offer.profileId, + title: offer.offersFullTime?.title || offer.offersIntern?.title || '', + token: offer.profile.editToken, + totalYoe: offer.profile.background?.totalYoe ?? -1, + }; + + if (offer.offersFullTime && offer.jobType === JobType.FULLTIME) { + adminDashboardOfferDto.income = valuationDtoMapper( + offer.offersFullTime.totalCompensation, + ); + + if (offer.offersFullTime.baseSalary) { + adminDashboardOfferDto.baseSalary = valuationDtoMapper( + offer.offersFullTime.baseSalary, + ); + } + + if (offer.offersFullTime.bonus) { + adminDashboardOfferDto.bonus = valuationDtoMapper( + offer.offersFullTime.bonus, + ); + } + + if (offer.offersFullTime.stocks) { + adminDashboardOfferDto.stocks = valuationDtoMapper( + offer.offersFullTime.stocks, + ); + } + } else if (offer.offersIntern && offer.jobType === JobType.INTERN) { + adminDashboardOfferDto.income = valuationDtoMapper( + offer.offersIntern.monthlySalary, + ); + } + + return adminDashboardOfferDto; +}; + +export const getAdminOffersResponseMapper = ( + data: Array, + paging: Paging, + jobType: JobType, +) => { + const getAdminOffersResponse: GetAdminOffersResponse = { + data, + jobType, + paging, + }; + return getAdminOffersResponse; +}; diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index f5462ea6..5db25389 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -4,6 +4,7 @@ import { companiesRouter } from './companies-router'; import { createRouter } from './context'; import { locationsRouter } from './locations-router'; import { offersRouter } from './offers/offers'; +import { offerAdminRouter } from './offers/offers-admin-router'; import { offersAnalysisRouter } from './offers/offers-analysis-router'; import { offersCommentsRouter } from './offers/offers-comments-router'; import { offersProfileRouter } from './offers/offers-profile-router'; @@ -70,7 +71,8 @@ export const appRouter = createRouter() .merge('offers.profile.', offersProfileRouter) .merge('offers.analysis.', offersAnalysisRouter) .merge('offers.comments.', offersCommentsRouter) - .merge('offers.user.profile.', offersUserProfileRouter); + .merge('offers.user.profile.', offersUserProfileRouter) + .merge('offers.admin.', offerAdminRouter); // Export type definition of API export type AppRouter = typeof appRouter; diff --git a/apps/portal/src/server/router/offers/offers-admin-router.ts b/apps/portal/src/server/router/offers/offers-admin-router.ts new file mode 100644 index 00000000..4403b959 --- /dev/null +++ b/apps/portal/src/server/router/offers/offers-admin-router.ts @@ -0,0 +1,466 @@ +import { z } from 'zod'; +import { JobType } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; + +import { + adminDashboardOfferDtoMapper, + getAdminOffersResponseMapper, +} from '~/mappers/offers-mappers'; +import { Currency } from '~/utils/offers/currency/CurrencyEnum'; +import { convertWithDate } from '~/utils/offers/currency/currencyExchange'; +import { createValidationRegex } from '~/utils/offers/zodRegex'; + +import { createRouter } from '../context'; + +const getOrder = (prefix: string) => { + return prefix === '+' ? 'asc' : 'desc'; +}; + +const sortingKeysMap = { + companyName: 'companyName', + jobTitle: 'jobTitle', + monthYearReceived: 'monthYearReceived', + totalCompensation: 'totalCompensation', + totalYoe: 'totalYoe', +}; + +const yoeCategoryMap: Record = { + 0: 'Internship', + 1: 'Fresh Grad', + 2: 'Mid', + 3: 'Senior', +}; + +const getYoeRange = (yoeCategory: number | null | undefined) => { + return yoeCategory == null + ? { maxYoe: 100, minYoe: 0 } + : yoeCategoryMap[yoeCategory] === 'Fresh Grad' + ? { maxYoe: 2, minYoe: 0 } + : yoeCategoryMap[yoeCategory] === 'Mid' + ? { maxYoe: 5, minYoe: 3 } + : yoeCategoryMap[yoeCategory] === 'Senior' + ? { maxYoe: 100, minYoe: 6 } + : null; // Internship +}; + +export const offerAdminRouter = createRouter().query('list', { + input: z.object({ + companyId: z.string().nullish(), + countryId: z.string().nullish(), + currency: z.string().nullish(), + dateEnd: z.date().nullish(), + dateStart: z.date().nullish(), + limit: z.number().positive(), + offset: z.number().nonnegative(), + salaryMax: z.number().nonnegative().nullish(), + salaryMin: z.number().nonnegative().nullish(), + sortBy: z + .string() + .regex(createValidationRegex(Object.keys(sortingKeysMap), '[+-]{1}')) + .nullish(), + title: z.string().nullish(), + yoeCategory: z.number().min(0).max(3).nullish(), + yoeMax: z.number().max(100).nullish(), + yoeMin: z.number().min(0).nullish(), + }), + async resolve({ ctx, input }) { + const yoeRange = getYoeRange(input.yoeCategory); + const yoeMin = input.yoeMin != null ? input.yoeMin : yoeRange?.minYoe; + const yoeMax = input.yoeMax != null ? input.yoeMax : yoeRange?.maxYoe; + + if (!input.sortBy) { + input.sortBy = '-' + sortingKeysMap.monthYearReceived; + } + + const order = getOrder(input.sortBy.charAt(0)); + const sortingKey = input.sortBy.substring(1); + + const data = !yoeRange + ? await ctx.prisma.offersOffer.findMany({ + // Internship + include: { + company: true, + location: { + include: { + state: { + include: { + country: true, + }, + }, + }, + }, + offersFullTime: { + include: { + baseSalary: true, + bonus: true, + stocks: true, + totalCompensation: true, + }, + }, + offersIntern: { + include: { + monthlySalary: true, + }, + }, + profile: { + include: { + background: true, + offers: true, + }, + }, + }, + orderBy: + sortingKey === sortingKeysMap.monthYearReceived + ? { + monthYearReceived: order, + } + : sortingKey === sortingKeysMap.totalCompensation + ? [ + { + offersIntern: { + monthlySalary: { + baseValue: order, + }, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : sortingKey === sortingKeysMap.totalYoe + ? [ + { + profile: { + background: { + totalYoe: order, + }, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : sortingKey === sortingKeysMap.companyName + ? [ + { + company: { + name: order, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : sortingKey === sortingKeysMap.jobTitle + ? [ + { + offersIntern: { + title: order, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : { monthYearReceived: 'desc' }, + where: { + AND: [ + { + location: { + state: { + countryId: + input.countryId != null && input.countryId.length !== 0 + ? input.countryId + : undefined, + }, + }, + }, + { + offersIntern: { + isNot: null, + }, + }, + { + offersIntern: { + title: + input.title != null && input.title.length !== 0 + ? input.title + : undefined, + }, + }, + { + offersIntern: { + monthlySalary: { + baseValue: { + gte: input.salaryMin ?? undefined, + lte: input.salaryMax ?? undefined, + }, + }, + }, + }, + { + offersFullTime: { + is: null, + }, + }, + { + companyId: + input.companyId && input.companyId.length !== 0 + ? input.companyId + : undefined, + }, + { + profile: { + background: { + totalYoe: { + gte: yoeMin, + lte: yoeMax, + }, + }, + }, + }, + { + monthYearReceived: { + gte: input.dateStart ?? undefined, + lte: input.dateEnd ?? undefined, + }, + }, + ], + }, + }) + : await ctx.prisma.offersOffer.findMany({ + // Junior, Mid, Senior + include: { + company: true, + location: { + include: { + state: { + include: { + country: true, + }, + }, + }, + }, + offersFullTime: { + include: { + baseSalary: true, + bonus: true, + stocks: true, + totalCompensation: true, + }, + }, + offersIntern: { + include: { + monthlySalary: true, + }, + }, + profile: { + include: { + background: true, + offers: true, + }, + }, + }, + orderBy: + sortingKey === sortingKeysMap.monthYearReceived + ? { + monthYearReceived: order, + } + : sortingKey === sortingKeysMap.totalCompensation + ? [ + { + offersFullTime: { + totalCompensation: { + baseValue: order, + }, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : sortingKey === sortingKeysMap.totalYoe + ? [ + { + profile: { + background: { + totalYoe: order, + }, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : sortingKey === sortingKeysMap.companyName + ? [ + { + company: { + name: order, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : sortingKey === sortingKeysMap.jobTitle + ? [ + { + offersFullTime: { + title: order, + }, + }, + { + monthYearReceived: 'desc', + }, + ] + : { monthYearReceived: 'desc' }, + where: { + AND: [ + { + location: { + state: { + countryId: + input.countryId != null && input.countryId.length !== 0 + ? input.countryId + : undefined, + }, + }, + }, + { + offersIntern: { + is: null, + }, + }, + { + offersFullTime: { + isNot: null, + }, + }, + { + offersFullTime: { + title: + input.title != null && input.title.length !== 0 + ? input.title + : undefined, + }, + }, + { + offersFullTime: { + totalCompensation: { + baseValue: { + gte: input.salaryMin ?? undefined, + lte: input.salaryMax ?? undefined, + }, + }, + }, + }, + { + companyId: + input.companyId && input.companyId.length !== 0 + ? input.companyId + : undefined, + }, + { + profile: { + background: { + totalYoe: { + gte: yoeMin, + lte: yoeMax, + }, + }, + }, + }, + { + monthYearReceived: { + gte: input.dateStart ?? undefined, + lte: input.dateEnd ?? undefined, + }, + }, + ], + }, + }); + + const startRecordIndex: number = input.limit * input.offset; + const endRecordIndex: number = + startRecordIndex + input.limit <= data.length + ? startRecordIndex + input.limit + : data.length; + let paginatedData = data.slice(startRecordIndex, endRecordIndex); + + // CONVERTING + const currency = input.currency?.toUpperCase(); + if (currency != null && currency in Currency) { + paginatedData = await Promise.all( + paginatedData.map(async (offer) => { + if (offer.offersFullTime?.totalCompensation != null) { + offer.offersFullTime.totalCompensation.value = + await convertWithDate( + offer.offersFullTime.totalCompensation.value, + offer.offersFullTime.totalCompensation.currency, + currency, + offer.offersFullTime.totalCompensation.updatedAt, + ); + offer.offersFullTime.totalCompensation.currency = currency; + + if (offer.offersFullTime?.baseSalary != null) { + offer.offersFullTime.baseSalary.value = await convertWithDate( + offer.offersFullTime.baseSalary.value, + offer.offersFullTime.baseSalary.currency, + currency, + offer.offersFullTime.baseSalary.updatedAt, + ); + offer.offersFullTime.baseSalary.currency = currency; + } + + if (offer.offersFullTime?.stocks != null) { + offer.offersFullTime.stocks.value = await convertWithDate( + offer.offersFullTime.stocks.value, + offer.offersFullTime.stocks.currency, + currency, + offer.offersFullTime.stocks.updatedAt, + ); + offer.offersFullTime.stocks.currency = currency; + } + + if (offer.offersFullTime?.bonus != null) { + offer.offersFullTime.bonus.value = await convertWithDate( + offer.offersFullTime.bonus.value, + offer.offersFullTime.bonus.currency, + currency, + offer.offersFullTime.bonus.updatedAt, + ); + offer.offersFullTime.bonus.currency = currency; + } + } else if (offer.offersIntern?.monthlySalary != null) { + offer.offersIntern.monthlySalary.value = await convertWithDate( + offer.offersIntern.monthlySalary.value, + offer.offersIntern.monthlySalary.currency, + currency, + offer.offersIntern.monthlySalary.updatedAt, + ); + offer.offersIntern.monthlySalary.currency = currency; + } else { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Total Compensation or Salary not found', + }); + } + + return offer; + }), + ); + } + + return getAdminOffersResponseMapper( + paginatedData.map((offer) => adminDashboardOfferDtoMapper(offer)), + { + currentPage: input.offset, + numOfItems: paginatedData.length, + numOfPages: Math.ceil(data.length / input.limit), + totalItems: data.length, + }, + !yoeRange ? JobType.INTERN : JobType.FULLTIME, + ); + }, +}); diff --git a/apps/portal/src/types/offers.d.ts b/apps/portal/src/types/offers.d.ts index 5faca2bb..9814a208 100644 --- a/apps/portal/src/types/offers.d.ts +++ b/apps/portal/src/types/offers.d.ts @@ -228,3 +228,25 @@ export type Location = { stateId: string; stateName: string; }; + +export type GetAdminOffersResponse = { + data: Array; + jobType: JobType; + paging: Paging; +}; + +export type AdminDashboardOffer = { + baseSalary?: Valuation; + bonus?: Valuation; + company: OffersCompany; + id: string; + income: Valuation; + location: Location; + monthYearReceived: Date; + numberOfOtherOffers: number; + profileId: string; + stocks?: Valuation; + title: string; + token: string; + totalYoe: number; +};