Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
1 change: 1 addition & 0 deletions src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});

Expand Down
23 changes: 21 additions & 2 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
2 changes: 0 additions & 2 deletions src/modules/onboarding/onboarding-status-update.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand All @@ -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,
}),
Expand Down
39 changes: 30 additions & 9 deletions src/modules/onboarding/onboarding.controller.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 18 additions & 7 deletions src/modules/onboarding/onboarding.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,11 @@ export class OnboardingService {
});
}

async updateStatus(
body: Omit<OnboardingStatusUpdateData, "onboardingType">
): Promise<OnboardingUpdateResponse> {
async updateStatus(body: OnboardingStatusUpdateData): Promise<OnboardingUpdateResponse> {
if (body.onboardingType !== this.role) {
throw new ApplicationError("Onboarding type does not match user role");
}

const parsed = onboardingStatusUpdateSchema.parse({
...body,
onboardingType: this.role,
Expand All @@ -333,17 +335,26 @@ export class OnboardingService {
}

async completeOnboarding(
draft: unknown,
currentStep: number
onboardingData: unknown,
currentStep: number,
onboardingType: UserRole = this.role
): Promise<OnboardingCompleteResponse> {
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;
}
Expand Down
7 changes: 7 additions & 0 deletions src/shared/constants/user-role.ts
Original file line number Diff line number Diff line change
@@ -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");
}
6 changes: 6 additions & 0 deletions src/types/app-meta.ts
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions src/utils/meta-cookie.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}