diff --git a/apps/portal/prisma/migrations/20221009160601_/migration.sql b/apps/portal/prisma/migrations/20221009160601_/migration.sql new file mode 100644 index 00000000..0b63fe6e --- /dev/null +++ b/apps/portal/prisma/migrations/20221009160601_/migration.sql @@ -0,0 +1,204 @@ +-- CreateEnum +CREATE TYPE "JobType" AS ENUM ('INTERN', 'FULLTIME'); + +-- CreateTable +CREATE TABLE "OffersProfile" ( + "id" TEXT NOT NULL, + "profileName" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "editToken" TEXT NOT NULL, + "userId" TEXT, + + CONSTRAINT "OffersProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersBackground" ( + "id" TEXT NOT NULL, + "totalYoe" INTEGER, + "offersProfileId" TEXT NOT NULL, + + CONSTRAINT "OffersBackground_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersSpecificYoe" ( + "id" TEXT NOT NULL, + "yoe" INTEGER NOT NULL, + "domain" TEXT NOT NULL, + "backgroundId" TEXT NOT NULL, + + CONSTRAINT "OffersSpecificYoe_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersExperience" ( + "id" TEXT NOT NULL, + "companyId" TEXT, + "jobType" "JobType", + "title" TEXT, + "durationInMonths" INTEGER, + "specialization" TEXT, + "level" TEXT, + "totalCompensationId" TEXT, + "monthlySalaryId" TEXT, + "backgroundId" TEXT NOT NULL, + + CONSTRAINT "OffersExperience_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersCurrency" ( + "id" TEXT NOT NULL, + "value" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + + CONSTRAINT "OffersCurrency_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersEducation" ( + "id" TEXT NOT NULL, + "type" TEXT, + "field" TEXT, + "isAttending" BOOLEAN, + "school" TEXT, + "startDate" TIMESTAMP(3), + "endDate" TIMESTAMP(3), + "backgroundId" TEXT NOT NULL, + + CONSTRAINT "OffersEducation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersReply" ( + "id" TEXT NOT NULL, + "creator" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "message" TEXT NOT NULL, + "replyingToId" TEXT, + "profileId" TEXT NOT NULL, + + CONSTRAINT "OffersReply_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersOffer" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "monthYearReceived" TIMESTAMP(3) NOT NULL, + "location" TEXT NOT NULL, + "negotiationStrategy" TEXT, + "comments" TEXT, + "jobType" "JobType" NOT NULL, + + CONSTRAINT "OffersOffer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffersIntern" ( + "offerId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "specialization" TEXT NOT NULL, + "internshipCycle" TEXT NOT NULL, + "startYear" INTEGER NOT NULL, + "monthlySalaryId" TEXT NOT NULL, + + CONSTRAINT "OffersIntern_pkey" PRIMARY KEY ("offerId") +); + +-- CreateTable +CREATE TABLE "OffersFullTime" ( + "offerId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "specialization" TEXT NOT NULL, + "level" TEXT NOT NULL, + "totalCompensationId" TEXT NOT NULL, + "baseSalaryId" TEXT NOT NULL, + "bonusId" TEXT NOT NULL, + "stocksId" TEXT NOT NULL, + + CONSTRAINT "OffersFullTime_pkey" PRIMARY KEY ("offerId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersBackground_offersProfileId_key" ON "OffersBackground"("offersProfileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersExperience_totalCompensationId_key" ON "OffersExperience"("totalCompensationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersExperience_monthlySalaryId_key" ON "OffersExperience"("monthlySalaryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersIntern_monthlySalaryId_key" ON "OffersIntern"("monthlySalaryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersFullTime_totalCompensationId_key" ON "OffersFullTime"("totalCompensationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersFullTime_baseSalaryId_key" ON "OffersFullTime"("baseSalaryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersFullTime_bonusId_key" ON "OffersFullTime"("bonusId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffersFullTime_stocksId_key" ON "OffersFullTime"("stocksId"); + +-- AddForeignKey +ALTER TABLE "OffersProfile" ADD CONSTRAINT "OffersProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersBackground" ADD CONSTRAINT "OffersBackground_offersProfileId_fkey" FOREIGN KEY ("offersProfileId") REFERENCES "OffersProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersSpecificYoe" ADD CONSTRAINT "OffersSpecificYoe_backgroundId_fkey" FOREIGN KEY ("backgroundId") REFERENCES "OffersBackground"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersExperience" ADD CONSTRAINT "OffersExperience_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersExperience" ADD CONSTRAINT "OffersExperience_totalCompensationId_fkey" FOREIGN KEY ("totalCompensationId") REFERENCES "OffersCurrency"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersExperience" ADD CONSTRAINT "OffersExperience_monthlySalaryId_fkey" FOREIGN KEY ("monthlySalaryId") REFERENCES "OffersCurrency"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersExperience" ADD CONSTRAINT "OffersExperience_backgroundId_fkey" FOREIGN KEY ("backgroundId") REFERENCES "OffersBackground"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersEducation" ADD CONSTRAINT "OffersEducation_backgroundId_fkey" FOREIGN KEY ("backgroundId") REFERENCES "OffersBackground"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersReply" ADD CONSTRAINT "OffersReply_replyingToId_fkey" FOREIGN KEY ("replyingToId") REFERENCES "OffersReply"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersReply" ADD CONSTRAINT "OffersReply_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersOffer" ADD CONSTRAINT "OffersOffer_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersOffer" ADD CONSTRAINT "OffersOffer_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersIntern" ADD CONSTRAINT "OffersIntern_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "OffersOffer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersIntern" ADD CONSTRAINT "OffersIntern_monthlySalaryId_fkey" FOREIGN KEY ("monthlySalaryId") REFERENCES "OffersCurrency"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "OffersOffer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_totalCompensationId_fkey" FOREIGN KEY ("totalCompensationId") REFERENCES "OffersCurrency"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_baseSalaryId_fkey" FOREIGN KEY ("baseSalaryId") REFERENCES "OffersCurrency"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_bonusId_fkey" FOREIGN KEY ("bonusId") REFERENCES "OffersCurrency"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_stocksId_fkey" FOREIGN KEY ("stocksId") REFERENCES "OffersCurrency"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 8ee673f6..513d3166 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -160,7 +160,7 @@ model ResumesCommentVote { model OffersProfile { id String @id @default(cuid()) - profileName String + profileName String @unique createdAt DateTime @default(now()) background OffersBackground? @@ -250,10 +250,9 @@ enum JobType { } model OffersEducation { - id String @id @default(cuid()) - type String? - field String? - isAttending Boolean? + id String @id @default(cuid()) + type String? + field String? // Add more fields school String? diff --git a/apps/portal/prisma/seed.ts b/apps/portal/prisma/seed.ts index 4af35e05..c31d6705 100644 --- a/apps/portal/prisma/seed.ts +++ b/apps/portal/prisma/seed.ts @@ -35,9 +35,37 @@ const COMPANIES = [ }, ]; +const OFFER_PROFILES = [ + { + id: 'cl91v97ex000109mt7fka5rto', + profileName: 'battery-horse-stable-cow', + editToken: 'cl91ulmhg000009l86o45aspt', + }, + { + id: 'cl91v9iw2000209mtautgdnxq', + profileName: 'house-zebra-fast-giraffe', + editToken: 'cl91umigc000109l80f1tcqe8', + }, + { + id: 'cl91v9m3y000309mt1ctw55wi', + profileName: 'keyboard-mouse-lazy-cat', + editToken: 'cl91ummoa000209l87q2b8hl7', + }, + { + id: 'cl91v9p09000409mt5rvoasf1', + profileName: 'router-hen-bright-pig', + editToken: 'cl91umqa3000309l87jyefe9k', + }, + { + id: 'cl91v9uda000509mt5i5fez3v', + profileName: 'screen-ant-dirty-bird', + editToken: 'cl91umuj9000409l87ez85vmg', + }, +]; + async function main() { console.log('Seeding started...'); - await Promise.all( + await Promise.all([ COMPANIES.map(async (company) => { await prisma.company.upsert({ where: { slug: company.slug }, @@ -45,7 +73,14 @@ async function main() { create: company, }); }), - ); + OFFER_PROFILES.map(async (offerProfile) => { + await prisma.offersProfile.upsert({ + where: { profileName: offerProfile.profileName }, + update: offerProfile, + create: offerProfile, + }); + }), + ]); console.log('Seeding completed.'); } diff --git a/apps/portal/src/pages/offers/test.tsx b/apps/portal/src/pages/offers/test.tsx new file mode 100644 index 00000000..df41ae88 --- /dev/null +++ b/apps/portal/src/pages/offers/test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { trpc } from '~/utils/trpc'; + +function test() { + const data = trpc.useQuery([ + 'offers.list', + { + limit: 3, + location: 'Singapore, Singapore', + offset: 0, + yoeCategory: 0, + }, + ]); + + return ( + + ); +} + +export default test; diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 92cb47cf..2f3a63ee 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -1,6 +1,7 @@ import superjson from 'superjson'; import { createRouter } from './context'; +import { offersRouter } from './offers'; import { protectedExampleRouter } from './protected-example-router'; import { resumesResumeUserRouter } from './resumes-resume-user-router'; import { todosRouter } from './todos'; @@ -14,7 +15,8 @@ export const appRouter = createRouter() .merge('auth.', protectedExampleRouter) .merge('todos.', todosRouter) .merge('todos.user.', todosUserRouter) - .merge('resumes.resume.user.', resumesResumeUserRouter); + .merge('resumes.resume.user.', resumesResumeUserRouter) + .merge('offers.', offersRouter); // Export type definition of API export type AppRouter = typeof appRouter; diff --git a/apps/portal/src/server/router/offers.ts b/apps/portal/src/server/router/offers.ts new file mode 100644 index 00000000..17fe2a34 --- /dev/null +++ b/apps/portal/src/server/router/offers.ts @@ -0,0 +1,229 @@ +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 sortingKeys = ['date', 'tc', 'yoe']; + +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, + }, + skip: input.limit * input.offset, + take: input.limit, + 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, + }, + // Junior, Mid + skip: input.limit * input.offset, + take: input.limit, + 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, + }, + skip: input.limit * input.offset, + take: input.limit, + where: { + AND: [ + { + location: input.location, + }, + { + OffersIntern: { + is: null, + }, + }, + { + OffersFullTime: { + isNot: null, + }, + }, + { + profile: { + background: { + totalYoe: { + gte: yoeRange.minYoe, + }, + }, + }, + }, + ], + }, + }); + + 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; + }); + + return data; + }, +});