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
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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
68 changes: 68 additions & 0 deletions backend/Input_validators/ValidateInterviewExperience.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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()
.regex(
/^([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i,
"Invalid clientKey format",
)
.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,
};
241 changes: 241 additions & 0 deletions backend/controllers/interviewExperienceController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
const mongoose = require("mongoose");
const InterviewExperience = require("../models/InterviewExperience");

const ALLOWED_STATUSES = ["pending", "approved", "rejected"];
const SECURE_CLIENT_KEY =
/^([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;

const isSecureClientKey = (value) =>
typeof value === "string" && SECURE_CLIENT_KEY.test(value);

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;
} else if (!isSecureClientKey(payload.clientKey)) {
return res.status(400).json({
success: false,
message: "clientKey is required for anonymous submissions",
});
}

if (payload.clientKey && !isSecureClientKey(payload.clientKey)) {
return res.status(400).json({
success: false,
message: "Invalid clientKey format",
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);

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,
});
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 rawClientKey =
typeof req.query.clientKey === "string" ? req.query.clientKey.trim() : "";
const clientKey = isSecureClientKey(rawClientKey) ? rawClientKey : "";
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 { id } = req.params;
if (!mongoose.isValidObjectId(id)) {
return res.status(400).json({
success: false,
message: "Invalid experience id",
});
}

// Resolve against a constant allowlist so the update document is never
// built directly from the request body (CodeQL js/nosql-injection).
const status = ALLOWED_STATUSES.find((value) => value === req.body?.status);
if (!status) {
return res.status(400).json({
success: false,
message: "status must be pending, approved, or rejected",
});
}

const experience = await InterviewExperience.findByIdAndUpdate(
id,
{ 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 @@ -64,4 +64,54 @@ const requireAdmin = (req, res, next) => {
next();
};

module.exports = { protect, requireAdmin };
// 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, requireAdmin, optionalProtect, requireModerator };
Loading
Loading