Skip to content
Closed
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
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ MONGO_URI=mongodb://localhost:27017/interview_prep_ai
# JWT Configuration
# Secret key for signing JWT tokens (use a strong random string in production)
JWT_SECRET=your_jwt_secret_key_here_change_me

# Comma-separated emails allowed to moderate interview experiences
# MODERATOR_EMAILS=admin@example.com,moderator@example.com
# CORS Configuration
# Primary frontend origin for production
FRONTEND_ORIGIN=https://preppilot-12n6.onrender.com
Expand Down
60 changes: 60 additions & 0 deletions backend/Input_validators/ValidateInterviewExperience.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { z } = require("zod");
const { handleValidationError } = require("./ValidateQuestions");

const createInterviewExperienceSchema = z.object({
company: z.string().trim().min(1, "Company is required").max(120),
role: z.string().trim().min(1, "Role is required").max(120),
experience: z.string().trim().max(60).optional().default("N/A"),
difficulty: z.enum(["Easy", "Medium", "Hard"]).optional().default("Medium"),
offerReceived: z.boolean().optional().default(false),
date: z.string().trim().max(40).optional().default(""),
rounds: z
.array(
z.object({
name: z.string().trim().max(120).optional().default("Round"),
type: z.string().trim().max(60).optional().default("Coding"),
description: z.string().trim().max(2000).optional().default(""),
}),
)
.max(20)
.optional()
.default([]),
summary: z.string().trim().min(1, "Summary is required").max(5000),
tips: z.array(z.string().trim().max(500)).max(20).optional().default([]),
tags: z.array(z.string().trim().max(40)).max(10).optional().default([]),
color: z.string().trim().max(40).optional(),
clientKey: z.string().trim().min(8).max(64).optional().nullable(),
// Required so keyless retries cannot bypass the unique partial index.
idempotencyKey: z
.string()
.trim()
.min(8, "idempotencyKey is required")
.max(64),
});

const updateStatusSchema = z.object({
status: z.enum(["pending", "approved", "rejected"]),
});

const validateCreateInterviewExperience = (req, res, next) => {
try {
req.body = createInterviewExperienceSchema.parse(req.body);
next();
} catch (error) {
return handleValidationError(res, error);
}
};

const validateUpdateInterviewExperienceStatus = (req, res, next) => {
try {
req.body = updateStatusSchema.parse(req.body);
next();
} catch (error) {
return handleValidationError(res, error);
}
};

module.exports = {
validateCreateInterviewExperience,
validateUpdateInterviewExperienceStatus,
};
202 changes: 202 additions & 0 deletions backend/controllers/interviewExperienceController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
const InterviewExperience = require("../models/InterviewExperience");

const toClientShape = (doc) => {
const obj = typeof doc.toObject === "function" ? doc.toObject() : doc;
return {
id: String(obj._id),
company: obj.company,
role: obj.role,
experience: obj.experience,
difficulty: obj.difficulty,
offerReceived: obj.offerReceived,
date: obj.date,
rounds: obj.rounds || [],
summary: obj.summary,
tips: obj.tips || [],
tags: obj.tags || [],
color: obj.color,
status: obj.status,
createdAt: obj.createdAt,
};
};

/**
* @desc Submit an interview experience for moderation
* @route POST /api/interview-experiences
* @access Public (optional auth)
*/
const createInterviewExperience = async (req, res) => {
try {
const payload = { ...req.body, status: "pending" };
const idempotencyKey =
typeof payload.idempotencyKey === "string"
? payload.idempotencyKey.trim()
: "";

if (!idempotencyKey) {
return res.status(400).json({
success: false,
message: "idempotencyKey is required",
});
}

payload.idempotencyKey = idempotencyKey;

if (req.user?._id) {
payload.userId = req.user._id;
}

if (!payload.color && payload.company) {
payload.color = `hsl(${(payload.company.charCodeAt(0) * 37) % 360}, 55%, 50%)`;
}

const existing = await InterviewExperience.findOne({ idempotencyKey });
if (existing) {
return res.status(200).json({
success: true,
message: "Interview experience already submitted",
experience: toClientShape(existing),
});
}

const experience = await InterviewExperience.create(payload);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return res.status(201).json({
success: true,
message: "Interview experience submitted for review",
experience: toClientShape(experience),
});
} catch (error) {
// Concurrent retry won the unique index race — return the first write.
if (error?.code === 11000 && req.body?.idempotencyKey) {
try {
const existing = await InterviewExperience.findOne({
idempotencyKey: req.body.idempotencyKey,
});
Comment thread
KaranUnique marked this conversation as resolved.
Dismissed
if (existing) {
return res.status(200).json({
success: true,
message: "Interview experience already submitted",
experience: toClientShape(existing),
});
}
} catch {
// fall through to generic 500
}
}

return res.status(500).json({
success: false,
message: "Failed to submit interview experience",
error: "A server error occurred",
});
}
};

