diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c35e038 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.organizeImports": "never", + "source.fixAll.eslint": "always" + }, + "editor.formatOnSave": true +} diff --git a/src/app.ts b/src/app.ts index e3209b4..72321de 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,14 +4,14 @@ import cors from "cors"; import express from "express"; import helmet from "helmet"; +import { env } from "./config/env.schema.ts"; 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"; +import authRouter from "./modules/auth/auth.routes.ts"; +import healthRouter from "./modules/health/health.routes.ts"; +import onboardingRouter from "./modules/onboarding/onboarding.routes.ts"; +import { auth } from "./shared/auth/auth.ts"; +import { errorHandler } from "./shared/middlewares/error-handler.middleware.ts"; const app = express(); diff --git a/src/lib/validation/env.schema.ts b/src/config/env.schema.ts similarity index 100% rename from src/lib/validation/env.schema.ts rename to src/config/env.schema.ts diff --git a/src/controllers/onboarding.controller.ts b/src/controllers/onboarding.controller.ts deleted file mode 100644 index 8589c0a..0000000 --- a/src/controllers/onboarding.controller.ts +++ /dev/null @@ -1,301 +0,0 @@ -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 index 66f149e..0cc7e1e 100644 --- a/src/db/create-user-context.ts +++ b/src/db/create-user-context.ts @@ -2,7 +2,7 @@ import { sql } from "drizzle-orm"; import { db } from "./index.js"; -type TransactionType = Parameters[0]>[0]; +export type TransactionType = Parameters[0]>[0]; export const createUserContext = (userId: string) => { return { diff --git a/src/db/index.ts b/src/db/index.ts index bffd5db..9fd1d3e 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,7 +1,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import { env } from "../lib/validation/env.schema.ts"; +import { env } from "../config/env.schema.ts"; import * as schema from "./schema/index.ts"; const client = postgres(env.DATABASE_APP_USER_URL, { diff --git a/src/db/schema/app/candidate_profile_tag.schema.ts b/src/db/schema/app/candidate-profile-tag.schema.ts similarity index 100% rename from src/db/schema/app/candidate_profile_tag.schema.ts rename to src/db/schema/app/candidate-profile-tag.schema.ts diff --git a/src/db/schema/app/candidate-profile.schema.ts b/src/db/schema/app/candidate-profile.schema.ts index 8da6538..e802cb2 100644 --- a/src/db/schema/app/candidate-profile.schema.ts +++ b/src/db/schema/app/candidate-profile.schema.ts @@ -2,7 +2,7 @@ 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"; +import { candidateProfileTagTable } from "./candidate-profile-tag.schema.ts"; export const candidateProfileTable = pgTable( "candidate_profile", diff --git a/src/db/schema/app/onboarding.schema.ts b/src/db/schema/app/onboarding.schema.ts index afe8524..30f2029 100644 --- a/src/db/schema/app/onboarding.schema.ts +++ b/src/db/schema/app/onboarding.schema.ts @@ -10,7 +10,8 @@ import { timestamp, } from "drizzle-orm/pg-core"; -import type { CandidateOnboardingDraftData } from "../../../lib/validation/candidate-onboarding.schema.ts"; +import type { CandidateOnboardingDraftData } from "../../../modules/onboarding/candidate-onboarding.schema.ts"; +import type { RecruiterOnboardingDraftData } from "../../../modules/onboarding/recruiter-onboarding.schema.ts"; import { userTable } from "../auth/auth.schema.ts"; export const userOnboardingTable = pgTable( @@ -23,7 +24,9 @@ export const userOnboardingTable = pgTable( isCompleted: boolean("is_completed").default(false).notNull(), currentStep: integer("current_step").default(1).notNull(), - draft: jsonb("draft").$type(), + draft: jsonb("draft").$type< + CandidateOnboardingDraftData | RecruiterOnboardingDraftData | null + >(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) diff --git a/src/db/schema/app/recruiter_profile_tag.schema.ts b/src/db/schema/app/recruiter-profile-tag.schema.ts similarity index 100% rename from src/db/schema/app/recruiter_profile_tag.schema.ts rename to src/db/schema/app/recruiter-profile-tag.schema.ts diff --git a/src/db/schema/app/recruiter-profile.schema.ts b/src/db/schema/app/recruiter-profile.schema.ts index dd8b934..0916641 100644 --- a/src/db/schema/app/recruiter-profile.schema.ts +++ b/src/db/schema/app/recruiter-profile.schema.ts @@ -2,7 +2,7 @@ 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"; +import { recruiterProfileTagTable } from "./recruiter-profile-tag.schema.ts"; export const recruiterProfileTable = pgTable( "recruiter_profile", diff --git a/src/db/schema/app/tag.schema.ts b/src/db/schema/app/tag.schema.ts index 30d6543..0a0fa18 100644 --- a/src/db/schema/app/tag.schema.ts +++ b/src/db/schema/app/tag.schema.ts @@ -1,8 +1,8 @@ -import { relations, sql } from "drizzle-orm"; -import { index, integer, pgPolicy, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; -import { candidateProfileTagTable } from "./candidate_profile_tag.schema.ts"; -import { recruiterProfileTagTable } from "./recruiter_profile_tag.schema.ts"; +import { candidateProfileTagTable } from "./candidate-profile-tag.schema.ts"; +import { recruiterProfileTagTable } from "./recruiter-profile-tag.schema.ts"; export const tagTable = pgTable( "tag", @@ -18,24 +18,7 @@ export const tagTable = pgTable( .$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')`, - }), - ] + (table) => [index("tag_name_idx").on(table.name), index("tag_type_idx").on(table.type)] ); export const tagRelations = relations(tagTable, ({ many }) => ({ diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 9210c85..5397699 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,7 +1,7 @@ -export * from "./app/candidate_profile_tag.schema.ts"; export * from "./app/candidate-profile.schema.ts"; +export * from "./app/candidate-profile-tag.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/recruiter-profile-tag.schema.ts"; export * from "./app/tag.schema.ts"; export * from "./auth/auth.schema.ts"; diff --git a/src/lib/validation/onboarding-status.schema.ts b/src/lib/validation/onboarding-status.schema.ts deleted file mode 100644 index 07ef67f..0000000 --- a/src/lib/validation/onboarding-status.schema.ts +++ /dev/null @@ -1,60 +0,0 @@ -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/controllers/auth.controller.ts b/src/modules/auth/auth.controller.ts similarity index 83% rename from src/controllers/auth.controller.ts rename to src/modules/auth/auth.controller.ts index c6197a4..d696bfa 100644 --- a/src/controllers/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,6 +1,6 @@ import type { Request, Response } from "express"; -import { getAuth } from "../utils/get-auth.ts"; +import { getAuth } from "../../utils/get-auth.ts"; export const getMe = (req: Request, res: Response) => { const { user } = getAuth(req); diff --git a/src/routes/auth.routes.ts b/src/modules/auth/auth.routes.ts similarity index 52% rename from src/routes/auth.routes.ts rename to src/modules/auth/auth.routes.ts index a10d1a9..3d782e3 100644 --- a/src/routes/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; -import { getMe } from "../controllers/auth.controller.ts"; -import { requireAuth } from "../middlewares/auth.middleware.ts"; +import { requireAuth } from "../../shared/middlewares/auth.middleware.ts"; +import { getMe } from "./auth.controller.ts"; const authRouter = Router(); diff --git a/src/types/auth.types.ts b/src/modules/auth/auth.types.ts similarity index 75% rename from src/types/auth.types.ts rename to src/modules/auth/auth.types.ts index 347c0a0..055dbe6 100644 --- a/src/types/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -1,4 +1,4 @@ -import type { auth } from "../lib/auth/auth.js"; +import type { auth } from "../../shared/auth/auth.ts"; export type AuthSession = typeof auth.$Infer.Session; export type Session = typeof auth.$Infer.Session.session; diff --git a/src/lib/validation/email-signup.schema.ts b/src/modules/auth/email-signup.schema.ts similarity index 94% rename from src/lib/validation/email-signup.schema.ts rename to src/modules/auth/email-signup.schema.ts index f878965..3299327 100644 --- a/src/lib/validation/email-signup.schema.ts +++ b/src/modules/auth/email-signup.schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { USER_ROLES } from "../constants/user-role.ts"; +import { USER_ROLES } from "../../shared/constants/user-role.ts"; export const EmailSignupSchema = z .object({ diff --git a/src/lib/validation/oauth-state.schema.ts b/src/modules/auth/oauth-state.schema.ts similarity index 84% rename from src/lib/validation/oauth-state.schema.ts rename to src/modules/auth/oauth-state.schema.ts index 643728d..a882025 100644 --- a/src/lib/validation/oauth-state.schema.ts +++ b/src/modules/auth/oauth-state.schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { USER_ROLES } from "../constants/user-role.ts"; +import { USER_ROLES } from "../../shared/constants/user-role.ts"; /* * OAuth state passed via Better Auth `additionalData` diff --git a/src/routes/health.routes.ts b/src/modules/health/health.routes.ts similarity index 85% rename from src/routes/health.routes.ts rename to src/modules/health/health.routes.ts index b59b81c..687571d 100644 --- a/src/routes/health.routes.ts +++ b/src/modules/health/health.routes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import { env } from "../lib/validation/env.schema.js"; +import { env } from "../../config/env.schema.ts"; const healthRouter = Router(); diff --git a/src/lib/validation/candidate-onboarding.schema.ts b/src/modules/onboarding/candidate-onboarding.schema.ts similarity index 100% rename from src/lib/validation/candidate-onboarding.schema.ts rename to src/modules/onboarding/candidate-onboarding.schema.ts diff --git a/src/modules/onboarding/onboarding-status-update.schema.ts b/src/modules/onboarding/onboarding-status-update.schema.ts new file mode 100644 index 0000000..ea12244 --- /dev/null +++ b/src/modules/onboarding/onboarding-status-update.schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { candidateOnboardingDraftSchema } from "./candidate-onboarding.schema.ts"; +import { recruiterOnboardingDraftSchema } from "./recruiter-onboarding.schema.ts"; + +export const onboardingStatusUpdateSchema = 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"), + // PATCH must not mark onboarding completed — completion goes through POST /onboarding/complete + isCompleted: z.literal(false), + 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"), + // PATCH must not mark onboarding completed — completion goes through POST /onboarding/complete + isCompleted: z.literal(false), + draft: recruiterOnboardingDraftSchema, + }), +]); + +export type OnboardingStatusUpdateData = z.infer; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..a5e4251 --- /dev/null +++ b/src/modules/onboarding/onboarding.controller.ts @@ -0,0 +1,57 @@ +import { type NextFunction, type Request, type Response } from "express"; + +import { USER_ROLES, type UserRole } from "../../shared/constants/user-role.ts"; +import { getAuth } from "../../utils/get-auth.ts"; +import { OnboardingService } from "./onboarding.service.ts"; + +const parseUserRole = (role: string): UserRole => { + if (USER_ROLES.includes(role as UserRole)) { + return role as UserRole; + } + throw new Error("Invalid user role"); +}; + +export const getOnboardingStatus = async (req: Request, res: Response, next: NextFunction) => { + try { + const user = getAuth(req).user; + const userRole = parseUserRole(user.role); + + const service = new OnboardingService(user.id, userRole); + const result = await service.getStatus(); + + return res.json(result); + } catch (error) { + return next(error); + } +}; + +export const patchOnboardingStatus = async (req: Request, res: Response, next: NextFunction) => { + try { + const user = getAuth(req).user; + const userRole = parseUserRole(user.role); + + const service = new OnboardingService(user.id, userRole); + await service.initializeIfNotExists(); + + const result = await service.updateStatus(req.body); + + return res.json(result); + } catch (error) { + return next(error); + } +}; + +export const completeOnboarding = async (req: Request, res: Response, next: NextFunction) => { + try { + const user = getAuth(req).user; + const userRole = parseUserRole(user.role); + + const service = new OnboardingService(user.id, userRole); + await service.initializeIfNotExists(); + const result = await service.completeOnboarding(req.body.draft, req.body.currentStep); + + return res.json(result); + } catch (error) { + return next(error); + } +}; diff --git a/src/modules/onboarding/onboarding.routes.ts b/src/modules/onboarding/onboarding.routes.ts new file mode 100644 index 0000000..648f201 --- /dev/null +++ b/src/modules/onboarding/onboarding.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; + +import { requireAuth } from "../../shared/middlewares/auth.middleware.ts"; +import { + completeOnboarding, + getOnboardingStatus, + patchOnboardingStatus, +} from "./onboarding.controller.ts"; + +const onboardingRouter = Router(); + +onboardingRouter.get("/onboarding", requireAuth, getOnboardingStatus); +onboardingRouter.patch("/onboarding", requireAuth, patchOnboardingStatus); +onboardingRouter.post("/onboarding/complete", requireAuth, completeOnboarding); + +export default onboardingRouter; diff --git a/src/modules/onboarding/onboarding.service.ts b/src/modules/onboarding/onboarding.service.ts new file mode 100644 index 0000000..7d72213 --- /dev/null +++ b/src/modules/onboarding/onboarding.service.ts @@ -0,0 +1,360 @@ +import CryptoJS from "crypto-js"; +import { and, eq, inArray, or } from "drizzle-orm"; + +import { env } from "../../config/env.schema.ts"; +import { createUserContext, type TransactionType } from "../../db/create-user-context.ts"; +import { + candidateProfileTable, + candidateProfileTagTable, + recruiterProfileTable, + recruiterProfileTagTable, + tagTable, + userOnboardingTable, +} from "../../db/schema/index.ts"; +import type { UserRole } from "../../shared/constants/user-role.ts"; +import { ApplicationError } from "../../shared/errors/application-error.ts"; +import { + type CandidateOnboardingData, + type CandidateOnboardingDraftData, + candidateOnboardingDraftSchema, + candidateOnboardingSchema, + mapYearsEnumToRange, +} from "./candidate-onboarding.schema.ts"; +import type { + OnboardingCompleteResponse, + OnboardingInitializeResponse, + OnboardingStatusResponse, + OnboardingUpdateResponse, +} from "./onboarding.type.ts"; +import { + type OnboardingStatusUpdateData, + onboardingStatusUpdateSchema, +} from "./onboarding-status-update.schema.ts"; +import { + type RecruiterOnboardingData, + type RecruiterOnboardingDraftData, + recruiterOnboardingDraftSchema, + recruiterOnboardingSchema, +} from "./recruiter-onboarding.schema.ts"; + +export class OnboardingService { + constructor( + private userId: string, + private role: UserRole + ) {} + + private get userCtx() { + return createUserContext(this.userId); + } + + private parseDraft( + role: UserRole, + draft: unknown + ): CandidateOnboardingDraftData | RecruiterOnboardingDraftData | null { + if (!draft) return null; + + if (role === "candidate") { + const parsed = candidateOnboardingDraftSchema.safeParse(draft); + return parsed.success ? parsed.data : null; + } + + if (role === "recruiter") { + const parsed = recruiterOnboardingDraftSchema.safeParse(draft); + return parsed.success ? parsed.data : null; + } + + return null; + } + + async getStatus(): Promise { + const onboardingData = await this.userCtx.withRls((tx) => { + return tx.query.userOnboardingTable.findFirst({ + where: eq(userOnboardingTable.userId, this.userId), + }); + }); + + if (!onboardingData) { + return { + status: "not_started", + onboardingType: this.role, + currentStep: null, + draft: null, + }; + } + + if (!onboardingData.isCompleted) { + switch (this.role) { + case "candidate": + return { + status: "in_progress", + onboardingType: "candidate", + currentStep: onboardingData.currentStep, + draft: this.parseDraft(this.role, onboardingData.draft), + }; + + case "recruiter": + return { + status: "in_progress", + onboardingType: "recruiter", + currentStep: onboardingData.currentStep, + draft: this.parseDraft(this.role, onboardingData.draft), + }; + + default: + break; + } + } + + return { + status: "completed", + onboardingType: this.role, + currentStep: null, + draft: null, + }; + } + + async initializeIfNotExists(): Promise { + const existing = await this.userCtx.withRls((tx) => { + return tx.query.userOnboardingTable.findFirst({ + where: eq(userOnboardingTable.userId, this.userId), + }); + }); + + if (existing) { + return null; + } + + const [newRecord] = await this.userCtx.withRls((tx) => { + return tx + .insert(userOnboardingTable) + .values({ + userId: this.userId, + currentStep: 1, + isCompleted: false, + draft: null, + }) + .returning({ + currentStep: userOnboardingTable.currentStep, + isCompleted: userOnboardingTable.isCompleted, + }); + }); + + if (!newRecord) { + throw new ApplicationError("Failed to initialize onboarding"); + } + + return { + type: "initialized", + currentStep: newRecord.currentStep, + isCompleted: newRecord.isCompleted, + }; + } + + private async attachCandidateTags(tx: TransactionType, skills: string[]): Promise { + if (skills.length === 0) return; + + // Insert missing skills into tag table (if not exists) + await tx + .insert(tagTable) + .values( + skills.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, skills)), + }); + + // Insert new skill tags for the candidate profile + if (tagRows.length > 0) { + await tx.insert(candidateProfileTagTable).values( + tagRows.map((tag) => ({ + candidateUserId: this.userId, + tagId: tag.id, + })) + ); + } + } + + async completeCandidateOnboarding( + data: CandidateOnboardingData, + currentStep: number + ): Promise { + const { min: minYears, max: maxYears } = mapYearsEnumToRange(data.yearsOfExperience); + + await this.userCtx.withRls(async (tx) => { + const existing = await tx.query.candidateProfileTable.findFirst({ + where: eq(candidateProfileTable.userId, this.userId), + }); + + if (existing) { + throw new ApplicationError("Candidate profile already exists"); + } + + // Upsert candidate profile data + await tx.insert(candidateProfileTable).values({ + userId: this.userId, + 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, + }); + + // Attach skill tags to candidate profile + await this.attachCandidateTags(tx, data.topSkills); + + // Mark onboarding as completed and clear the draft data + await tx + .update(userOnboardingTable) + .set({ + isCompleted: true, + currentStep: currentStep, + draft: null, + }) + .where(eq(userOnboardingTable.userId, this.userId)); + }); + } + + private async attachRecruiterTags( + tx: TransactionType, + domains: string[], + experienceLevels: string[] + ): Promise { + if (domains.length === 0 && experienceLevels.length === 0) return; + + // Insert missing domain + experience_level tags into tag table (if not exists) + await tx + .insert(tagTable) + .values([ + ...domains.map((domain) => ({ name: domain, type: "domain" })), + ...experienceLevels.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, domains)), + and(eq(tagTable.type, "experience_level"), inArray(tagTable.name, experienceLevels)) + ), + }); + + // Insert new tags for the recruiter profile + if (tagRows.length > 0) { + await tx.insert(recruiterProfileTagTable).values( + tagRows.map((tag) => ({ + recruiterUserId: this.userId, + tagId: tag.id, + })) + ); + } + } + + async completeRecruiterOnboarding( + data: RecruiterOnboardingData, + currentStep: number + ): Promise { + await this.userCtx.withRls(async (tx) => { + const existing = await tx.query.recruiterProfileTable.findFirst({ + where: eq(recruiterProfileTable.userId, this.userId), + }); + + if (existing) { + throw new ApplicationError("Recruiter profile already exists"); + } + + await tx.insert(recruiterProfileTable).values({ + userId: this.userId, + 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, + }); + + // Attach domain + experience_level tags to recruiter profile + await this.attachRecruiterTags(tx, data.hiringDomains, data.experienceLevelsHiring); + + // Mark onboarding as completed and clear the draft data + await tx + .update(userOnboardingTable) + .set({ + isCompleted: true, + currentStep, + draft: null, + }) + .where(eq(userOnboardingTable.userId, this.userId)); + }); + } + + async updateStatus( + body: Omit + ): Promise { + const parsed = onboardingStatusUpdateSchema.parse({ + ...body, + onboardingType: this.role, + }); + + const { currentStep, isCompleted, draft } = parsed; + + await this.userCtx.withRls((tx) => { + return tx + .update(userOnboardingTable) + .set({ + currentStep, + draft, + isCompleted, + }) + .where(eq(userOnboardingTable.userId, this.userId)); + }); + + return { + type: "draft_saved", + currentStep, + isCompleted: false, + }; + } + + async completeOnboarding( + draft: unknown, + currentStep: number + ): Promise { + switch (this.role) { + case "candidate": { + const parsedDraft = candidateOnboardingSchema.parse(draft); + await this.completeCandidateOnboarding(parsedDraft, currentStep); + break; + } + case "recruiter": { + const parsedDraft = recruiterOnboardingSchema.parse(draft); + await this.completeRecruiterOnboarding(parsedDraft, currentStep); + break; + } + default: + throw new ApplicationError("Invalid user role for onboarding"); + } + + return { + type: "completed", + currentStep, + isCompleted: true, + }; + } +} diff --git a/src/modules/onboarding/onboarding.type.ts b/src/modules/onboarding/onboarding.type.ts new file mode 100644 index 0000000..5d7fdc5 --- /dev/null +++ b/src/modules/onboarding/onboarding.type.ts @@ -0,0 +1,47 @@ +import type { UserRole } from "../../shared/constants/user-role.ts"; +import type { CandidateOnboardingDraftData } from "./candidate-onboarding.schema.ts"; +import type { RecruiterOnboardingDraftData } from "./recruiter-onboarding.schema.ts"; + +export type OnboardingStatusResponse = + | { + status: "not_started"; + onboardingType: UserRole; + currentStep: null; + draft: null; + } + | { + status: "completed"; + onboardingType: UserRole; + currentStep: null; + draft: null; + } + | { + status: "in_progress"; + onboardingType: "candidate"; + currentStep: number; + draft: CandidateOnboardingDraftData | null; + } + | { + status: "in_progress"; + onboardingType: "recruiter"; + currentStep: number; + draft: RecruiterOnboardingDraftData | null; + }; + +export type OnboardingInitializeResponse = { + type: "initialized"; + currentStep: number; + isCompleted: boolean; +}; + +export type OnboardingUpdateResponse = { + type: "draft_saved"; + currentStep: number; + isCompleted: false; +}; + +export type OnboardingCompleteResponse = { + type: "completed"; + currentStep: number; + isCompleted: true; +}; diff --git a/src/lib/validation/recruiter-onboarding.schema.ts b/src/modules/onboarding/recruiter-onboarding.schema.ts similarity index 100% rename from src/lib/validation/recruiter-onboarding.schema.ts rename to src/modules/onboarding/recruiter-onboarding.schema.ts diff --git a/src/routes/onboarding.routes.ts b/src/routes/onboarding.routes.ts deleted file mode 100644 index f1c5c86..0000000 --- a/src/routes/onboarding.routes.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/server.ts b/src/server.ts index f22b6fa..f2a74ff 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import http from "http"; import app from "./app.js"; -import { env } from "./lib/validation/env.schema.js"; +import { env } from "./config/env.schema.ts"; const server = http.createServer(app); diff --git a/src/lib/auth/applySignupMetadata.ts b/src/shared/auth/applySignupMetadata.ts similarity index 95% rename from src/lib/auth/applySignupMetadata.ts rename to src/shared/auth/applySignupMetadata.ts index c34c433..be78b8e 100644 --- a/src/lib/auth/applySignupMetadata.ts +++ b/src/shared/auth/applySignupMetadata.ts @@ -1,7 +1,7 @@ import { BetterAuthError } from "better-auth"; import { z } from "zod"; -import { env } from "../validation/env.schema.ts"; +import { env } from "../../config/env.schema.ts"; type SignupMetadata = { role: string; diff --git a/src/lib/auth/auth.ts b/src/shared/auth/auth.ts similarity index 91% rename from src/lib/auth/auth.ts rename to src/shared/auth/auth.ts index ef7aa58..860312a 100644 --- a/src/lib/auth/auth.ts +++ b/src/shared/auth/auth.ts @@ -2,11 +2,11 @@ import { betterAuth, BetterAuthError } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { getOAuthState } from "better-auth/api"; +import { env } from "../../config/env.schema.ts"; 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"; +import { EmailSignupSchema } from "../../modules/auth/email-signup.schema.ts"; +import { OauthSignupSchema } from "../../modules/auth/oauth-state.schema.ts"; import { validateAndApplyUserMetadata } from "./applySignupMetadata.ts"; export const auth = betterAuth({ diff --git a/src/lib/constants/user-role.ts b/src/shared/constants/user-role.ts similarity index 100% rename from src/lib/constants/user-role.ts rename to src/shared/constants/user-role.ts diff --git a/src/lib/errors/application-error.ts b/src/shared/errors/application-error.ts similarity index 100% rename from src/lib/errors/application-error.ts rename to src/shared/errors/application-error.ts diff --git a/src/lib/errors/validation-error.ts b/src/shared/errors/validation-error.ts similarity index 100% rename from src/lib/errors/validation-error.ts rename to src/shared/errors/validation-error.ts diff --git a/src/middlewares/auth.middleware.ts b/src/shared/middlewares/auth.middleware.ts similarity index 93% rename from src/middlewares/auth.middleware.ts rename to src/shared/middlewares/auth.middleware.ts index 36807ed..6254581 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/shared/middlewares/auth.middleware.ts @@ -1,7 +1,7 @@ import { fromNodeHeaders } from "better-auth/node"; import type { NextFunction, Request, Response } from "express"; -import { auth } from "../lib/auth/auth.js"; +import { auth } from "../auth/auth.ts"; export const requireAuth = async ( req: Request, diff --git a/src/middlewares/error-handler.middleware.ts b/src/shared/middlewares/error-handler.middleware.ts similarity index 59% rename from src/middlewares/error-handler.middleware.ts rename to src/shared/middlewares/error-handler.middleware.ts index 1928e77..6883581 100644 --- a/src/middlewares/error-handler.middleware.ts +++ b/src/shared/middlewares/error-handler.middleware.ts @@ -1,10 +1,10 @@ import type { NextFunction, Request, Response } from "express"; +import { ZodError } from "zod"; -import { ApplicationError } from "../lib/errors/application-error.ts"; -import { ValidationError } from "../lib/errors/validation-error.ts"; +import { ApplicationError } from "../errors/application-error.ts"; +import { ValidationError } from "../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, @@ -19,6 +19,16 @@ export function errorHandler(err: unknown, req: Request, res: Response, _next: N }); } + if (err instanceof ZodError) { + return res.status(400).json({ + error: "Invalid request data", + details: err.issues.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + })), + }); + } + console.error("Unexpected Error:", err); return res.status(500).json({ diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 483bca5..3c91c8b 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,6 +1,6 @@ import "express"; -import type { AuthSession } from "./auth.types.ts"; +import type { AuthSession } from "../modules/auth/auth.types.ts"; declare module "express" { interface Request { diff --git a/src/utils/get-auth.ts b/src/utils/get-auth.ts index 8ebc550..20b08bf 100644 --- a/src/utils/get-auth.ts +++ b/src/utils/get-auth.ts @@ -1,6 +1,6 @@ import type { Request } from "express"; -import type { AuthSession } from "../types/auth.types.ts"; +import type { AuthSession } from "../modules/auth/auth.types.ts"; export class UnauthorizedError extends Error { readonly statusCode = 401;