diff --git a/src/app.ts b/src/app.ts index 72321de..064fb01 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,7 +18,7 @@ const app = express(); app.use( cors({ origin: env.FRONTEND_URL, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"], credentials: true, }) diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index f5fe240..8213b01 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -16,6 +16,7 @@ export const envSchema = z.object({ 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"), + SECRET_KEY: z.string().min(32, "Secret key must be at least 32 characters"), CURRENT_TERMS_VERSION: z.string().default("1.0.0"), }); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d696bfa..ac9969f 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,14 +1,33 @@ import type { Request, Response } from "express"; +import { env } from "../../config/env.schema.ts"; +import { parseUserRole } from "../../shared/constants/user-role.ts"; import { getAuth } from "../../utils/get-auth.ts"; +import { createMetaCookie } from "../../utils/meta-cookie.ts"; +import { OnboardingService } from "../onboarding/onboarding.service.ts"; -export const getMe = (req: Request, res: Response) => { +export const getMe = async (req: Request, res: Response) => { const { user } = getAuth(req); + const userRole = parseUserRole(user.role); + const onboardingService = new OnboardingService(user.id, userRole); + const onboardingStatus = await onboardingService.getStatus(); + const oc = onboardingStatus.status === "completed"; + + const meta = createMetaCookie({ r: userRole, oc }); + res.cookie("app_meta", meta, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days + path: "/", + }); + return res.status(200).json({ id: user.id, email: user.email, - role: user.role, name: user.name, + role: user.role, + onboardingStatus: onboardingStatus.status, }); }; diff --git a/src/modules/onboarding/onboarding-status-update.schema.ts b/src/modules/onboarding/onboarding-status-update.schema.ts index ea12244..5ab850e 100644 --- a/src/modules/onboarding/onboarding-status-update.schema.ts +++ b/src/modules/onboarding/onboarding-status-update.schema.ts @@ -11,7 +11,6 @@ export const onboardingStatusUpdateSchema = z.discriminatedUnion("onboardingType .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, }), @@ -22,7 +21,6 @@ export const onboardingStatusUpdateSchema = z.discriminatedUnion("onboardingType .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, }), diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index a5e4251..8325262 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -1,16 +1,11 @@ import { type NextFunction, type Request, type Response } from "express"; -import { USER_ROLES, type UserRole } from "../../shared/constants/user-role.ts"; +import { env } from "../../config/env.schema.ts"; +import { parseUserRole } from "../../shared/constants/user-role.ts"; import { getAuth } from "../../utils/get-auth.ts"; +import { createMetaCookie } from "../../utils/meta-cookie.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; @@ -19,6 +14,17 @@ export const getOnboardingStatus = async (req: Request, res: Response, next: Nex const service = new OnboardingService(user.id, userRole); const result = await service.getStatus(); + // update signed meta cookie so frontend has latest role + onboarding flag + const oc = result.status === "completed"; + const meta = createMetaCookie({ r: userRole, oc }); + res.cookie("app_meta", meta, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 1000 * 60 * 60 * 24 * 30, + path: "/", + }); + return res.json(result); } catch (error) { return next(error); @@ -48,7 +54,22 @@ export const completeOnboarding = async (req: Request, res: Response, next: Next const service = new OnboardingService(user.id, userRole); await service.initializeIfNotExists(); - const result = await service.completeOnboarding(req.body.draft, req.body.currentStep); + + const result = await service.completeOnboarding( + req.body.onboardingData, + req.body.currentStep, + req.body.onboardingType + ); + + // set updated meta cookie (frontend can read onboarding complete immediately) + const meta = createMetaCookie({ r: userRole, oc: result.isCompleted === true }); + res.cookie("app_meta", meta, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 1000 * 60 * 60 * 24 * 30, + path: "/", + }); return res.json(result); } catch (error) { diff --git a/src/modules/onboarding/onboarding.service.ts b/src/modules/onboarding/onboarding.service.ts index 7d72213..2b65627 100644 --- a/src/modules/onboarding/onboarding.service.ts +++ b/src/modules/onboarding/onboarding.service.ts @@ -304,9 +304,11 @@ export class OnboardingService { }); } - async updateStatus( - body: Omit - ): Promise { + async updateStatus(body: OnboardingStatusUpdateData): Promise { + if (body.onboardingType !== this.role) { + throw new ApplicationError("Onboarding type does not match user role"); + } + const parsed = onboardingStatusUpdateSchema.parse({ ...body, onboardingType: this.role, @@ -333,17 +335,26 @@ export class OnboardingService { } async completeOnboarding( - draft: unknown, - currentStep: number + onboardingData: unknown, + currentStep: number, + onboardingType: UserRole = this.role ): Promise { + if (currentStep < 1 || currentStep > 3) { + throw new ApplicationError("Invalid current step"); + } + + if (onboardingType !== this.role) { + throw new ApplicationError("Onboarding type does not match user role"); + } + switch (this.role) { case "candidate": { - const parsedDraft = candidateOnboardingSchema.parse(draft); + const parsedDraft = candidateOnboardingSchema.parse(onboardingData); await this.completeCandidateOnboarding(parsedDraft, currentStep); break; } case "recruiter": { - const parsedDraft = recruiterOnboardingSchema.parse(draft); + const parsedDraft = recruiterOnboardingSchema.parse(onboardingData); await this.completeRecruiterOnboarding(parsedDraft, currentStep); break; } diff --git a/src/shared/constants/user-role.ts b/src/shared/constants/user-role.ts index f72cf86..ecc101a 100644 --- a/src/shared/constants/user-role.ts +++ b/src/shared/constants/user-role.ts @@ -1,3 +1,10 @@ export const USER_ROLES = ["candidate", "recruiter"] as const; export type UserRole = (typeof USER_ROLES)[number]; + +export function parseUserRole(role: string): UserRole { + if (USER_ROLES.includes(role as UserRole)) { + return role as UserRole; + } + throw new Error("Invalid user role"); +} diff --git a/src/types/app-meta.ts b/src/types/app-meta.ts new file mode 100644 index 0000000..a05a743 --- /dev/null +++ b/src/types/app-meta.ts @@ -0,0 +1,6 @@ +import type { UserRole } from "../shared/constants/user-role.ts"; + +export interface AppMeta { + r: UserRole; // User role (candidate or recruiter) + oc: boolean; // Onboarding complete flag +} diff --git a/src/utils/meta-cookie.ts b/src/utils/meta-cookie.ts new file mode 100644 index 0000000..882fd0f --- /dev/null +++ b/src/utils/meta-cookie.ts @@ -0,0 +1,14 @@ +import crypto from "crypto"; + +import { env } from "../config/env.schema.ts"; +import type { AppMeta } from "../types/app-meta.ts"; + +function sign(value: string) { + return crypto.createHmac("sha256", env.SECRET_KEY).update(value).digest("base64url"); +} + +export function createMetaCookie(data: AppMeta) { + const payload = Buffer.from(JSON.stringify(data)).toString("base64url"); + const signature = sign(payload); + return `${payload}.${signature}`; +}