/**
* @desc List approved community experiences
* @route GET /api/interview-experiences/approved
* @access Public
*/
const getApprovedInterviewExperiences = async (req, res) => {
try {
const experiences = await InterviewExperience.find({ status: "approved" })
.sort({ createdAt: -1 })
.lean();

return res.status(200).json({
success: true,
experiences: experiences.map(toClientShape),
});
} catch (error) {
return res.status(500).json({
success: false,
message: "Failed to load approved experiences",
error: "A server error occurred",
});
}
};

/**
* @desc Load a visitor's own submissions by client key and/or auth
* @route GET /api/interview-experiences/mine
* @access Public (clientKey query) / Private
*/
const getMyInterviewExperiences = async (req, res) => {
try {
const clientKey =
typeof req.query.clientKey === "string" ? req.query.clientKey.trim() : "";
const filters = [];

if (req.user?._id) {
filters.push({ userId: req.user._id });
}
if (clientKey) {
filters.push({ clientKey });
}

if (filters.length === 0) {
return res.status(400).json({
success: false,
message: "Provide clientKey or authenticate to load your submissions",
});
}

const experiences = await InterviewExperience.find({ $or: filters })
.sort({ createdAt: -1 })
.lean();

return res.status(200).json({
success: true,
experiences: experiences.map(toClientShape),
});
} catch (error) {
return res.status(500).json({
success: false,
message: "Failed to load your submissions",
error: "A server error occurred",
});
}
};

/**
* @desc Update moderation status
* @route PATCH /api/interview-experiences/:id/status
* @access Private
*/
const updateInterviewExperienceStatus = async (req, res) => {
try {
const experience = await InterviewExperience.findByIdAndUpdate(
req.params.id,
{ status: req.body.status },
{ new: true },
);

if (!experience) {
return res.status(404).json({
success: false,
message: "Interview experience not found",
});
}

return res.status(200).json({
success: true,
message: `Experience marked as ${experience.status}`,
experience: toClientShape(experience),
});
} catch (error) {
return res.status(500).json({
success: false,
message: "Failed to update experience status",
error: "A server error occurred",
});
}
};

module.exports = {
createInterviewExperience,
getApprovedInterviewExperiences,
getMyInterviewExperiences,
updateInterviewExperienceStatus,
toClientShape,
};
52 changes: 51 additions & 1 deletion backend/middlewares/authMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,54 @@ const protect = async (req, res, next) => {
}
};

module.exports = { protect };
// Attach req.user when a valid token is present; continue anonymously otherwise.
const optionalProtect = async (req, res, next) => {
try {
let token = req.headers.authorization;

if (!token || !token.toLowerCase().startsWith("bearer")) {
return next();
}

token = token.split(" ")[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);

if (decoded.tokenType && decoded.tokenType !== "access") {
return next();
}

const user = await User.findById(decoded.id).select("-password");
if (
user &&
(decoded.tokenVersion ?? 0) === (user.tokenVersion ?? 0)
) {
req.user = user;
}
} catch (error) {
// Ignore invalid tokens for optional auth paths.
}

return next();
};

const parseModeratorEmails = () =>
String(process.env.MODERATOR_EMAILS || "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);

// Restrict moderation endpoints to emails listed in MODERATOR_EMAILS.
const requireModerator = (req, res, next) => {
const email = req.user?.email?.trim().toLowerCase();
const moderators = parseModeratorEmails();

if (!email || moderators.length === 0 || !moderators.includes(email)) {
return res.status(403).json({
message: "Not authorized — moderator access required",
});
}

return next();
};

module.exports = { protect, optionalProtect, requireModerator };
Loading
Loading