Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
55 changes: 55 additions & 0 deletions backend/Input_validators/ValidateInterviewExperience.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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(),
idempotencyKey: z.string().trim().min(8).max(64).optional().nullable(),
});

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,
};
192 changes: 192 additions & 0 deletions backend/controllers/interviewExperienceController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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" };
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%)`;
}

if (payload.idempotencyKey) {
const existing = await InterviewExperience.findOne({
idempotencyKey: payload.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