diff --git a/apps/portal/package.json b/apps/portal/package.json index 303c6163..fb8b60b9 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -10,6 +10,7 @@ "tsc": "tsc", "postinstall": "prisma generate", "seed": "ts-node prisma/seed.ts", + "seed-salaries": "ts-node prisma/seed-salaries.ts", "seed-questions": "ts-node prisma/seed-questions.ts" }, "dependencies": { @@ -26,10 +27,12 @@ "@trpc/server": "^9.27.2", "axios": "^1.1.2", "clsx": "^1.2.1", + "cross-fetch": "^3.1.5", "date-fns": "^2.29.3", "formidable": "^2.0.1", "next": "12.3.1", "next-auth": "~4.10.3", + "node-fetch": "^3.2.10", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -38,8 +41,10 @@ "react-popper": "^2.3.0", "react-popper-tooltip": "^4.4.2", "react-query": "^3.39.2", + "read-excel-file": "^5.5.3", "superjson": "^1.10.0", "unique-names-generator": "^4.7.1", + "xlsx": "^0.18.5", "zod": "^3.18.0" }, "devDependencies": { diff --git a/apps/portal/prisma/companySeed.ts b/apps/portal/prisma/companySeed.ts new file mode 100644 index 00000000..e11780e5 --- /dev/null +++ b/apps/portal/prisma/companySeed.ts @@ -0,0 +1,19 @@ +import reader from 'xlsx'; + +const file = reader.readFile('prisma/salaries.xlsx'); + +export const COMPANIES: Array = [] + +type CompanyData = { + Finalized: string; + description: string; + logoUrl: string; + name: string; + slug: string; + website: string; +}; + +const temp = reader.utils.sheet_to_json(file.Sheets[file.SheetNames[1]]); +temp.forEach((res: CompanyData) => { + COMPANIES.push(res); +}); \ No newline at end of file diff --git a/apps/portal/prisma/migrations/20221023203239_/migration.sql b/apps/portal/prisma/migrations/20221023203239_/migration.sql new file mode 100644 index 00000000..bf083a83 --- /dev/null +++ b/apps/portal/prisma/migrations/20221023203239_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OffersBackground" ALTER COLUMN "totalYoe" SET DEFAULT 0; diff --git a/apps/portal/prisma/migrations/20221104084342_add_cascade_delete_to_analysed_offer_in_analysis_unit/migration.sql b/apps/portal/prisma/migrations/20221104084342_add_cascade_delete_to_analysed_offer_in_analysis_unit/migration.sql new file mode 100644 index 00000000..12ec892c --- /dev/null +++ b/apps/portal/prisma/migrations/20221104084342_add_cascade_delete_to_analysed_offer_in_analysis_unit/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "OffersAnalysisUnit" DROP CONSTRAINT "OffersAnalysisUnit_analysedOfferId_fkey"; + +-- AddForeignKey +ALTER TABLE "OffersAnalysisUnit" ADD CONSTRAINT "OffersAnalysisUnit_analysedOfferId_fkey" FOREIGN KEY ("analysedOfferId") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221104084451_add_cascade_delete_to_overall_analysis_in_offers_analysis/migration.sql b/apps/portal/prisma/migrations/20221104084451_add_cascade_delete_to_overall_analysis_in_offers_analysis/migration.sql new file mode 100644 index 00000000..a04f6969 --- /dev/null +++ b/apps/portal/prisma/migrations/20221104084451_add_cascade_delete_to_overall_analysis_in_offers_analysis/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "OffersAnalysis" DROP CONSTRAINT "OffersAnalysis_overallAnalysisUnitId_fkey"; + +-- AddForeignKey +ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_overallAnalysisUnitId_fkey" FOREIGN KEY ("overallAnalysisUnitId") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221104095551_use_location_database_for_resumes/migration.sql b/apps/portal/prisma/migrations/20221104095551_use_location_database_for_resumes/migration.sql new file mode 100644 index 00000000..e9bda081 --- /dev/null +++ b/apps/portal/prisma/migrations/20221104095551_use_location_database_for_resumes/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `location` on the `ResumesResume` table. All the data in the column will be lost. + - Added the required column `locationId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable. Set default location to Singapore. +ALTER TABLE "ResumesResume" DROP COLUMN "location", +ADD COLUMN "locationId" TEXT NOT NULL DEFAULT '196'; + +-- AddForeignKey +ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Country"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/migrations/20221105140124_revert_default_location/migration.sql b/apps/portal/prisma/migrations/20221105140124_revert_default_location/migration.sql new file mode 100644 index 00000000..63805097 --- /dev/null +++ b/apps/portal/prisma/migrations/20221105140124_revert_default_location/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ResumesResume" ALTER COLUMN "locationId" DROP DEFAULT; diff --git a/apps/portal/prisma/salaries.xlsx b/apps/portal/prisma/salaries.xlsx new file mode 100644 index 00000000..3ff67944 Binary files /dev/null and b/apps/portal/prisma/salaries.xlsx differ diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index cbf65808..77a8d44b 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -112,6 +112,7 @@ model Country { code String @unique states State[] questionsQuestionEncounters QuestionsQuestionEncounter[] + ResumesResume ResumesResume[] } model State { @@ -148,13 +149,14 @@ model ResumesResume { // TODO: Update role, experience, location to use Enums role String @db.Text experience String @db.Text - location String @db.Text + locationId String url String additionalInfo String? @db.Text isResolved Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) + location Country @relation(fields: [locationId], references: [id], onDelete: Cascade) stars ResumesStar[] comments ResumesComment[] } @@ -235,7 +237,7 @@ model OffersProfile { model OffersBackground { id String @id @default(cuid()) - totalYoe Int + totalYoe Int @default(0) specificYoes OffersSpecificYoe[] experiences OffersExperience[] @@ -410,7 +412,7 @@ model OffersAnalysis { offerId String @unique // OVERALL - overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id]) + overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id], onDelete: Cascade) overallAnalysisUnitId String companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis") @@ -419,7 +421,7 @@ model OffersAnalysis { model OffersAnalysisUnit { id String @id @default(cuid()) - analysedOffer OffersOffer @relation("Analysed Offer", fields: [analysedOfferId], references: [id]) + analysedOffer OffersOffer @relation("Analysed Offer", fields: [analysedOfferId], references: [id], onDelete: Cascade) analysedOfferId String percentile Float diff --git a/apps/portal/prisma/seed-salaries.ts b/apps/portal/prisma/seed-salaries.ts new file mode 100644 index 00000000..69aa8a72 --- /dev/null +++ b/apps/portal/prisma/seed-salaries.ts @@ -0,0 +1,377 @@ +import reader from 'xlsx'; +import { PrismaClient } from '@prisma/client'; +import crypto from 'crypto'; +import { baseCurrencyString } from '../src/utils/offers/currency'; +import { convert } from '../src/utils/offers/currency/currencyExchange'; +import { generateAnalysis } from '../src/utils/offers/analysis/analysisGeneration'; + +import { + generateRandomName, + generateRandomStringForToken, +} from '../src/utils/offers/randomGenerator'; + +const prisma = new PrismaClient(); + +// Reading our test file +const file = reader.readFile('prisma/salaries.xlsx'); + +let data: Array = []; + +type ExcelData = { + Timestamp: Date; + Type: string; + Company: string; + Role: string; + Income?: number | string; + Stocks?: number | string; + SignOn?: number | string; + TC?: number | string; + Bonus?: number | string; + Comments?: string; +}; + +const sheets = file.SheetNames; + +for (let i = 0; i < 1; i++) { + const temp = reader.utils.sheet_to_json(file.Sheets[file.SheetNames[i]]); + temp.forEach((res: ExcelData) => { + data.push(res); + }); +} + +function xlSerialToJsDate(xlSerial) { + return new Date(Date.UTC(0, 0, xlSerial - 1)); +} + +const getJobTitle = (role: string) => { + const processedRole = role.toUpperCase().trim(); + + if (processedRole.includes('ML ENGINEER')) { + return 'machine-learning-engineer'; + } else if (processedRole.includes('BACKEND')) { + return 'back-end-engineer'; + } else if (processedRole.includes('DATA')) { + return 'data-engineer'; + } else if (processedRole.includes('DEVOPS')) { + return 'devops-engineer'; + } else if (processedRole.includes('ENTERPRISE')) { + return 'enterprise-engineer'; + } else if (processedRole.includes('RESEARCH')) { + return 'research-engineer'; + } else if ( + processedRole.includes('CYBER') || + processedRole.includes('SECURITY') + ) { + return 'security-engineer'; + } else if (processedRole.includes('QA')) { + return 'test-engineer'; + } else if (processedRole.includes('SYSTEM')) { + return 'systems-engineer'; + } else { + return 'software-engineer'; // Assume default SWE + } +}; + +const getYoe = (type: string) => { + const processedType = type.toUpperCase().trim(); + + if ( + processedType.includes('FRESH GRAD') || + processedType.includes('JUNIOR') + ) { + return Math.floor(Math.random() * 3); + } else if (processedType.includes('MID')) { + return Math.floor(Math.random() * 3) + 3; + } else if (processedType.includes('SENIOR')) { + return Math.floor(Math.random() * 5) + 6; + } else { + return 0; // INTERNSHIP OR ERROR -> 0 YOE + } +}; + +const getLevel = (type: string) => { + const processedType = type.toUpperCase().trim(); + + if ( + processedType.includes('FRESH GRAD') || + processedType.includes('JUNIOR') + ) { + return 'L1'; + } else if (processedType.includes('MID')) { + return 'L2'; + } else if (processedType.includes('SENIOR')) { + return 'L4'; + } else { + return 'L0'; + } +}; + +const createdProfileIds: Array = []; + +const seedSalaries = async () => { + console.log('Seeding from salaries sheet...'); + + const companyIdMappings = {}; + (await prisma.company.findMany()).forEach((company) => { + companyIdMappings[company.slug] = company.id; + }); + + // get countryId of Singapore + const singapore = await prisma.city.findFirst({ + where: { + name: 'Singapore', + }, + }); + + console.log('Singapore ID: ' + singapore?.id); + // break; + // seed here + + if (singapore) { + return await Promise.all( + data.map(async (data: ExcelData) => { + if (data.TC && typeof data.TC === 'number') { + // Generate random name until unique + let uniqueName: string = await generateRandomName(); + + const jobTitle = getJobTitle(data.Role); + const yoe = getYoe(data.Type); + const level = getLevel(data.Type); + + // check if we have company id + if (companyIdMappings[data.Company]) { + const token = crypto + .createHash('sha256') + .update( + xlSerialToJsDate(data.Timestamp).toString() + + generateRandomStringForToken(), + ) + .digest('hex'); + + if (data.Type.toUpperCase() === 'INTERNSHIP') { + // create profile + const dataAdded = await prisma.offersProfile.create({ + data: { + profileName: uniqueName, + createdAt: xlSerialToJsDate(data.Timestamp), + editToken: token, + background: { + create: { + totalYoe: yoe, + }, + }, + offers: { + create: { + comments: data.Comments ?? '', + company: { + connect: { + id: companyIdMappings[data.Company], + }, + }, + jobType: 'INTERN', + location: { + connect: { + id: singapore.id, + }, + }, // TODO: DEFAULT AS SG + monthYearReceived: xlSerialToJsDate(data.Timestamp), + negotiationStrategy: '', + offersIntern: { + create: { + internshipCycle: 'Summer', + monthlySalary: { + create: { + baseCurrency: baseCurrencyString, + baseValue: await convert( + data.Income + ? typeof data.Income === 'number' + ? data.Income + : 0 + : 0, + 'SGD', // assume sgd + baseCurrencyString, + ), + currency: 'SGD', // assume sgd + value: data.Income + ? typeof data.Income === 'number' + ? data.Income + : 0 + : 0, + }, + }, + startYear: xlSerialToJsDate( + data.Timestamp, + ).getFullYear(), + title: jobTitle, + }, + }, + }, + }, + }, + }); + + console.log('Profile created:', dataAdded.id); + createdProfileIds.push(dataAdded.id); + } else { + // assume rest full time + const dataAdded = await prisma.offersProfile.create({ + data: { + profileName: uniqueName, + createdAt: xlSerialToJsDate(data.Timestamp), + editToken: token, + background: { + create: { + totalYoe: yoe, + }, + }, + offers: { + create: { + comments: data.Comments ?? '', + company: { + connect: { + id: companyIdMappings[data.Company], + }, + }, + jobType: 'FULLTIME', + location: { + connect: { + id: singapore.id, + }, + }, // TODO: DEFAULT AS SG + monthYearReceived: xlSerialToJsDate(data.Timestamp), + negotiationStrategy: '', + offersFullTime: { + create: { + baseSalary: { + create: { + baseCurrency: baseCurrencyString, + baseValue: await convert( + data.Income + ? typeof data.Income === 'number' + ? data.Income + : 0 + : 0, + 'SGD', // assume sgd + baseCurrencyString, + ), + currency: 'SGD', // assume sgd + value: data.Income + ? typeof data.Income === 'number' + ? data.Income + : 0 + : 0, + }, + }, + bonus: { + create: { + baseCurrency: baseCurrencyString, + baseValue: await convert( + data.Bonus + ? typeof data.Bonus === 'number' + ? data.Bonus + : 0 + : 0, + 'SGD', + baseCurrencyString, + ), + currency: 'SGD', + value: data.Bonus + ? typeof data.Bonus === 'number' + ? data.Bonus + : 0 + : 0, + }, + }, + level: level, + stocks: { + create: { + baseCurrency: baseCurrencyString, + baseValue: await convert( + data.Stocks + ? typeof data.Stocks === 'number' + ? data.Stocks + : 0 + : 0, + 'SGD', + baseCurrencyString, + ), + currency: 'SGD', + value: data.Stocks + ? typeof data.Stocks === 'number' + ? data.Stocks + : 0 + : 0, + }, + }, + title: jobTitle, + totalCompensation: { + create: { + baseCurrency: baseCurrencyString, + baseValue: await convert( + data.TC + ? typeof data.TC === 'number' + ? data.TC + : 0 + : 0, + 'SGD', + baseCurrencyString, + ), + currency: 'SGD', + value: data.TC + ? typeof data.TC === 'number' + ? data.TC + : 0 + : 0, + }, + }, + }, + }, + }, + }, + }, + }); + console.log('Profile created:', dataAdded.id); + createdProfileIds.push(dataAdded.id); + } + } else { + console.log('Invalid Company: ' + data.Company); + } + } else { + console.log('Invalid TC not a number: ' + data.TC); + } + }), + ); + } +}; + +const generateAllAnalysis = async () => { + return await Promise.all( + createdProfileIds.map(async (profileId) => { + await generateAnalysis({ + ctx: { prisma, session: null }, + input: { profileId }, + }); + console.log('Analysis generated for profile with id:', profileId); + }), + ); +}; + +Promise.all([seedSalaries()]) + .then(() => { + console.log(createdProfileIds.length + ' profiles created'); + console.log('Busy crunching analysis.....'); + }) + .then(() => generateAllAnalysis()) + .then((_data) => { + console.log('Seeding from salaries sheet complete'); + }) + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); + +export {}; diff --git a/apps/portal/prisma/seed.ts b/apps/portal/prisma/seed.ts index c27ef569..e008fd28 100644 --- a/apps/portal/prisma/seed.ts +++ b/apps/portal/prisma/seed.ts @@ -1,3 +1,4 @@ +import { COMPANIES } from './companySeed'; const { PrismaClient } = require('@prisma/client'); const cities = require('./data/cities.json'); @@ -6,45 +7,6 @@ const states = require('./data/states.json'); const prisma = new PrismaClient(); -const COMPANIES = [ - { - name: 'Meta', - slug: 'meta', - description: `Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.`, - logoUrl: 'https://logo.clearbit.com/meta.com', - }, - { - name: 'Google', - slug: 'google', - description: `Google LLC is an American multinational technology company that focuses on search engine technology, online advertising, cloud computing, computer software, quantum computing, e-commerce, artificial intelligence, and consumer electronics.`, - logoUrl: 'https://logo.clearbit.com/google.com', - }, - { - name: 'Apple', - slug: 'apple', - description: `Apple Inc. is an American multinational technology company that specializes in consumer electronics, software and online services headquartered in Cupertino, California, United States.`, - logoUrl: 'https://logo.clearbit.com/apple.com', - }, - { - name: 'Amazon', - slug: 'amazon', - description: `Amazon.com, Inc. is an American multinational technology company that focuses on e-commerce, cloud computing, digital streaming, and artificial intelligence.`, - logoUrl: 'https://logo.clearbit.com/amazon.com', - }, - { - name: 'Microsoft', - slug: 'microsoft', - description: `Microsoft Corporation is an American multinational technology corporation which produces computer software, consumer electronics, personal computers, and related services headquartered at the Microsoft Redmond campus located in Redmond, Washington, United States.`, - logoUrl: 'https://logo.clearbit.com/microsoft.com', - }, - { - name: 'Netflix', - slug: 'netflix', - description: null, - logoUrl: 'https://logo.clearbit.com/netflix.com', - }, -]; - async function main() { console.log('Seeding started...'); diff --git a/apps/portal/src/components/global/AppShell.tsx b/apps/portal/src/components/global/AppShell.tsx index b1191d45..1f1979da 100644 --- a/apps/portal/src/components/global/AppShell.tsx +++ b/apps/portal/src/components/global/AppShell.tsx @@ -194,7 +194,7 @@ export default function AppShell({ children }: Props) { Open sidebar