From 198dc672d2d17b02a6b11e7388ac54e93e9d03cf Mon Sep 17 00:00:00 2001 From: Nirbhay Date: Thu, 12 Feb 2026 17:22:06 +0530 Subject: [PATCH] feat(onboarding): implement candidate & recruiter onboarding completion flow --- eslint.config.mts | 11 +- package.json | 4 +- pnpm-lock.yaml | 16 + schema.sql | 0 src/app.ts | 10 + src/controllers/auth.controller.ts | 14 + src/controllers/onboarding.controller.ts | 301 +++++ src/db/create-user-context.ts | 16 + src/db/index.ts | 3 +- .../migrations/0003_perpetual_shadowcat.sql | 11 + src/db/migrations/0004_ambitious_redwing.sql | 11 + src/db/migrations/0005_cheerful_magdalene.sql | 72 ++ src/db/migrations/meta/0003_snapshot.json | 574 +++++++++ src/db/migrations/meta/0004_snapshot.json | 604 +++++++++ src/db/migrations/meta/0005_snapshot.json | 1079 +++++++++++++++++ src/db/migrations/meta/_journal.json | 21 + src/db/schema/app/candidate-profile.schema.ts | 53 + .../app/candidate_profile_tag.schema.ts | 50 + src/db/schema/app/onboarding.schema.ts | 65 + src/db/schema/app/recruiter-profile.schema.ts | 50 + .../app/recruiter_profile_tag.schema.ts | 50 + src/db/schema/app/tag.schema.ts | 44 + src/db/schema/auth/auth.schema.ts | 24 +- src/db/schema/index.ts | 6 + src/lib/auth/auth.ts | 5 +- src/lib/errors/application-error.ts | 12 + src/lib/errors/validation-error.ts | 7 + .../validation/candidate-onboarding.schema.ts | 48 + src/lib/validation/env.schema.ts | 1 + .../validation/onboarding-status.schema.ts | 60 + .../validation/recruiter-onboarding.schema.ts | 27 + src/middlewares/auth.middleware.ts | 27 + src/middlewares/error-handler.middleware.ts | 27 + src/routes/auth.routes.ts | 10 + src/routes/health.routes.ts | 16 + src/routes/onboarding.routes.ts | 11 + src/types/auth.types.ts | 5 + src/types/express.d.ts | 9 + src/types/user.d.ts | 1 + src/utils/get-auth.ts | 20 + tsconfig.json | 8 +- 41 files changed, 3356 insertions(+), 27 deletions(-) create mode 100644 schema.sql create mode 100644 src/controllers/auth.controller.ts create mode 100644 src/controllers/onboarding.controller.ts create mode 100644 src/db/create-user-context.ts create mode 100644 src/db/migrations/0003_perpetual_shadowcat.sql create mode 100644 src/db/migrations/0004_ambitious_redwing.sql create mode 100644 src/db/migrations/0005_cheerful_magdalene.sql create mode 100644 src/db/migrations/meta/0003_snapshot.json create mode 100644 src/db/migrations/meta/0004_snapshot.json create mode 100644 src/db/migrations/meta/0005_snapshot.json create mode 100644 src/db/schema/app/candidate-profile.schema.ts create mode 100644 src/db/schema/app/candidate_profile_tag.schema.ts create mode 100644 src/db/schema/app/onboarding.schema.ts create mode 100644 src/db/schema/app/recruiter-profile.schema.ts create mode 100644 src/db/schema/app/recruiter_profile_tag.schema.ts create mode 100644 src/db/schema/app/tag.schema.ts create mode 100644 src/lib/errors/application-error.ts create mode 100644 src/lib/errors/validation-error.ts create mode 100644 src/lib/validation/candidate-onboarding.schema.ts create mode 100644 src/lib/validation/onboarding-status.schema.ts create mode 100644 src/lib/validation/recruiter-onboarding.schema.ts create mode 100644 src/middlewares/auth.middleware.ts create mode 100644 src/middlewares/error-handler.middleware.ts create mode 100644 src/routes/auth.routes.ts create mode 100644 src/routes/health.routes.ts create mode 100644 src/routes/onboarding.routes.ts create mode 100644 src/types/auth.types.ts create mode 100644 src/types/express.d.ts create mode 100644 src/types/user.d.ts create mode 100644 src/utils/get-auth.ts diff --git a/eslint.config.mts b/eslint.config.mts index cb31c9a..cde8f0a 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -1,6 +1,5 @@ import js from "@eslint/js"; import { defineConfig } from "eslint/config"; -import importPlugin from "eslint-plugin-import"; import n from "eslint-plugin-n"; import simpleImportSort from "eslint-plugin-simple-import-sort"; import globals from "globals"; @@ -13,7 +12,6 @@ export default defineConfig([ js, n, "simple-import-sort": simpleImportSort, - import: importPlugin, }, extends: [js.configs.recommended, n.configs["flat/recommended"]], languageOptions: { @@ -24,17 +22,12 @@ export default defineConfig([ "simple-import-sort/imports": "error", "simple-import-sort/exports": "error", }, - settings: { - "import/resolver": { - typescript: { - project: "./tsconfig.json", - }, - }, - }, }, { rules: { "n/no-unpublished-import": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "import/order": "off", }, }, tseslint.configs.recommended, diff --git a/package.json b/package.json index ca992b3..30d049f 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,12 @@ "keywords": [], "author": "", "license": "ISC", - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "dependencies": { "better-auth": "^1.4.18", "compression": "^1.8.1", "cors": "^2.8.6", + "crypto-js": "^4.2.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "express": "^5.2.1", @@ -37,6 +38,7 @@ "@eslint/js": "^9.39.2", "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", + "@types/crypto-js": "^4.2.2", "@types/express": "^5.0.6", "@types/node": "^25.1.0", "drizzle-kit": "^0.31.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8829c07..57a2698 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: cors: specifier: ^2.8.6 version: 2.8.6 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -45,6 +48,9 @@ importers: '@types/cors': specifier: ^2.8.19 version: 2.8.19 + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -667,6 +673,9 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1101,6 +1110,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2720,6 +2732,8 @@ snapshots: dependencies: '@types/node': 25.1.0 + '@types/crypto-js@4.2.2': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.1': @@ -3136,6 +3150,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..e69de29 diff --git a/src/app.ts b/src/app.ts index b0643a6..e3209b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,10 @@ import { db } from "./db/index.ts"; import { userTable } from "./db/schema/auth/auth.schema.ts"; import { auth } from "./lib/auth/auth.ts"; import { env } from "./lib/validation/env.schema.ts"; +import { errorHandler } from "./middlewares/error-handler.middleware.ts"; +import authRouter from "./routes/auth.routes.ts"; +import healthRouter from "./routes/health.routes.ts"; +import onboardingRouter from "./routes/onboarding.routes.ts"; const app = express(); @@ -32,4 +36,10 @@ app.get("/", async (_req, res) => { res.send({ message: "SmartAssess Server is running", users: result }); }); +app.use("/api", healthRouter); +app.use("/api", authRouter); +app.use("/api", onboardingRouter); + +app.use(errorHandler); + export default app; diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts new file mode 100644 index 0000000..c6197a4 --- /dev/null +++ b/src/controllers/auth.controller.ts @@ -0,0 +1,14 @@ +import type { Request, Response } from "express"; + +import { getAuth } from "../utils/get-auth.ts"; + +export const getMe = (req: Request, res: Response) => { + const { user } = getAuth(req); + + return res.status(200).json({ + id: user.id, + email: user.email, + role: user.role, + name: user.name, + }); +}; diff --git a/src/controllers/onboarding.controller.ts b/src/controllers/onboarding.controller.ts new file mode 100644 index 0000000..8589c0a --- /dev/null +++ b/src/controllers/onboarding.controller.ts @@ -0,0 +1,301 @@ +import CryptoJS from "crypto-js"; +import { and, eq, inArray, or } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import { z } from "zod"; + +import { createUserContext } from "../db/create-user-context.ts"; +import { + candidateProfileTable, + candidateProfileTagTable, + recruiterProfileTable, + recruiterProfileTagTable, + tagTable, + userOnboardingTable, +} from "../db/schema/index.ts"; +import { + candidateOnboardingSchema, + mapYearsEnumToRange, +} from "../lib/validation/candidate-onboarding.schema.ts"; +import { env } from "../lib/validation/env.schema.ts"; +import { onboardingUpdateSchema } from "../lib/validation/onboarding-status.schema.ts"; +import { recruiterOnboardingSchema } from "../lib/validation/recruiter-onboarding.schema.ts"; +import { getAuth } from "../utils/get-auth.ts"; + +type OnboardingStatus = "not_started" | "in_progress" | "completed"; + +export const getOnboardingStatus = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = getAuth(req).user.id; + const userCtx = createUserContext(userId); + + const onboardingData = await userCtx.withRls((tx) => { + return tx.query.userOnboardingTable.findFirst({ + where: eq(userOnboardingTable.userId, userId), + }); + }); + + let response: { + status: OnboardingStatus; + currentStep?: number; + draft?: unknown; + }; + + if (!onboardingData) { + response = { + status: "not_started", + }; + } else if (!onboardingData.isCompleted) { + response = { + status: "in_progress", + currentStep: onboardingData.currentStep, + draft: onboardingData.draft, + }; + } else { + response = { + status: "completed", + }; + } + + return res.json(response); + } catch (error) { + return next(error); + } +}; + +export const putOnboardingStatus = async (req: Request, res: Response, next: NextFunction) => { + try { + const user = getAuth(req).user; + const userCtx = createUserContext(user.id); + + const onboardingData = await userCtx.withRls((tx) => { + return tx.query.userOnboardingTable.findFirst({ + where: eq(userOnboardingTable.userId, user.id), + }); + }); + + // If no onboarding data exists for the user, create a new record with default values and return it + if (!onboardingData) { + const [onboarding] = await userCtx.withRls(async (tx) => { + return tx + .insert(userOnboardingTable) + .values({ + userId: user.id, + currentStep: 1, + isCompleted: false, + draft: null, + }) + .returning({ + currentStep: userOnboardingTable.currentStep, + isCompleted: userOnboardingTable.isCompleted, + draft: userOnboardingTable.draft, + }); + }); + return res.json(onboarding); + } + + // Parse and validate the incoming data using the onboardingUpdateSchema, which checks the structure of the data and also ensures that if onboarding is marked as completed, the provided draft data is complete and valid according to the full onboarding schema for the user's role + const parsedData = onboardingUpdateSchema.safeParse({ + ...req.body, + onboardingType: user.role, + }); + + // If the provided data is invalid, return a 400 error with details about the validation issues + if (!parsedData.success) { + return res.status(400).json({ + error: "Invalid onboarding data", + details: z.flattenError(parsedData.error).fieldErrors, + }); + } + + const { isCompleted, currentStep, draft } = parsedData.data; + + // If onboarding is not marked as completed, allow updating the current step and draft data without validating completeness + if (!isCompleted) { + const [updatedOnboarding] = await userCtx.withRls(async (tx) => { + return tx + .update(userOnboardingTable) + .set({ + currentStep, + isCompleted, + draft, + }) + .where(eq(userOnboardingTable.userId, user.id)) + .returning({ + currentStep: userOnboardingTable.currentStep, + isCompleted: userOnboardingTable.isCompleted, + draft: userOnboardingTable.draft, + }); + }); + return res.json(updatedOnboarding); + } + + // If onboarding is marked as completed, validate that the provided draft data is complete and valid according to the full onboarding schema + // Validate the draft data against the full onboarding schema based on the user's role + if (user.role === "candidate") { + const parsedCandidateData = candidateOnboardingSchema.safeParse(draft); + + if (!parsedCandidateData.success) { + return res.status(400).json({ + error: "Invalid candidate onboarding data", + details: z.flattenError(parsedCandidateData.error).fieldErrors, + }); + } + + const data = parsedCandidateData.data; + + const { min: minYears, max: maxYears } = mapYearsEnumToRange(data.yearsOfExperience); + + await userCtx.withRls(async (tx) => { + // Check if candidate profile already exists for the user + const existingProfile = await tx.query.candidateProfileTable.findFirst({ + where: eq(candidateProfileTable.userId, user.id), + }); + + if (existingProfile) { + throw new Error("Candidate profile already exists"); + } + + // Upsert candidate profile data + await tx.insert(candidateProfileTable).values({ + userId: user.id, + domain: data.domain, + primaryRole: data.primaryRole, + highestEducation: data.highestEducation, + currentStatus: data.currentStatus, + yearsOfExperienceMin: minYears, + yearsOfExperienceMax: maxYears, + professionalBio: data.professionalBio, + country: data.country, + portfolioUrl: data.portfolioUrl || null, + githubUrl: data.githubUrl || null, + linkedinUrl: data.linkedinUrl || null, + }); + + // Insert missing skills into tag table (if not exists) + await tx + .insert(tagTable) + .values( + data.topSkills.map((skill) => ({ + name: skill, + type: "skill", + })) + ) + .onConflictDoNothing(); + + // Fetch skill tag IDs for the provided skills + const tagRows = await tx.query.tagTable.findMany({ + where: and(eq(tagTable.type, "skill"), inArray(tagTable.name, data.topSkills)), + }); + + // Insert new skill tags for the candidate profile + if (tagRows.length > 0) { + await tx.insert(candidateProfileTagTable).values( + tagRows.map((tag) => ({ + candidateUserId: user.id, + tagId: tag.id, + })) + ); + } + + // Mark onboarding as completed and clear the draft data + await tx + .update(userOnboardingTable) + .set({ + isCompleted: true, + currentStep, + draft: null, + }) + .where(eq(userOnboardingTable.userId, user.id)); + }); + + return res.json({ success: true, message: "Candidate onboarding completed successfully" }); + } + + if (user.role === "recruiter") { + const existingProfile = await userCtx.withRls((tx) => { + return tx.query.recruiterProfileTable.findFirst({ + where: eq(recruiterProfileTable.userId, user.id), + }); + }); + + if (existingProfile) { + throw new Error("Recruiter profile already exists"); + } + + const parsedRecruiterData = recruiterOnboardingSchema.safeParse(draft); + + if (!parsedRecruiterData.success) { + return res.status(400).json({ + error: "Invalid recruiter onboarding data", + details: z.flattenError(parsedRecruiterData.error).fieldErrors, + }); + } + + const data = parsedRecruiterData.data; + + await userCtx.withRls(async (tx) => { + // Upsert recruiter profile data + await tx.insert(recruiterProfileTable).values({ + userId: user.id, + organizationName: data.organizationName, + organizationSize: data.organizationSize, + industry: data.industry, + country: data.country, + companyWebsite: data.companyWebsite || null, + llmProvider: data.llmProvider, + llmApiKey: CryptoJS.AES.encrypt(data.llmApiKey, env.ENCRYPTION_KEY).toString(), + defaultModel: data.defaultModel || null, + }); + + // Insert missing domain + experience_level tags into tag table (if not exists) + await tx + .insert(tagTable) + .values([ + ...data.hiringDomains.map((domain) => ({ name: domain, type: "domain" })), + ...data.experienceLevelsHiring.map((level) => ({ + name: level, + type: "experience_level", + })), + ]) + .onConflictDoNothing(); + + // Fetch tag IDs for the provided hiring domains and experience levels + const tagRows = await tx.query.tagTable.findMany({ + where: or( + and(eq(tagTable.type, "domain"), inArray(tagTable.name, data.hiringDomains)), + and( + eq(tagTable.type, "experience_level"), + inArray(tagTable.name, data.experienceLevelsHiring) + ) + ), + }); + + // Insert new tags for the recruiter profile + if (tagRows.length > 0) { + await tx.insert(recruiterProfileTagTable).values( + tagRows.map((tag) => ({ + recruiterUserId: user.id, + tagId: tag.id, + })) + ); + } + + // Mark onboarding as completed and clear the draft data + await tx + .update(userOnboardingTable) + .set({ + isCompleted: true, + currentStep, + draft: null, + }) + .where(eq(userOnboardingTable.userId, user.id)); + }); + + return res.json({ success: true, message: "Recruiter onboarding completed successfully" }); + } + + throw new Error("Invalid user role"); + } catch (error) { + return next(error); + } +}; diff --git a/src/db/create-user-context.ts b/src/db/create-user-context.ts new file mode 100644 index 0000000..66f149e --- /dev/null +++ b/src/db/create-user-context.ts @@ -0,0 +1,16 @@ +import { sql } from "drizzle-orm"; + +import { db } from "./index.js"; + +type TransactionType = Parameters[0]>[0]; + +export const createUserContext = (userId: string) => { + return { + withRls: async (fn: (tx: TransactionType) => Promise) => { + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('app.current_user_id', ${userId}, true)`); + return fn(tx); + }); + }, + }; +}; diff --git a/src/db/index.ts b/src/db/index.ts index 8805fe9..bffd5db 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -2,10 +2,11 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { env } from "../lib/validation/env.schema.ts"; +import * as schema from "./schema/index.ts"; const client = postgres(env.DATABASE_APP_USER_URL, { prepare: false, ssl: "require", }); -export const db = drizzle(client); +export const db = drizzle(client, { schema }); diff --git a/src/db/migrations/0003_perpetual_shadowcat.sql b/src/db/migrations/0003_perpetual_shadowcat.sql new file mode 100644 index 0000000..44a5386 --- /dev/null +++ b/src/db/migrations/0003_perpetual_shadowcat.sql @@ -0,0 +1,11 @@ +CREATE TABLE "user_onboarding" ( + "user_id" text PRIMARY KEY NOT NULL, + "is_completed" boolean DEFAULT false NOT NULL, + "current_step" integer DEFAULT 1 NOT NULL, + "draft" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_onboarding_userId_idx" ON "user_onboarding" USING btree ("user_id"); \ No newline at end of file diff --git a/src/db/migrations/0004_ambitious_redwing.sql b/src/db/migrations/0004_ambitious_redwing.sql new file mode 100644 index 0000000..c7eb2cc --- /dev/null +++ b/src/db/migrations/0004_ambitious_redwing.sql @@ -0,0 +1,11 @@ +ALTER TABLE "user_onboarding" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE POLICY "Users can see their own onboarding" ON "user_onboarding" AS PERMISSIVE FOR SELECT TO "app_user" USING ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +CREATE POLICY "Users can insert their own onboarding" ON "user_onboarding" AS PERMISSIVE FOR INSERT TO "app_user" WITH CHECK ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +CREATE POLICY "Users can update their own onboarding" ON "user_onboarding" AS PERMISSIVE FOR UPDATE TO "app_user" USING ((user_id = current_setting('app.current_user_id', true))) WITH CHECK ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +CREATE POLICY "Users can delete their own onboarding" ON "user_onboarding" AS PERMISSIVE FOR DELETE TO "app_user" USING ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +ALTER POLICY "Users can update their own accounts" ON "auth"."account" TO app_user USING ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +ALTER POLICY "Users can delete their own accounts" ON "auth"."account" TO app_user USING ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +ALTER POLICY "Users can delete their own sessions" ON "auth"."session" TO app_user USING ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +ALTER POLICY "Users can update their own sessions" ON "auth"."session" TO app_user USING ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +ALTER POLICY "Users can delete their own profile" ON "auth"."user" TO app_user USING ((id = current_setting('app.current_user_id', true)));--> statement-breakpoint +ALTER POLICY "Users can update their own profile" ON "auth"."user" TO app_user USING ((id = current_setting('app.current_user_id', true))) WITH CHECK ((id = current_setting('app.current_user_id', true))); \ No newline at end of file diff --git a/src/db/migrations/0005_cheerful_magdalene.sql b/src/db/migrations/0005_cheerful_magdalene.sql new file mode 100644 index 0000000..0be9102 --- /dev/null +++ b/src/db/migrations/0005_cheerful_magdalene.sql @@ -0,0 +1,72 @@ +CREATE TABLE "candidate_profile" ( + "user_id" text PRIMARY KEY NOT NULL, + "domain" text NOT NULL, + "primary_role" text NOT NULL, + "highest_education" text NOT NULL, + "current_status" text NOT NULL, + "years_of_experience_min" integer NOT NULL, + "years_of_experience_max" integer, + "professional_bio" text NOT NULL, + "country" text NOT NULL, + "portfolio_url" text, + "github_url" text, + "linkedin_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "candidate_profile" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "candidate_profile_tag" ( + "candidate_user_id" text NOT NULL, + "tag_id" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "candidate_profile_tag_candidate_user_id_tag_id_pk" PRIMARY KEY("candidate_user_id","tag_id") +); +--> statement-breakpoint +ALTER TABLE "candidate_profile_tag" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "recruiter_profile" ( + "user_id" text PRIMARY KEY NOT NULL, + "organization_name" text NOT NULL, + "organization_size" text NOT NULL, + "industry" text NOT NULL, + "country" text NOT NULL, + "company_website" text, + "llm_provider" text NOT NULL, + "llm_api_key" text NOT NULL, + "default_model" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "recruiter_profile" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "recruiter_profile_tag" ( + "recruiter_user_id" text NOT NULL, + "tag_id" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "recruiter_profile_tag_recruiter_user_id_tag_id_pk" PRIMARY KEY("recruiter_user_id","tag_id") +); +--> statement-breakpoint +ALTER TABLE "recruiter_profile_tag" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "tag" ( + "id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "tag_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "name" text NOT NULL, + "type" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "tag_name_unique" UNIQUE("name") +); +--> statement-breakpoint +ALTER TABLE "candidate_profile" ADD CONSTRAINT "candidate_profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "candidate_profile_tag" ADD CONSTRAINT "candidate_profile_tag_candidate_user_id_candidate_profile_user_id_fk" FOREIGN KEY ("candidate_user_id") REFERENCES "public"."candidate_profile"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "candidate_profile_tag" ADD CONSTRAINT "candidate_profile_tag_tag_id_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recruiter_profile" ADD CONSTRAINT "recruiter_profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recruiter_profile_tag" ADD CONSTRAINT "recruiter_profile_tag_recruiter_user_id_recruiter_profile_user_id_fk" FOREIGN KEY ("recruiter_user_id") REFERENCES "public"."recruiter_profile"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recruiter_profile_tag" ADD CONSTRAINT "recruiter_profile_tag_tag_id_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "candidate_profile_tag_tagId_idx" ON "candidate_profile_tag" USING btree ("tag_id");--> statement-breakpoint +CREATE INDEX "recruiter_profile_tag_tagId_idx" ON "recruiter_profile_tag" USING btree ("tag_id");--> statement-breakpoint +CREATE INDEX "tag_name_idx" ON "tag" USING btree ("name");--> statement-breakpoint +CREATE INDEX "tag_type_idx" ON "tag" USING btree ("type");--> statement-breakpoint +CREATE POLICY "Users can manage their candidate profile" ON "candidate_profile" AS PERMISSIVE FOR ALL TO "app_user" USING ((user_id = current_setting('app.current_user_id', true))) WITH CHECK ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +CREATE POLICY "Users can manage their profile tags" ON "candidate_profile_tag" AS PERMISSIVE FOR ALL TO "app_user" USING ((candidate_user_id = current_setting('app.current_user_id', true))) WITH CHECK ((candidate_user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +CREATE POLICY "Users can manage their recruiter profile" ON "recruiter_profile" AS PERMISSIVE FOR ALL TO "app_user" USING ((user_id = current_setting('app.current_user_id', true))) WITH CHECK ((user_id = current_setting('app.current_user_id', true)));--> statement-breakpoint +CREATE POLICY "Users can manage their recruiter tags" ON "recruiter_profile_tag" AS PERMISSIVE FOR ALL TO "app_user" USING ((recruiter_user_id = current_setting('app.current_user_id', true))) WITH CHECK ((recruiter_user_id = current_setting('app.current_user_id', true))); \ No newline at end of file diff --git a/src/db/migrations/meta/0003_snapshot.json b/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..b17c920 --- /dev/null +++ b/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,574 @@ +{ + "id": "34945d57-00d3-49a4-8096-34babcca2b24", + "prevId": "2d46798e-05bb-43dc-adfb-1f68ac17c62c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_onboarding": { + "name": "user_onboarding", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_onboarding_userId_idx": { + "name": "user_onboarding_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_user_id_user_id_fk": { + "name": "user_onboarding_user_id_user_id_fk", + "tableFrom": "user_onboarding", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.account": { + "name": "account", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can update their own accounts": { + "name": "Users can update their own accounts", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id'::text, true))" + }, + "System can insert all accounts": { + "name": "System can insert all accounts", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + }, + "System can see all accounts": { + "name": "System can see all accounts", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "Users can delete their own accounts": { + "name": "Users can delete their own accounts", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id'::text, true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session": { + "name": "session", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": { + "System can see all sessions": { + "name": "System can see all sessions", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "Users can delete their own sessions": { + "name": "Users can delete their own sessions", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id'::text, true))" + }, + "Users can update their own sessions": { + "name": "Users can update their own sessions", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id'::text, true))" + }, + "System can create sessions during signup": { + "name": "System can create sessions during signup", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user": { + "name": "user", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "auth", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "terms_accepted_at": { + "name": "terms_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terms_accepted_version": { + "name": "terms_accepted_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": { + "System can see all users": { + "name": "System can see all users", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "System can insert new users": { + "name": "System can insert new users", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + }, + "Users can delete their own profile": { + "name": "Users can delete their own profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(id = current_setting('app.current_user_id'::text, true))" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(id = current_setting('app.current_user_id'::text, true))", + "withCheck": "(id = current_setting('app.current_user_id'::text, true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verification": { + "name": "verification", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "App user can manage verification tokens": { + "name": "App user can manage verification tokens", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["app_user"], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "auth.user_role": { + "name": "user_role", + "schema": "auth", + "values": ["candidate", "recruiter", "admin"] + } + }, + "schemas": { + "auth": "auth" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0004_snapshot.json b/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..f9b866a --- /dev/null +++ b/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,604 @@ +{ + "id": "1ad074e4-d405-41c3-a1bb-8ead116bc1dd", + "prevId": "34945d57-00d3-49a4-8096-34babcca2b24", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_onboarding": { + "name": "user_onboarding", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_onboarding_userId_idx": { + "name": "user_onboarding_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_user_id_user_id_fk": { + "name": "user_onboarding_user_id_user_id_fk", + "tableFrom": "user_onboarding", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can see their own onboarding": { + "name": "Users can see their own onboarding", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can insert their own onboarding": { + "name": "Users can insert their own onboarding", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can update their own onboarding": { + "name": "Users can update their own onboarding", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))", + "withCheck": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can delete their own onboarding": { + "name": "Users can delete their own onboarding", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.account": { + "name": "account", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can update their own accounts": { + "name": "Users can update their own accounts", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "System can insert all accounts": { + "name": "System can insert all accounts", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + }, + "System can see all accounts": { + "name": "System can see all accounts", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "Users can delete their own accounts": { + "name": "Users can delete their own accounts", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session": { + "name": "session", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": { + "System can see all sessions": { + "name": "System can see all sessions", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "Users can delete their own sessions": { + "name": "Users can delete their own sessions", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can update their own sessions": { + "name": "Users can update their own sessions", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "System can create sessions during signup": { + "name": "System can create sessions during signup", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user": { + "name": "user", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "auth", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "terms_accepted_at": { + "name": "terms_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terms_accepted_version": { + "name": "terms_accepted_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": { + "System can see all users": { + "name": "System can see all users", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "System can insert new users": { + "name": "System can insert new users", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + }, + "Users can delete their own profile": { + "name": "Users can delete their own profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(id = current_setting('app.current_user_id', true))" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(id = current_setting('app.current_user_id', true))", + "withCheck": "(id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verification": { + "name": "verification", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "App user can manage verification tokens": { + "name": "App user can manage verification tokens", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["app_user"], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "auth.user_role": { + "name": "user_role", + "schema": "auth", + "values": ["candidate", "recruiter", "admin"] + } + }, + "schemas": { + "auth": "auth" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0005_snapshot.json b/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..69a2bf4 --- /dev/null +++ b/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1079 @@ +{ + "id": "0b8b08ce-f7e7-4c76-bad4-be6074614caa", + "prevId": "1ad074e4-d405-41c3-a1bb-8ead116bc1dd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.candidate_profile": { + "name": "candidate_profile", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_role": { + "name": "primary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "highest_education": { + "name": "highest_education", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_status": { + "name": "current_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "years_of_experience_min": { + "name": "years_of_experience_min", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "years_of_experience_max": { + "name": "years_of_experience_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "professional_bio": { + "name": "professional_bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portfolio_url": { + "name": "portfolio_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "candidate_profile_user_id_user_id_fk": { + "name": "candidate_profile_user_id_user_id_fk", + "tableFrom": "candidate_profile", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can manage their candidate profile": { + "name": "Users can manage their candidate profile", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))", + "withCheck": "(user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_profile_tag": { + "name": "candidate_profile_tag", + "schema": "", + "columns": { + "candidate_user_id": { + "name": "candidate_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_profile_tag_tagId_idx": { + "name": "candidate_profile_tag_tagId_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_profile_tag_candidate_user_id_candidate_profile_user_id_fk": { + "name": "candidate_profile_tag_candidate_user_id_candidate_profile_user_id_fk", + "tableFrom": "candidate_profile_tag", + "tableTo": "candidate_profile", + "columnsFrom": ["candidate_user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_profile_tag_tag_id_tag_id_fk": { + "name": "candidate_profile_tag_tag_id_tag_id_fk", + "tableFrom": "candidate_profile_tag", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "candidate_profile_tag_candidate_user_id_tag_id_pk": { + "name": "candidate_profile_tag_candidate_user_id_tag_id_pk", + "columns": ["candidate_user_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can manage their profile tags": { + "name": "Users can manage their profile tags", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["app_user"], + "using": "(candidate_user_id = current_setting('app.current_user_id', true))", + "withCheck": "(candidate_user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_onboarding": { + "name": "user_onboarding", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_onboarding_userId_idx": { + "name": "user_onboarding_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_onboarding_user_id_user_id_fk": { + "name": "user_onboarding_user_id_user_id_fk", + "tableFrom": "user_onboarding", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can see their own onboarding": { + "name": "Users can see their own onboarding", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can insert their own onboarding": { + "name": "Users can insert their own onboarding", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can update their own onboarding": { + "name": "Users can update their own onboarding", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))", + "withCheck": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can delete their own onboarding": { + "name": "Users can delete their own onboarding", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recruiter_profile": { + "name": "recruiter_profile", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_size": { + "name": "organization_size", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_website": { + "name": "company_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "llm_api_key": { + "name": "llm_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recruiter_profile_user_id_user_id_fk": { + "name": "recruiter_profile_user_id_user_id_fk", + "tableFrom": "recruiter_profile", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can manage their recruiter profile": { + "name": "Users can manage their recruiter profile", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))", + "withCheck": "(user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recruiter_profile_tag": { + "name": "recruiter_profile_tag", + "schema": "", + "columns": { + "recruiter_user_id": { + "name": "recruiter_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recruiter_profile_tag_tagId_idx": { + "name": "recruiter_profile_tag_tagId_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recruiter_profile_tag_recruiter_user_id_recruiter_profile_user_id_fk": { + "name": "recruiter_profile_tag_recruiter_user_id_recruiter_profile_user_id_fk", + "tableFrom": "recruiter_profile_tag", + "tableTo": "recruiter_profile", + "columnsFrom": ["recruiter_user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recruiter_profile_tag_tag_id_tag_id_fk": { + "name": "recruiter_profile_tag_tag_id_tag_id_fk", + "tableFrom": "recruiter_profile_tag", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recruiter_profile_tag_recruiter_user_id_tag_id_pk": { + "name": "recruiter_profile_tag_recruiter_user_id_tag_id_pk", + "columns": ["recruiter_user_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can manage their recruiter tags": { + "name": "Users can manage their recruiter tags", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["app_user"], + "using": "(recruiter_user_id = current_setting('app.current_user_id', true))", + "withCheck": "(recruiter_user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "tag_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tag_name_idx": { + "name": "tag_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_type_idx": { + "name": "tag_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_name_unique": { + "name": "tag_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.account": { + "name": "account", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can update their own accounts": { + "name": "Users can update their own accounts", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "System can insert all accounts": { + "name": "System can insert all accounts", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + }, + "System can see all accounts": { + "name": "System can see all accounts", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "Users can delete their own accounts": { + "name": "Users can delete their own accounts", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session": { + "name": "session", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": { + "System can see all sessions": { + "name": "System can see all sessions", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "Users can delete their own sessions": { + "name": "Users can delete their own sessions", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "Users can update their own sessions": { + "name": "Users can update their own sessions", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(user_id = current_setting('app.current_user_id', true))" + }, + "System can create sessions during signup": { + "name": "System can create sessions during signup", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user": { + "name": "user", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "auth", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "terms_accepted_at": { + "name": "terms_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terms_accepted_version": { + "name": "terms_accepted_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": { + "System can see all users": { + "name": "System can see all users", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["app_user"], + "using": "true" + }, + "System can insert new users": { + "name": "System can insert new users", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["app_user"], + "withCheck": "true" + }, + "Users can delete their own profile": { + "name": "Users can delete their own profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["app_user"], + "using": "(id = current_setting('app.current_user_id', true))" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["app_user"], + "using": "(id = current_setting('app.current_user_id', true))", + "withCheck": "(id = current_setting('app.current_user_id', true))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verification": { + "name": "verification", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "App user can manage verification tokens": { + "name": "App user can manage verification tokens", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["app_user"], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "auth.user_role": { + "name": "user_role", + "schema": "auth", + "values": ["candidate", "recruiter", "admin"] + } + }, + "schemas": { + "auth": "auth" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 5407267..a99c7d0 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -22,6 +22,27 @@ "when": 1770105372720, "tag": "0002_moaning_dreaming_celestial", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1770718310759, + "tag": "0003_perpetual_shadowcat", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1770719453645, + "tag": "0004_ambitious_redwing", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1770876034376, + "tag": "0005_cheerful_magdalene", + "breakpoints": true } ] } diff --git a/src/db/schema/app/candidate-profile.schema.ts b/src/db/schema/app/candidate-profile.schema.ts new file mode 100644 index 0000000..8da6538 --- /dev/null +++ b/src/db/schema/app/candidate-profile.schema.ts @@ -0,0 +1,53 @@ +import { relations, sql } from "drizzle-orm"; +import { integer, pgPolicy, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +import { userTable } from "../auth/auth.schema.ts"; +import { candidateProfileTagTable } from "./candidate_profile_tag.schema.ts"; + +export const candidateProfileTable = pgTable( + "candidate_profile", + { + userId: text("user_id") + .primaryKey() + .notNull() + .references(() => userTable.id, { onDelete: "cascade" }), + + domain: text("domain").notNull(), + primaryRole: text("primary_role").notNull(), + highestEducation: text("highest_education").notNull(), + currentStatus: text("current_status").notNull(), + + // Stored as numeric min/max years. `yearsOfExperience` enum maps to these ranges (e.g., '1-2' => min=1, max=2). + yearsOfExperienceMin: integer("years_of_experience_min").notNull(), + yearsOfExperienceMax: integer("years_of_experience_max"), + professionalBio: text("professional_bio").notNull(), + + country: text("country").notNull(), + portfolioUrl: text("portfolio_url"), + githubUrl: text("github_url"), + linkedinUrl: text("linkedin_url"), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + () => [ + // Single policy covering select/insert/update/delete for profile owner + pgPolicy("Users can manage their candidate profile", { + for: "all", + to: "app_user", + using: sql`(user_id = current_setting('app.current_user_id', true))`, + withCheck: sql`(user_id = current_setting('app.current_user_id', true))`, + }), + ] +); + +export const candidateProfileRelations = relations(candidateProfileTable, ({ one, many }) => ({ + user: one(userTable, { + fields: [candidateProfileTable.userId], + references: [userTable.id], + }), + tags: many(candidateProfileTagTable), +})); diff --git a/src/db/schema/app/candidate_profile_tag.schema.ts b/src/db/schema/app/candidate_profile_tag.schema.ts new file mode 100644 index 0000000..794d655 --- /dev/null +++ b/src/db/schema/app/candidate_profile_tag.schema.ts @@ -0,0 +1,50 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + integer, + pgPolicy, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +import { candidateProfileTable } from "./candidate-profile.schema.ts"; +import { tagTable } from "./tag.schema.ts"; + +export const candidateProfileTagTable = pgTable( + "candidate_profile_tag", + { + candidateUserId: text("candidate_user_id") + .notNull() + .references(() => candidateProfileTable.userId, { onDelete: "cascade" }), + tagId: integer("tag_id") + .notNull() + .references(() => tagTable.id, { onDelete: "cascade" }), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + // composite primary key (candidate_user_id, tag_id) + primaryKey({ columns: [table.candidateUserId, table.tagId] }), + index("candidate_profile_tag_tagId_idx").on(table.tagId), + // Single policy covering select/insert/update/delete for profile owner + pgPolicy("Users can manage their profile tags", { + for: "all", + to: "app_user", + using: sql`(candidate_user_id = current_setting('app.current_user_id', true))`, + withCheck: sql`(candidate_user_id = current_setting('app.current_user_id', true))`, + }), + ] +); + +export const candidateProfileTagRelations = relations(candidateProfileTagTable, ({ one }) => ({ + candidate: one(candidateProfileTable, { + fields: [candidateProfileTagTable.candidateUserId], + references: [candidateProfileTable.userId], + }), + tag: one(tagTable, { + fields: [candidateProfileTagTable.tagId], + references: [tagTable.id], + }), +})); diff --git a/src/db/schema/app/onboarding.schema.ts b/src/db/schema/app/onboarding.schema.ts new file mode 100644 index 0000000..afe8524 --- /dev/null +++ b/src/db/schema/app/onboarding.schema.ts @@ -0,0 +1,65 @@ +import { relations, sql } from "drizzle-orm"; +import { + boolean, + index, + integer, + jsonb, + pgPolicy, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +import type { CandidateOnboardingDraftData } from "../../../lib/validation/candidate-onboarding.schema.ts"; +import { userTable } from "../auth/auth.schema.ts"; + +export const userOnboardingTable = pgTable( + "user_onboarding", + { + userId: text("user_id") + .primaryKey() + .notNull() + .references(() => userTable.id, { onDelete: "cascade" }), + + isCompleted: boolean("is_completed").default(false).notNull(), + currentStep: integer("current_step").default(1).notNull(), + draft: jsonb("draft").$type(), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [ + index("user_onboarding_userId_idx").on(table.userId), + pgPolicy("Users can see their own onboarding", { + for: "select", + to: "app_user", + using: sql`(user_id = current_setting('app.current_user_id', true))`, + }), + pgPolicy("Users can insert their own onboarding", { + for: "insert", + to: "app_user", + withCheck: sql`(user_id = current_setting('app.current_user_id', true))`, + }), + pgPolicy("Users can update their own onboarding", { + for: "update", + to: "app_user", + using: sql`(user_id = current_setting('app.current_user_id', true))`, + withCheck: sql`(user_id = current_setting('app.current_user_id', true))`, + }), + pgPolicy("Users can delete their own onboarding", { + for: "delete", + to: "app_user", + using: sql`(user_id = current_setting('app.current_user_id', true))`, + }), + ] +); + +export const userOnboardingRelations = relations(userOnboardingTable, ({ one }) => ({ + user: one(userTable, { + fields: [userOnboardingTable.userId], + references: [userTable.id], + }), +})); diff --git a/src/db/schema/app/recruiter-profile.schema.ts b/src/db/schema/app/recruiter-profile.schema.ts new file mode 100644 index 0000000..dd8b934 --- /dev/null +++ b/src/db/schema/app/recruiter-profile.schema.ts @@ -0,0 +1,50 @@ +import { relations, sql } from "drizzle-orm"; +import { pgPolicy, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +import { userTable } from "../auth/auth.schema.ts"; +import { recruiterProfileTagTable } from "./recruiter_profile_tag.schema.ts"; + +export const recruiterProfileTable = pgTable( + "recruiter_profile", + { + userId: text("user_id") + .primaryKey() + .notNull() + .references(() => userTable.id, { onDelete: "cascade" }), + + organizationName: text("organization_name").notNull(), + organizationSize: text("organization_size").notNull(), + industry: text("industry").notNull(), + country: text("country").notNull(), + + companyWebsite: text("company_website"), + + // LLM setup fields + llmProvider: text("llm_provider").notNull(), + llmApiKey: text("llm_api_key").notNull(), + defaultModel: text("default_model"), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + () => [ + // Single policy covering select/insert/update/delete for profile owner + pgPolicy("Users can manage their recruiter profile", { + for: "all", + to: "app_user", + using: sql`(user_id = current_setting('app.current_user_id', true))`, + withCheck: sql`(user_id = current_setting('app.current_user_id', true))`, + }), + ] +); + +export const recruiterProfileRelations = relations(recruiterProfileTable, ({ one, many }) => ({ + user: one(userTable, { + fields: [recruiterProfileTable.userId], + references: [userTable.id], + }), + tags: many(recruiterProfileTagTable), +})); diff --git a/src/db/schema/app/recruiter_profile_tag.schema.ts b/src/db/schema/app/recruiter_profile_tag.schema.ts new file mode 100644 index 0000000..cc09781 --- /dev/null +++ b/src/db/schema/app/recruiter_profile_tag.schema.ts @@ -0,0 +1,50 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + integer, + pgPolicy, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +import { recruiterProfileTable } from "./recruiter-profile.schema.ts"; +import { tagTable } from "./tag.schema.ts"; + +export const recruiterProfileTagTable = pgTable( + "recruiter_profile_tag", + { + recruiterUserId: text("recruiter_user_id") + .notNull() + .references(() => recruiterProfileTable.userId, { onDelete: "cascade" }), + tagId: integer("tag_id") + .notNull() + .references(() => tagTable.id, { onDelete: "cascade" }), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + // composite primary key (recruiter_user_id, tag_id) + primaryKey({ columns: [table.recruiterUserId, table.tagId] }), + index("recruiter_profile_tag_tagId_idx").on(table.tagId), + // Single policy covering select/insert/update/delete for recruiter owner + pgPolicy("Users can manage their recruiter tags", { + for: "all", + to: "app_user", + using: sql`(recruiter_user_id = current_setting('app.current_user_id', true))`, + withCheck: sql`(recruiter_user_id = current_setting('app.current_user_id', true))`, + }), + ] +); + +export const recruiterProfileTagRelations = relations(recruiterProfileTagTable, ({ one }) => ({ + recruiter: one(recruiterProfileTable, { + fields: [recruiterProfileTagTable.recruiterUserId], + references: [recruiterProfileTable.userId], + }), + tag: one(tagTable, { + fields: [recruiterProfileTagTable.tagId], + references: [tagTable.id], + }), +})); diff --git a/src/db/schema/app/tag.schema.ts b/src/db/schema/app/tag.schema.ts new file mode 100644 index 0000000..30d6543 --- /dev/null +++ b/src/db/schema/app/tag.schema.ts @@ -0,0 +1,44 @@ +import { relations, sql } from "drizzle-orm"; +import { index, integer, pgPolicy, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +import { candidateProfileTagTable } from "./candidate_profile_tag.schema.ts"; +import { recruiterProfileTagTable } from "./recruiter_profile_tag.schema.ts"; + +export const tagTable = pgTable( + "tag", + { + // Auto-incrementing integer primary key using PostgreSQL identity + id: integer("id").generatedByDefaultAsIdentity().primaryKey().notNull(), + name: text("name").notNull().unique(), + type: text("type").notNull(), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [ + index("tag_name_idx").on(table.name), + index("tag_type_idx").on(table.type), + + // RLS: allow any authenticated `app_user` to read tags, but only admins may create/update/delete + pgPolicy("App users can select tags", { + for: "select", + to: "app_user", + using: sql`true`, + }), + + pgPolicy("Admins can manage tags", { + for: "all", + to: "app_user", + using: sql`EXISTS (SELECT 1 FROM auth."user" u WHERE u.id = current_setting('app.current_user_id', true) AND u.role = 'admin')`, + withCheck: sql`EXISTS (SELECT 1 FROM auth."user" u WHERE u.id = current_setting('app.current_user_id', true) AND u.role = 'admin')`, + }), + ] +); + +export const tagRelations = relations(tagTable, ({ many }) => ({ + candidateProfileTags: many(candidateProfileTagTable), + recruiterProfileTags: many(recruiterProfileTagTable), +})); diff --git a/src/db/schema/auth/auth.schema.ts b/src/db/schema/auth/auth.schema.ts index d92df2e..59df678 100644 --- a/src/db/schema/auth/auth.schema.ts +++ b/src/db/schema/auth/auth.schema.ts @@ -1,6 +1,10 @@ import { relations, sql } from "drizzle-orm"; import { boolean, index, pgPolicy, pgSchema, text, timestamp } from "drizzle-orm/pg-core"; +import { candidateProfileTable } from "../app/candidate-profile.schema.ts"; +import { userOnboardingTable } from "../app/onboarding.schema.ts"; +import { recruiterProfileTable } from "../app/recruiter-profile.schema.ts"; + export const authSchema = pgSchema("auth"); export const userRoleEnum = authSchema.enum("user_role", ["candidate", "recruiter", "admin"]); @@ -41,13 +45,13 @@ export const userTable = authSchema.table( pgPolicy("Users can delete their own profile", { for: "delete", to: "app_user", - using: sql`(id = current_setting('app.current_user_id'::text, true))`, + using: sql`(id = current_setting('app.current_user_id', true))`, }), pgPolicy("Users can update their own profile", { for: "update", to: "app_user", - using: sql`(id = current_setting('app.current_user_id'::text, true))`, - withCheck: sql`(id = current_setting('app.current_user_id'::text, true))`, + using: sql`(id = current_setting('app.current_user_id', true))`, + withCheck: sql`(id = current_setting('app.current_user_id', true))`, }), ] ); @@ -82,12 +86,12 @@ export const sessionTable = authSchema.table( pgPolicy("Users can delete their own sessions", { for: "delete", to: "app_user", - using: sql`(user_id = current_setting('app.current_user_id'::text, true))`, + using: sql`(user_id = current_setting('app.current_user_id', true))`, }), pgPolicy("Users can update their own sessions", { for: "update", to: "app_user", - using: sql`(user_id = current_setting('app.current_user_id'::text, true))`, + using: sql`(user_id = current_setting('app.current_user_id', true))`, }), pgPolicy("System can create sessions during signup", { for: "insert", @@ -129,7 +133,7 @@ export const accountTable = authSchema.table( pgPolicy("Users can update their own accounts", { for: "update", to: "app_user", - using: sql`(user_id = current_setting('app.current_user_id'::text, true))`, + using: sql`(user_id = current_setting('app.current_user_id', true))`, }), pgPolicy("System can insert all accounts", { for: "insert", @@ -144,7 +148,7 @@ export const accountTable = authSchema.table( pgPolicy("Users can delete their own accounts", { for: "delete", to: "app_user", - using: sql`(user_id = current_setting('app.current_user_id'::text, true))`, + using: sql`(user_id = current_setting('app.current_user_id', true))`, }), ] ); @@ -175,9 +179,13 @@ export const verificationTable = authSchema.table( ] ); -export const userRelations = relations(userTable, ({ many }) => ({ +export const userRelations = relations(userTable, ({ one, many }) => ({ sessions: many(sessionTable), accounts: many(accountTable), + + onboarding: one(userOnboardingTable), + candidateProfile: one(candidateProfileTable), + recruiterProfile: one(recruiterProfileTable), })); export const sessionRelations = relations(sessionTable, ({ one }) => ({ diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index eaba21d..9210c85 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1 +1,7 @@ +export * from "./app/candidate_profile_tag.schema.ts"; +export * from "./app/candidate-profile.schema.ts"; +export * from "./app/onboarding.schema.ts"; +export * from "./app/recruiter_profile_tag.schema.ts"; +export * from "./app/recruiter-profile.schema.ts"; +export * from "./app/tag.schema.ts"; export * from "./auth/auth.schema.ts"; diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index ae667c4..ef7aa58 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -2,9 +2,8 @@ import { betterAuth, BetterAuthError } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { getOAuthState } from "better-auth/api"; -import { db } from "@/db/index.js"; -import { accountTable, sessionTable, userTable, verificationTable } from "@/db/schema/index.js"; - +import { db } from "../../db/index.js"; +import { accountTable, sessionTable, userTable, verificationTable } from "../../db/schema/index.js"; import { EmailSignupSchema } from "../validation/email-signup.schema.ts"; import { env } from "../validation/env.schema.ts"; import { OauthSignupSchema } from "../validation/oauth-state.schema.ts"; diff --git a/src/lib/errors/application-error.ts b/src/lib/errors/application-error.ts new file mode 100644 index 0000000..8c4089e --- /dev/null +++ b/src/lib/errors/application-error.ts @@ -0,0 +1,12 @@ +export class ApplicationError extends Error { + statusCode: number; + details: T | undefined; + + constructor(message: string, statusCode = 500, details?: T) { + super(message); + this.statusCode = statusCode; + this.details = details; + + Object.setPrototypeOf(this, ApplicationError.prototype); + } +} diff --git a/src/lib/errors/validation-error.ts b/src/lib/errors/validation-error.ts new file mode 100644 index 0000000..b9d6fe8 --- /dev/null +++ b/src/lib/errors/validation-error.ts @@ -0,0 +1,7 @@ +import { ApplicationError } from "./application-error.ts"; + +export class ValidationError extends ApplicationError> { + constructor(message: string, details?: Record) { + super(message, 400, details); + } +} diff --git a/src/lib/validation/candidate-onboarding.schema.ts b/src/lib/validation/candidate-onboarding.schema.ts new file mode 100644 index 0000000..9c33ac8 --- /dev/null +++ b/src/lib/validation/candidate-onboarding.schema.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +export const candidateOnboardingSchema = z.object({ + // step 1 - basic info + domain: z.string().min(1, "Domain / Industry is required"), + primaryRole: z.string().min(1, "Primary role is required"), + highestEducation: z.string().min(1, "Highest education level is required"), + currentStatus: z.string().min(1, "Current status is required"), + + // step 2 - skills & experience + topSkills: z + .array(z.string().min(1, "Skill is required").max(50, "Skill must be at most 50 characters")) + .min(1, "At least one skill is required"), + yearsOfExperience: z.enum( + ["fresher", "0-1", "1-2", "2-3", "3-5", "5-7", "7-10", "10+"], + "Select years of experience" + ), + professionalBio: z.string().min(20, "Professional bio must be at least 20 characters"), + + // step 3 - location & presence + country: z.string().min(1, "Country is required"), + portfolioUrl: z.url("Invalid portfolio URL").optional().or(z.literal("")), + githubUrl: z.url("Invalid GitHub URL").optional().or(z.literal("")), + linkedinUrl: z.url("Invalid LinkedIn profile URL").optional().or(z.literal("")), +}); + +export type CandidateOnboardingData = z.infer; + +export const candidateOnboardingDraftSchema = candidateOnboardingSchema.partial(); +export type CandidateOnboardingDraftData = z.infer; + +// Mapping from the `yearsOfExperience` enum value to numeric min/max years. +export const YEARS_OF_EXPERIENCE_RANGES = { + fresher: { min: 0, max: 0 }, + "0-1": { min: 0, max: 1 }, + "1-2": { min: 1, max: 2 }, + "2-3": { min: 2, max: 3 }, + "3-5": { min: 3, max: 5 }, + "5-7": { min: 5, max: 7 }, + "7-10": { min: 7, max: 10 }, + "10+": { min: 10, max: null }, +} as const; + +export type YearsOfExperienceEnum = keyof typeof YEARS_OF_EXPERIENCE_RANGES; + +export function mapYearsEnumToRange(key: YearsOfExperienceEnum) { + return YEARS_OF_EXPERIENCE_RANGES[key]; +} diff --git a/src/lib/validation/env.schema.ts b/src/lib/validation/env.schema.ts index 41277db..f5fe240 100644 --- a/src/lib/validation/env.schema.ts +++ b/src/lib/validation/env.schema.ts @@ -15,6 +15,7 @@ export const envSchema = z.object({ GOOGLE_CLIENT_SECRET: z.string().min(1), GITHUB_CLIENT_ID: z.string().min(1), GITHUB_CLIENT_SECRET: z.string().min(1), + ENCRYPTION_KEY: z.string().min(32, "Encryption key must be at least 32 characters"), CURRENT_TERMS_VERSION: z.string().default("1.0.0"), }); diff --git a/src/lib/validation/onboarding-status.schema.ts b/src/lib/validation/onboarding-status.schema.ts new file mode 100644 index 0000000..07ef67f --- /dev/null +++ b/src/lib/validation/onboarding-status.schema.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { + candidateOnboardingDraftSchema, + candidateOnboardingSchema, +} from "./candidate-onboarding.schema.ts"; +import { + recruiterOnboardingDraftSchema, + recruiterOnboardingSchema, +} from "./recruiter-onboarding.schema.ts"; + +export const onboardingUpdateSchema = z + .discriminatedUnion("onboardingType", [ + z.object({ + onboardingType: z.literal("candidate"), + currentStep: z + .number() + .int() + .min(1, "Current step must be at least 1") + .max(3, "Current step cannot be greater than 3"), + isCompleted: z.boolean(), + draft: candidateOnboardingDraftSchema, + }), + z.object({ + onboardingType: z.literal("recruiter"), + currentStep: z + .number() + .int() + .min(1, "Current step must be at least 1") + .max(3, "Current step cannot be greater than 3"), + isCompleted: z.boolean(), + draft: recruiterOnboardingDraftSchema, + }), + ]) + .superRefine((data, ctx) => { + // If onboarding is marked as completed, ensure that the draft data is complete and valid + if (data.isCompleted) { + if (data.onboardingType === "candidate") { + const fullData = candidateOnboardingSchema.safeParse(data.draft); + if (!fullData.success) { + ctx.addIssue({ + code: "custom", + message: "Candidate onboarding is incomplete", + path: ["draft"], + }); + } + } + + if (data.onboardingType === "recruiter") { + const fullData = recruiterOnboardingSchema.safeParse(data.draft); + if (!fullData.success) { + ctx.addIssue({ + code: "custom", + message: "Recruiter onboarding is incomplete", + path: ["draft"], + }); + } + } + } + }); diff --git a/src/lib/validation/recruiter-onboarding.schema.ts b/src/lib/validation/recruiter-onboarding.schema.ts new file mode 100644 index 0000000..438868a --- /dev/null +++ b/src/lib/validation/recruiter-onboarding.schema.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const recruiterOnboardingSchema = z.object({ + // Step 1 + organizationName: z + .string() + .min(1, "Organization name is required") + .min(2, "Organization name must be at least 2 characters"), + organizationSize: z.string().min(1, "Organization size is required"), + industry: z.string().min(1, "Industry is required"), + country: z.string().min(1, "Country is required"), + + // Step 2 + hiringDomains: z.array(z.string()).min(1, "Select at least one domain"), + experienceLevelsHiring: z.array(z.string()).min(1, "Select at least one level"), + companyWebsite: z.url("Invalid website URL").optional().or(z.literal("")), + + // Step 3 - LLM Setup + llmProvider: z.string().min(1, "Select LLM provider"), + llmApiKey: z.string().min(10, "Valid API key required"), + defaultModel: z.string().optional(), +}); + +export type RecruiterOnboardingData = z.infer; + +export const recruiterOnboardingDraftSchema = recruiterOnboardingSchema.partial(); +export type RecruiterOnboardingDraftData = z.infer; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000..36807ed --- /dev/null +++ b/src/middlewares/auth.middleware.ts @@ -0,0 +1,27 @@ +import { fromNodeHeaders } from "better-auth/node"; +import type { NextFunction, Request, Response } from "express"; + +import { auth } from "../lib/auth/auth.js"; + +export const requireAuth = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const authData = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (!authData) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + req.auth = authData; + return next(); + } catch (error) { + console.error("Authentication error:", error); + return next(error); + } +}; diff --git a/src/middlewares/error-handler.middleware.ts b/src/middlewares/error-handler.middleware.ts new file mode 100644 index 0000000..1928e77 --- /dev/null +++ b/src/middlewares/error-handler.middleware.ts @@ -0,0 +1,27 @@ +import type { NextFunction, Request, Response } from "express"; + +import { ApplicationError } from "../lib/errors/application-error.ts"; +import { ValidationError } from "../lib/errors/validation-error.ts"; + +export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) { + // Controlled application errors + if (err instanceof ApplicationError) { + return res.status(err.statusCode).json({ + error: err.message, + details: err.details ?? null, + }); + } + + if (err instanceof ValidationError) { + return res.status(400).json({ + error: err.message, + details: err.details ?? null, + }); + } + + console.error("Unexpected Error:", err); + + return res.status(500).json({ + error: "Internal Server Error", + }); +} diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts new file mode 100644 index 0000000..a10d1a9 --- /dev/null +++ b/src/routes/auth.routes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; + +import { getMe } from "../controllers/auth.controller.ts"; +import { requireAuth } from "../middlewares/auth.middleware.ts"; + +const authRouter = Router(); + +authRouter.get("/me", requireAuth, getMe); + +export default authRouter; diff --git a/src/routes/health.routes.ts b/src/routes/health.routes.ts new file mode 100644 index 0000000..b59b81c --- /dev/null +++ b/src/routes/health.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; + +import { env } from "../lib/validation/env.schema.js"; + +const healthRouter = Router(); + +healthRouter.get("/health", (_req, res) => { + res.status(200).json({ + status: "ok", + uptime: process.uptime(), + timestamp: new Date().toISOString(), + environment: env.NODE_ENV || "development", + }); +}); + +export default healthRouter; diff --git a/src/routes/onboarding.routes.ts b/src/routes/onboarding.routes.ts new file mode 100644 index 0000000..f1c5c86 --- /dev/null +++ b/src/routes/onboarding.routes.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; + +import { getOnboardingStatus, putOnboardingStatus } from "../controllers/onboarding.controller.ts"; +import { requireAuth } from "../middlewares/auth.middleware.ts"; + +const onboardingRouter = Router(); + +onboardingRouter.get("/onboarding", requireAuth, getOnboardingStatus); +onboardingRouter.put("/onboarding", requireAuth, putOnboardingStatus); + +export default onboardingRouter; diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts new file mode 100644 index 0000000..347c0a0 --- /dev/null +++ b/src/types/auth.types.ts @@ -0,0 +1,5 @@ +import type { auth } from "../lib/auth/auth.js"; + +export type AuthSession = typeof auth.$Infer.Session; +export type Session = typeof auth.$Infer.Session.session; +export type User = typeof auth.$Infer.Session.user; diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..483bca5 --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,9 @@ +import "express"; + +import type { AuthSession } from "./auth.types.ts"; + +declare module "express" { + interface Request { + auth?: AuthSession; + } +} diff --git a/src/types/user.d.ts b/src/types/user.d.ts new file mode 100644 index 0000000..c3f4e03 --- /dev/null +++ b/src/types/user.d.ts @@ -0,0 +1 @@ +declare type UserType = "candidate" | "recruiter"; diff --git a/src/utils/get-auth.ts b/src/utils/get-auth.ts new file mode 100644 index 0000000..8ebc550 --- /dev/null +++ b/src/utils/get-auth.ts @@ -0,0 +1,20 @@ +import type { Request } from "express"; + +import type { AuthSession } from "../types/auth.types.ts"; + +export class UnauthorizedError extends Error { + readonly statusCode = 401; + + constructor(message = "Unauthorized") { + super(message); + this.name = "UnauthorizedError"; + Object.setPrototypeOf(this, UnauthorizedError.prototype); + } +} + +export const getAuth = (req: Request): AuthSession => { + if (!req.auth) { + throw new UnauthorizedError("Unauthorized: requireAuth middleware missing"); + } + return req.auth; +}; diff --git a/tsconfig.json b/tsconfig.json index 8b54178..823145d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,11 +27,9 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true, - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - "paths": { - "@/*": ["./src/*"] - } + "moduleResolution": "nodenext", + "allowImportingTsExtensions": false, + "rewriteRelativeImportExtensions": true }, "include": ["src"], "exclude": ["node_modules", "dist", "eslint.config.mts"]