From 56ef1539ae3f1542c479ee47c69849aaf7f6ddb1 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Thu, 6 Aug 2026 17:43:56 +0530 Subject: [PATCH 1/4] fix(experiences): persist interview submissions with review status Form only updated React state, so reload wiped cards despite the moderation success copy. Add a backend resource, save on submit, reload via client key, and surface pending/approved/rejected. Co-authored-by: Cursor --- .../ValidateInterviewExperience.js | 54 +++++ .../interviewExperienceController.js | 161 +++++++++++++++ backend/middlewares/authMiddleware.js | 32 ++- backend/models/InterviewExperience.js | 91 +++++++++ backend/routes/interviewExperienceRoutes.js | 30 +++ backend/server.js | 2 + ...interviewExperienceController.unit.test.js | 184 +++++++++++++++++ .../InterviewExperiences.jsx | 185 ++++++++++++++---- frontend/src/utils/apiPaths.js | 6 + 9 files changed, 707 insertions(+), 38 deletions(-) create mode 100644 backend/Input_validators/ValidateInterviewExperience.js create mode 100644 backend/controllers/interviewExperienceController.js create mode 100644 backend/models/InterviewExperience.js create mode 100644 backend/routes/interviewExperienceRoutes.js create mode 100644 backend/tests/interviewExperienceController.unit.test.js diff --git a/backend/Input_validators/ValidateInterviewExperience.js b/backend/Input_validators/ValidateInterviewExperience.js new file mode 100644 index 00000000..7073f2b6 --- /dev/null +++ b/backend/Input_validators/ValidateInterviewExperience.js @@ -0,0 +1,54 @@ +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(), +}); + +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, +}; diff --git a/backend/controllers/interviewExperienceController.js b/backend/controllers/interviewExperienceController.js new file mode 100644 index 00000000..13bf7c5a --- /dev/null +++ b/backend/controllers/interviewExperienceController.js @@ -0,0 +1,161 @@ +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%)`; + } + + const experience = await InterviewExperience.create(payload); + + return res.status(201).json({ + success: true, + message: "Interview experience submitted for review", + experience: toClientShape(experience), + }); + } catch (error) { + 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, +}; diff --git a/backend/middlewares/authMiddleware.js b/backend/middlewares/authMiddleware.js index 400ea324..f53b8ff2 100644 --- a/backend/middlewares/authMiddleware.js +++ b/backend/middlewares/authMiddleware.js @@ -44,4 +44,34 @@ 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(); +}; + +module.exports = { protect, optionalProtect }; diff --git a/backend/models/InterviewExperience.js b/backend/models/InterviewExperience.js new file mode 100644 index 00000000..56a0092f --- /dev/null +++ b/backend/models/InterviewExperience.js @@ -0,0 +1,91 @@ +const mongoose = require("mongoose"); + +const roundSchema = new mongoose.Schema( + { + name: { type: String, trim: true, default: "Round" }, + type: { type: String, trim: true, default: "Coding" }, + description: { type: String, trim: true, default: "" }, + }, + { _id: false }, +); + +const interviewExperienceSchema = new mongoose.Schema( + { + company: { + type: String, + required: [true, "Company is required"], + trim: true, + maxlength: 120, + }, + role: { + type: String, + required: [true, "Role is required"], + trim: true, + maxlength: 120, + }, + experience: { + type: String, + trim: true, + default: "N/A", + maxlength: 60, + }, + difficulty: { + type: String, + enum: ["Easy", "Medium", "Hard"], + default: "Medium", + }, + offerReceived: { + type: Boolean, + default: false, + }, + date: { + type: String, + trim: true, + default: "", + }, + rounds: { + type: [roundSchema], + default: [], + }, + summary: { + type: String, + required: [true, "Summary is required"], + trim: true, + maxlength: 5000, + }, + tips: { + type: [String], + default: [], + }, + tags: { + type: [String], + default: [], + }, + color: { + type: String, + default: "hsl(260, 55%, 50%)", + }, + status: { + type: String, + enum: ["pending", "approved", "rejected"], + default: "pending", + index: true, + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + index: true, + }, + clientKey: { + type: String, + trim: true, + default: null, + index: true, + maxlength: 64, + }, + }, + { timestamps: true }, +); + +module.exports = mongoose.model("InterviewExperience", interviewExperienceSchema); diff --git a/backend/routes/interviewExperienceRoutes.js b/backend/routes/interviewExperienceRoutes.js new file mode 100644 index 00000000..3ca9fbd1 --- /dev/null +++ b/backend/routes/interviewExperienceRoutes.js @@ -0,0 +1,30 @@ +const express = require("express"); +const router = express.Router(); +const { protect, optionalProtect } = require("../middlewares/authMiddleware"); +const { + validateCreateInterviewExperience, + validateUpdateInterviewExperienceStatus, +} = require("../Input_validators/ValidateInterviewExperience"); +const { + createInterviewExperience, + getApprovedInterviewExperiences, + getMyInterviewExperiences, + updateInterviewExperienceStatus, +} = require("../controllers/interviewExperienceController"); + +router.get("/approved", getApprovedInterviewExperiences); +router.get("/mine", optionalProtect, getMyInterviewExperiences); +router.post( + "/", + optionalProtect, + validateCreateInterviewExperience, + createInterviewExperience, +); +router.patch( + "/:id/status", + protect, + validateUpdateInterviewExperienceStatus, + updateInterviewExperienceStatus, +); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 1a6341b8..756b5559 100644 --- a/backend/server.js +++ b/backend/server.js @@ -146,6 +146,8 @@ const flashcardRoutes = require("./routes/flashcardRoutes"); app.use("/api/flashcards", generalLimiter, flashcardRoutes); const roadmapRoutes = require("./routes/roadmapRoutes"); app.use("/api/roadmaps", roadmapRoutes); +const interviewExperienceRoutes = require("./routes/interviewExperienceRoutes"); +app.use("/api/interview-experiences", generalLimiter, interviewExperienceRoutes); app.use( diff --git a/backend/tests/interviewExperienceController.unit.test.js b/backend/tests/interviewExperienceController.unit.test.js new file mode 100644 index 00000000..92f11103 --- /dev/null +++ b/backend/tests/interviewExperienceController.unit.test.js @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../models/InterviewExperience.js"); + +const InterviewExperience = require("../models/InterviewExperience.js"); +const { + createInterviewExperience, + getApprovedInterviewExperiences, + getMyInterviewExperiences, + updateInterviewExperienceStatus, +} = require("../controllers/interviewExperienceController.js"); + +function makeReq(body = {}, params = {}, query = {}, user = null) { + return { body, params, query, user }; +} + +function makeRes() { + const res = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +const sampleDoc = { + _id: "507f1f77bcf86cd799439011", + company: "Google", + role: "SDE-2", + experience: "3 Years", + difficulty: "Hard", + offerReceived: true, + date: "Aug 2026", + rounds: [{ name: "Round 1", type: "Coding", description: "Arrays" }], + summary: "Tough but fair process", + tips: ["Practice graphs"], + tags: ["Hard", "SDE-2"], + color: "hsl(100, 55%, 50%)", + status: "pending", + createdAt: new Date("2026-08-06T00:00:00.000Z"), + toObject() { + return { ...this }; + }, +}; + +describe("createInterviewExperience", () => { + beforeEach(() => vi.clearAllMocks()); + + it("persists a pending submission and returns the shaped experience", async () => { + InterviewExperience.create = vi.fn().mockResolvedValue(sampleDoc); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Tough but fair process", + clientKey: "client-key-123456", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(InterviewExperience.create).toHaveBeenCalledWith( + expect.objectContaining({ + company: "Google", + status: "pending", + clientKey: "client-key-123456", + }), + ); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + experience: expect.objectContaining({ + id: "507f1f77bcf86cd799439011", + status: "pending", + company: "Google", + }), + }), + ); + }); + + it("returns 500 when persistence fails so the client can retry", async () => { + InterviewExperience.create = vi.fn().mockRejectedValue(new Error("db down")); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Summary", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false }), + ); + }); +}); + +describe("getMyInterviewExperiences", () => { + beforeEach(() => vi.clearAllMocks()); + + it("loads submissions for a clientKey so reloads keep pending cards", async () => { + InterviewExperience.find = vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ ...sampleDoc, toObject: undefined }]), + }), + }); + + const req = makeReq({}, {}, { clientKey: "client-key-123456" }); + const res = makeRes(); + + await getMyInterviewExperiences(req, res); + + expect(InterviewExperience.find).toHaveBeenCalledWith({ + $or: [{ clientKey: "client-key-123456" }], + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json.mock.calls[0][0].experiences[0].status).toBe("pending"); + }); + + it("rejects requests without clientKey or auth", async () => { + const req = makeReq(); + const res = makeRes(); + + await getMyInterviewExperiences(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); +}); + +describe("getApprovedInterviewExperiences", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns only approved experiences", async () => { + InterviewExperience.find = vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { ...sampleDoc, status: "approved", toObject: undefined }, + ]), + }), + }); + + const req = makeReq(); + const res = makeRes(); + + await getApprovedInterviewExperiences(req, res); + + expect(InterviewExperience.find).toHaveBeenCalledWith({ status: "approved" }); + expect(res.json.mock.calls[0][0].experiences[0].status).toBe("approved"); + }); +}); + +describe("updateInterviewExperienceStatus", () => { + beforeEach(() => vi.clearAllMocks()); + + it("approves a pending submission", async () => { + InterviewExperience.findByIdAndUpdate = vi.fn().mockResolvedValue({ + ...sampleDoc, + status: "approved", + }); + + const req = makeReq( + { status: "approved" }, + { id: "507f1f77bcf86cd799439011" }, + ); + const res = makeRes(); + + await updateInterviewExperienceStatus(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json.mock.calls[0][0].experience.status).toBe("approved"); + }); + + it("rejects an unknown id", async () => { + InterviewExperience.findByIdAndUpdate = vi.fn().mockResolvedValue(null); + + const req = makeReq({ status: "rejected" }, { id: "missing" }); + const res = makeRes(); + + await updateInterviewExperienceStatus(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); +}); diff --git a/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx b/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx index 3063275c..b50b32d9 100644 --- a/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx +++ b/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef, useEffect } from "react"; +import React, { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { MessageSquare, Building2, @@ -19,7 +19,27 @@ import { Briefcase, Brain, Send, + AlertCircle, } from "lucide-react"; +import axiosInstance from "../../utils/axiosinstance"; +import { API_PATHS } from "../../utils/apiPaths"; + +const CLIENT_KEY_STORAGE = "preppilot_interview_experience_client_key"; + +const getOrCreateClientKey = () => { + try { + const existing = localStorage.getItem(CLIENT_KEY_STORAGE); + if (existing) return existing; + const created = + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `anon-${Date.now()}-${Math.random().toString(36).slice(2)}`; + localStorage.setItem(CLIENT_KEY_STORAGE, created); + return created; + } catch { + return `anon-${Date.now()}`; + } +}; // ────────────────────────────────────────────── // Static Sample Data @@ -304,6 +324,22 @@ const DifficultyBadge = ({ difficulty }) => { ); }; +const STATUS_CONFIG = { + pending: { label: "Pending review", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" }, + approved: { label: "Approved", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" }, + rejected: { label: "Rejected", color: "text-red-400", bg: "bg-red-500/10 border-red-500/20" }, +}; + +const StatusBadge = ({ status }) => { + if (!status) return null; + const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.pending; + return ( + + {cfg.label} + + ); +}; + const ExperienceCard = ({ exp, onClick }) => (
onClick(exp)} @@ -319,6 +355,7 @@ const ExperienceCard = ({ exp, onClick }) => (

{exp.company}

+

{exp.role}

@@ -332,7 +369,7 @@ const ExperienceCard = ({ exp, onClick }) => ( - {exp.rounds.length} Rounds + {(exp.rounds || []).length} Rounds @@ -347,7 +384,7 @@ const ExperienceCard = ({ exp, onClick }) => ( {/* Tags */}
- {exp.tags.slice(0, 3).map((tag) => ( + {(exp.tags || []).slice(0, 3).map((tag) => ( {tag} @@ -464,47 +501,64 @@ const DetailModal = ({ exp, onClose }) => { // ────────────────────────────────────────────── // Submit Experience Modal // ────────────────────────────────────────────── -const SubmitModal = ({ onClose, onAdd }) => { +const SubmitModal = ({ onClose, onAdd, clientKey }) => { const [form, setForm] = useState({ company: "", role: "", experience: "", difficulty: "Medium", offerReceived: "Yes", rounds: "", summary: "", tips: "", }); const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); const handleChange = (e) => setForm((p) => ({ ...p, [e.target.name]: e.target.value })); - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); setSubmitting(true); - // Simulate brief submission delay for UX feedback - setTimeout(() => { - const newExp = { - id: Date.now(), - company: form.company.trim(), - role: form.role.trim(), - experience: form.experience || "N/A", - difficulty: form.difficulty, - offerReceived: form.offerReceived === "Yes", - date: new Date().toLocaleDateString("en-GB", { month: "short", year: "numeric" }), - rounds: form.rounds - ? form.rounds.split("\n").filter(Boolean).map((r, i) => ({ - name: `Round ${i + 1}`, - type: "Coding", - description: r, - })) - : [], - summary: form.summary.trim(), - tips: form.tips - ? form.tips.split("\n").filter(Boolean) - : [], - tags: [form.difficulty, form.role.split(" ")[0]].filter(Boolean), - color: `hsl(${(form.company.charCodeAt(0) * 37) % 360}, 55%, 50%)`, - }; - onAdd(newExp); - setSubmitting(false); + setSubmitError(""); + + const payload = { + company: form.company.trim(), + role: form.role.trim(), + experience: form.experience.trim() || "N/A", + difficulty: form.difficulty, + offerReceived: form.offerReceived === "Yes", + date: new Date().toLocaleDateString("en-GB", { month: "short", year: "numeric" }), + rounds: form.rounds + ? form.rounds.split("\n").filter(Boolean).map((r, i) => ({ + name: `Round ${i + 1}`, + type: "Coding", + description: r, + })) + : [], + summary: form.summary.trim(), + tips: form.tips + ? form.tips.split("\n").filter(Boolean) + : [], + tags: [form.difficulty, form.role.trim().split(" ")[0]].filter(Boolean), + color: `hsl(${(form.company.trim().charCodeAt(0) * 37) % 360}, 55%, 50%)`, + clientKey, + }; + + try { + const { data } = await axiosInstance.post( + API_PATHS.INTERVIEW_EXPERIENCES.CREATE, + payload, + ); + if (!data?.success || !data?.experience) { + throw new Error(data?.message || "Submission failed"); + } + onAdd(data.experience); setSubmitted(true); - }, 600); + } catch (err) { + setSubmitError( + err?.response?.data?.message || + err?.message || + "Could not save your experience. Please try again.", + ); + } finally { + setSubmitting(false); + } }; const inputCls = @@ -538,7 +592,7 @@ const SubmitModal = ({ onClose, onAdd }) => {

Thank you!

- Your interview experience has been submitted for review. It'll appear on the board once approved. + Your interview experience has been saved and submitted for review. It'll appear on the board once approved.

+ + ) : activeTab === "User" && loadingMine ? ( +
+

Loading your submissions...

+
+ ) : activeTab === "User" && userExperiences.length === 0 ? ( // Empty state specifically for User tab with no submissions yet
@@ -953,7 +1058,13 @@ const InterviewExperiences = () => { {/* ── Modals ── */} {selectedExp && setSelectedExp(null)} />} - {showSubmit && setShowSubmit(false)} onAdd={handleAddExperience} />} + {showSubmit && ( + setShowSubmit(false)} + onAdd={handleAddExperience} + clientKey={clientKey} + /> + )}
); }; diff --git a/frontend/src/utils/apiPaths.js b/frontend/src/utils/apiPaths.js index 924adfd1..9a992970 100644 --- a/frontend/src/utils/apiPaths.js +++ b/frontend/src/utils/apiPaths.js @@ -72,4 +72,10 @@ export const API_PATHS = { TOGGLE_TASK: (id) => `/api/roadmaps/${id}/tasks`, DELETE: (id) => `/api/roadmaps/${id}`, }, + INTERVIEW_EXPERIENCES: { + CREATE: "/api/interview-experiences", + APPROVED: "/api/interview-experiences/approved", + MINE: "/api/interview-experiences/mine", + UPDATE_STATUS: (id) => `/api/interview-experiences/${id}/status`, + }, }; \ No newline at end of file From 5579c8de6fdd38fec57eaf54fe54025368820b30 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Thu, 6 Aug 2026 22:24:55 +0530 Subject: [PATCH 2/4] fix(experiences): address CodeRabbit review on interview submissions Add idempotent creates, restrict status updates to MODERATOR_EMAILS, and load approved experiences into the Common tab. Co-authored-by: Cursor --- .../ValidateInterviewExperience.js | 1 + .../interviewExperienceController.js | 31 ++++++++++ backend/middlewares/authMiddleware.js | 22 ++++++- backend/models/InterviewExperience.js | 17 ++++++ backend/routes/interviewExperienceRoutes.js | 7 ++- ...interviewExperienceController.unit.test.js | 30 ++++++++++ backend/tests/requireModerator.unit.test.js | 59 +++++++++++++++++++ .../InterviewExperiences.jsx | 59 ++++++++++++++++--- 8 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 backend/tests/requireModerator.unit.test.js diff --git a/backend/Input_validators/ValidateInterviewExperience.js b/backend/Input_validators/ValidateInterviewExperience.js index 7073f2b6..1c7dee87 100644 --- a/backend/Input_validators/ValidateInterviewExperience.js +++ b/backend/Input_validators/ValidateInterviewExperience.js @@ -24,6 +24,7 @@ const createInterviewExperienceSchema = z.object({ 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({ diff --git a/backend/controllers/interviewExperienceController.js b/backend/controllers/interviewExperienceController.js index 13bf7c5a..bb40ed05 100644 --- a/backend/controllers/interviewExperienceController.js +++ b/backend/controllers/interviewExperienceController.js @@ -36,6 +36,19 @@ const createInterviewExperience = async (req, res) => { 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); return res.status(201).json({ @@ -44,6 +57,24 @@ const createInterviewExperience = async (req, res) => { 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", diff --git a/backend/middlewares/authMiddleware.js b/backend/middlewares/authMiddleware.js index f53b8ff2..2a5dd4c8 100644 --- a/backend/middlewares/authMiddleware.js +++ b/backend/middlewares/authMiddleware.js @@ -74,4 +74,24 @@ const optionalProtect = async (req, res, next) => { return next(); }; -module.exports = { protect, optionalProtect }; +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 }; diff --git a/backend/models/InterviewExperience.js b/backend/models/InterviewExperience.js index 56a0092f..e4344558 100644 --- a/backend/models/InterviewExperience.js +++ b/backend/models/InterviewExperience.js @@ -84,8 +84,25 @@ const interviewExperienceSchema = new mongoose.Schema( index: true, maxlength: 64, }, + // One key per modal submission so lost-response retries do not duplicate. + idempotencyKey: { + type: String, + trim: true, + default: null, + maxlength: 64, + }, }, { timestamps: true }, ); +interviewExperienceSchema.index( + { idempotencyKey: 1 }, + { + unique: true, + partialFilterExpression: { + idempotencyKey: { $type: "string", $gt: "" }, + }, + }, +); + module.exports = mongoose.model("InterviewExperience", interviewExperienceSchema); diff --git a/backend/routes/interviewExperienceRoutes.js b/backend/routes/interviewExperienceRoutes.js index 3ca9fbd1..02c2157f 100644 --- a/backend/routes/interviewExperienceRoutes.js +++ b/backend/routes/interviewExperienceRoutes.js @@ -1,6 +1,10 @@ const express = require("express"); const router = express.Router(); -const { protect, optionalProtect } = require("../middlewares/authMiddleware"); +const { + protect, + optionalProtect, + requireModerator, +} = require("../middlewares/authMiddleware"); const { validateCreateInterviewExperience, validateUpdateInterviewExperienceStatus, @@ -23,6 +27,7 @@ router.post( router.patch( "/:id/status", protect, + requireModerator, validateUpdateInterviewExperienceStatus, updateInterviewExperienceStatus, ); diff --git a/backend/tests/interviewExperienceController.unit.test.js b/backend/tests/interviewExperienceController.unit.test.js index 92f11103..556fa886 100644 --- a/backend/tests/interviewExperienceController.unit.test.js +++ b/backend/tests/interviewExperienceController.unit.test.js @@ -94,6 +94,36 @@ describe("createInterviewExperience", () => { expect.objectContaining({ success: false }), ); }); + + it("returns the existing submission when the same idempotency key is retried", async () => { + InterviewExperience.findOne = vi.fn().mockResolvedValue(sampleDoc); + InterviewExperience.create = vi.fn(); + + const req = makeReq({ + company: "Google", + role: "SDE-2", + summary: "Tough but fair process", + clientKey: "client-key-123456", + idempotencyKey: "submit-key-abc12345", + }); + const res = makeRes(); + + await createInterviewExperience(req, res); + + expect(InterviewExperience.findOne).toHaveBeenCalledWith({ + idempotencyKey: "submit-key-abc12345", + }); + expect(InterviewExperience.create).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + experience: expect.objectContaining({ + id: "507f1f77bcf86cd799439011", + }), + }), + ); + }); }); describe("getMyInterviewExperiences", () => { diff --git a/backend/tests/requireModerator.unit.test.js b/backend/tests/requireModerator.unit.test.js new file mode 100644 index 00000000..85588489 --- /dev/null +++ b/backend/tests/requireModerator.unit.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { requireModerator } from "../middlewares/authMiddleware.js"; + +function makeRes() { + const res = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +describe("requireModerator", () => { + const previous = process.env.MODERATOR_EMAILS; + + beforeEach(() => { + process.env.MODERATOR_EMAILS = "mods@preppilot.dev, other@example.com"; + }); + + afterEach(() => { + if (previous === undefined) { + delete process.env.MODERATOR_EMAILS; + } else { + process.env.MODERATOR_EMAILS = previous; + } + }); + + it("allows listed moderator emails", () => { + const req = { user: { email: "Mods@PrepPilot.dev" } }; + const res = makeRes(); + const next = vi.fn(); + + requireModerator(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects authenticated non-moderators with 403", () => { + const req = { user: { email: "user@example.com" } }; + const res = makeRes(); + const next = vi.fn(); + + requireModerator(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it("rejects when MODERATOR_EMAILS is unset", () => { + delete process.env.MODERATOR_EMAILS; + const req = { user: { email: "mods@preppilot.dev" } }; + const res = makeRes(); + const next = vi.fn(); + + requireModerator(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); +}); diff --git a/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx b/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx index b50b32d9..5bbfc79f 100644 --- a/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx +++ b/frontend/src/pages/InterviewExperiences/InterviewExperiences.jsx @@ -501,6 +501,11 @@ const DetailModal = ({ exp, onClose }) => { // ────────────────────────────────────────────── // Submit Experience Modal // ────────────────────────────────────────────── +const createIdempotencyKey = () => + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `submit-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const SubmitModal = ({ onClose, onAdd, clientKey }) => { const [form, setForm] = useState({ company: "", role: "", experience: "", difficulty: "Medium", @@ -509,6 +514,8 @@ const SubmitModal = ({ onClose, onAdd, clientKey }) => { const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(""); + // Stable for the life of this modal so retries do not create duplicates. + const idempotencyKeyRef = useRef(createIdempotencyKey()); const handleChange = (e) => setForm((p) => ({ ...p, [e.target.name]: e.target.value })); @@ -538,6 +545,7 @@ const SubmitModal = ({ onClose, onAdd, clientKey }) => { tags: [form.difficulty, form.role.trim().split(" ")[0]].filter(Boolean), color: `hsl(${(form.company.trim().charCodeAt(0) * 37) % 360}, 55%, 50%)`, clientKey, + idempotencyKey: idempotencyKeyRef.current, }; try { @@ -797,7 +805,6 @@ const CompanyDropdown = ({ value, onChange, options }) => { // Main Page // ────────────────────────────────────────────── const FILTERS = ["All", "Easy", "Medium", "Hard"]; -const COMPANIES = ["All Companies", ...Array.from(new Set(EXPERIENCES.map((e) => e.company))).sort((a, b) => a - b)]; const InterviewExperiences = () => { const [selectedExp, setSelectedExp] = useState(null); @@ -807,6 +814,7 @@ const InterviewExperiences = () => { const [search, setSearch] = useState(""); const [activeTab, setActiveTab] = useState("Common"); // "Common" | "User" const [userExperiences, setUserExperiences] = useState([]); + const [approvedExperiences, setApprovedExperiences] = useState([]); const [loadError, setLoadError] = useState(""); const [loadingMine, setLoadingMine] = useState(true); const clientKey = useMemo(() => getOrCreateClientKey(), []); @@ -830,17 +838,52 @@ const InterviewExperiences = () => { } }, [clientKey]); + const loadApprovedExperiences = useCallback(async () => { + try { + const { data } = await axiosInstance.get( + API_PATHS.INTERVIEW_EXPERIENCES.APPROVED, + ); + setApprovedExperiences(data?.experiences || []); + } catch { + // Keep static samples if the approved feed is unreachable. + setApprovedExperiences([]); + } + }, []); + useEffect(() => { loadMyExperiences(); }, [loadMyExperiences]); + useEffect(() => { + loadApprovedExperiences(); + }, [loadApprovedExperiences]); + const handleAddExperience = (exp) => { setUserExperiences((prev) => [exp, ...prev.filter((item) => item.id !== exp.id)]); setActiveTab("User"); }; + // Approved API results first; static samples fill gaps until moderation has content. + const commonExperiences = useMemo(() => { + const approvedIds = new Set(approvedExperiences.map((e) => String(e.id))); + return [ + ...approvedExperiences, + ...EXPERIENCES.filter((e) => !approvedIds.has(String(e.id))), + ]; + }, [approvedExperiences]); + + const companies = useMemo( + () => [ + "All Companies", + ...Array.from(new Set(commonExperiences.map((e) => e.company))).sort((a, b) => + a.localeCompare(b), + ), + ], + [commonExperiences], + ); + // Source array based on active tab - const sourceData = activeTab === "Common" ? EXPERIENCES : userExperiences; + const sourceData = activeTab === "Common" ? commonExperiences : userExperiences; const filtered = useMemo(() => { return sourceData.filter((e) => { @@ -857,10 +900,10 @@ const InterviewExperiences = () => { }, [sourceData, diffFilter, companyFilter, search]); const stats = useMemo(() => ({ - total: EXPERIENCES.length, - withOffer: EXPERIENCES.filter((e) => e.offerReceived).length, - companies: new Set(EXPERIENCES.map((e) => e.company)).size, - }), []); + total: commonExperiences.length, + withOffer: commonExperiences.filter((e) => e.offerReceived).length, + companies: new Set(commonExperiences.map((e) => e.company)).size, + }), [commonExperiences]); return (
@@ -947,7 +990,7 @@ const InterviewExperiences = () => {
@@ -956,7 +999,7 @@ const InterviewExperiences = () => { {/* Tab toggle pill */}
{["Common", "User"].map((tab) => { - const count = tab === "Common" ? EXPERIENCES.length : userExperiences.length; + const count = tab === "Common" ? commonExperiences.length : userExperiences.length; const isActive = activeTab === tab; return (