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 59c53a40..70a88985 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -58,6 +58,7 @@ model User { questionsAnswerVotes QuestionsAnswerVote[] questionsAnswerComments QuestionsAnswerComment[] questionsAnswerCommentVotes QuestionsAnswerCommentVote[] + OffersProfile OffersProfile[] } enum Vote { @@ -89,13 +90,15 @@ enum TodoStatus { } model Company { - id String @id @default(cuid()) - name String @db.Text - slug String @unique - description String? @db.Text - logoUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String @db.Text + slug String @unique + description String? @db.Text + logoUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + OffersExperience OffersExperience[] + OffersOffer OffersOffer[] } // Start of Resumes project models. @@ -170,6 +173,179 @@ model ResumesCommentVote { // Add Offers project models here, prefix all models with "Offer", // use camelCase for field names, and try to name them consistently // across all models in this file. + +model OffersProfile { + id String @id @default(cuid()) + profileName String @unique + createdAt DateTime @default(now()) + + background OffersBackground? + + editToken String + + discussion OffersReply[] + + offers OffersOffer[] + + user User? @relation(fields: [userId], references: [id]) + userId String? +} + +model OffersBackground { + id String @id @default(cuid()) + + totalYoe Int? + specificYoes OffersSpecificYoe[] + + experiences OffersExperience[] // For extensibility in the future + + educations OffersEducation[] // For extensibility in the future + + profile OffersProfile @relation(fields: [offersProfileId], references: [id]) + offersProfileId String @unique +} + +model OffersSpecificYoe { + id String @id @default(cuid()) + + yoe Int + domain String + + background OffersBackground @relation(fields: [backgroundId], references: [id]) + backgroundId String +} + +model OffersExperience { + id String @id @default(cuid()) + + company Company? @relation(fields: [companyId], references: [id]) + companyId String? + + jobType JobType? + title String? + + // Add more fields + durationInMonths Int? + specialization String? + + // FULLTIME fields + level String? + totalCompensation OffersCurrency? @relation("ExperienceTotalCompensation", fields: [totalCompensationId], references: [id]) + totalCompensationId String? @unique + + // INTERN fields + monthlySalary OffersCurrency? @relation("ExperienceMonthlySalary", fields: [monthlySalaryId], references: [id]) + monthlySalaryId String? @unique + + background OffersBackground @relation(fields: [backgroundId], references: [id]) + backgroundId String +} + +model OffersCurrency { + id String @id @default(cuid()) + value Int + currency String + + // Experience + OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation") + OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary") + + // Full Time + OffersTotalCompensation OffersFullTime? @relation("OfferTotalCompensation") + OffersBaseSalary OffersFullTime? @relation("OfferBaseSalary") + OffersBonus OffersFullTime? @relation("OfferBonus") + OffersStocks OffersFullTime? @relation("OfferStocks") + + // Intern + OffersMonthlySalary OffersIntern? +} + +enum JobType { + INTERN + FULLTIME +} + +model OffersEducation { + id String @id @default(cuid()) + type String? + field String? + + // Add more fields + school String? + startDate DateTime? + endDate DateTime? + + background OffersBackground @relation(fields: [backgroundId], references: [id]) + backgroundId String +} + +model OffersReply { + id String @id @default(cuid()) + creator String + createdAt DateTime @default(now()) + message String + + replyingToId String? + replyingTo OffersReply? @relation("ReplyThread", fields: [replyingToId], references: [id]) + replies OffersReply[] @relation("ReplyThread") + + profile OffersProfile @relation(fields: [profileId], references: [id]) + profileId String +} + +model OffersOffer { + id String @id @default(cuid()) + + profile OffersProfile @relation(fields: [profileId], references: [id]) + profileId String + + company Company @relation(fields: [companyId], references: [id]) + companyId String + + monthYearReceived DateTime + location String + negotiationStrategy String? + comments String? + + jobType JobType + + OffersIntern OffersIntern? @relation(fields: [offersInternId], references: [id]) + offersInternId String? @unique + + OffersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id]) + offersFullTimeId String? @unique +} + +model OffersIntern { + id String @id @default(cuid()) + + title String + specialization String + internshipCycle String + startYear Int + monthlySalary OffersCurrency @relation(fields: [monthlySalaryId], references: [id]) + monthlySalaryId String @unique + + OffersOffer OffersOffer? +} + +model OffersFullTime { + id String @id @default(cuid()) + title String + specialization String + level String + totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id]) + totalCompensationId String @unique + baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id]) + baseSalaryId String @unique + bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id]) + bonusId String @unique + stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id]) + stocksId String @unique + + OffersOffer OffersOffer? +} + // End of Offers project models. // Start of Questions project models. 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..5206935f --- /dev/null +++ b/apps/portal/src/pages/offers/test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { trpc } from '~/utils/trpc'; + +function test() { + const data = trpc.useQuery([ + 'offers.list', + { + limit: 3, + location: 'Singapore, Singapore', + offset: 0, + sortBy: '-monthYearReceived', + yoeCategory: 0, + }, + ]); + + return ( + + ); +} + +export default test; diff --git a/apps/portal/src/pages/offers/testCreateProfile.tsx b/apps/portal/src/pages/offers/testCreateProfile.tsx new file mode 100644 index 00000000..f58dbfd4 --- /dev/null +++ b/apps/portal/src/pages/offers/testCreateProfile.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; + +import { trpc } from '~/utils/trpc'; + +function Test() { +// F const data = trpc.useQuery([ +// 'offers.profile.', +// { +// limit: 3, +// location: 'Singapore, Singapore', +// offset: 0, +// yoeCategory: 0, +// }, +// ]); + + const [createdData, setCreatedData] = useState("") + + const createMutation = trpc.useMutation(['offers.profile.create'], { + onError(error: any) { + alert(error) + }, + onSuccess(data) { + setCreatedData(JSON.stringify(data)) + }, + }); + + const handleClick = () => { + createMutation.mutate({ +"background": { + "educations": [ + { + "endDate": new Date("2018-09-30T07:58:54.000Z"), + "field": "Computer Science", + "school": "National University of Singapore", + "startDate": new Date("2014-09-30T07:58:54.000Z"), + "type": "Bachelors" + } + ], + "experiences": [ + { + "companyId": "cl92ly8xm0000w3mwh5ymyqmx", + "durationInMonths": 24, + "jobType": "FULLTIME", + "level": "Junior", + // "monthlySalary": undefined, + "specialization": "Front End", + "title": "Software Engineer", + "totalCompensation": { + "currency": "SGD", + "value": 104100 + } + } + ], + "specificYoes": [ + { + "domain": "Front End", + "yoe": 2 + }, + { + "domain": "Full Stack", + "yoe": 2 + } + ], + "totalYoe": 4 + }, + "offers": [ + { + "comments": "", + "companyId": "cl92ly8xm0000w3mwh5ymyqmx", + "job": { + "base": { + "currency": "SGD", + "value": 84000 + }, + "bonus": { + "currency": "SGD", + "value": 20000 + }, + "level": "Junior", + "specialization": "Front End", + "stocks": { + "currency": "SGD", + "value": 100 + }, + "title": "Software Engineer", + "totalCompensation": { + "currency": "SGD", + "value": 104100 + } + }, + "jobType": "FULLTIME", + "location": "Singapore, Singapore", + "monthYearReceived": new Date("2022-09-30T07:58:54.000Z"), + "negotiationStrategy": "Leveraged having multiple offers" + }, + { + "comments": "", + "companyId": "cl92ly8xm0000w3mwh5ymyqmx", + "job": { + "base": { + "currency": "SGD", + "value": 84000 + }, + "bonus": { + "currency": "SGD", + "value": 20000 + }, + "level": "Junior", + "specialization": "Front End", + "stocks": { + "currency": "SGD", + "value": 100 + }, + "title": "Software Engineer", + "totalCompensation": { + "currency": "SGD", + "value": 104100 + } + }, + "jobType": "FULLTIME", + "location": "Singapore, Singapore", + "monthYearReceived": new Date("2022-09-30T07:58:54.000Z"), + "negotiationStrategy": "Leveraged having multiple offers" + } + ] + }); + }; + + return ( + // + <> +
+ {createdData} +
+ + + ); +} + +export default Test; diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index aa8446ca..c018c3f7 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -2,6 +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 { protectedExampleRouter } from './protected-example-router'; import { questionsAnswerCommentRouter } from './questions-answer-comment-router'; import { questionsAnswerRouter } from './questions-answer-router'; @@ -32,7 +34,9 @@ export const appRouter = createRouter() .merge('questions.answers.comments.', questionsAnswerCommentRouter) .merge('questions.answers.', questionsAnswerRouter) .merge('questions.questions.comments.', questionsQuestionCommentRouter) - .merge('questions.questions.', questionsQuestionRouter); + .merge('questions.questions.', questionsQuestionRouter) + .merge('offers.', offersRouter) + .merge('offers.profile.', offersProfileRouter); // Export type definition of API export type AppRouter = typeof appRouter; diff --git a/apps/portal/src/server/router/offers-profile-router.ts b/apps/portal/src/server/router/offers-profile-router.ts new file mode 100644 index 00000000..286b6e5a --- /dev/null +++ b/apps/portal/src/server/router/offers-profile-router.ts @@ -0,0 +1,269 @@ +import crypto, { randomUUID } from "crypto"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; + +import { createProtectedRouter } 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 = createProtectedRouter().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) => ({ + 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(), + }, + 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 + } + }, +); diff --git a/apps/portal/src/server/router/offers.ts b/apps/portal/src/server/router/offers.ts new file mode 100644 index 00000000..28e6c0eb --- /dev/null +++ b/apps/portal/src/server/router/offers.ts @@ -0,0 +1,297 @@ +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', '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; + }); + + 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; + } + } + 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; + } + } + + return defaultReturn; + })(); + } + return defaultReturn; + }); + + return data; + }, +});