Skip to content
Open
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
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ GITHUB_TOKEN=github_pat_your_token_here
EMAIL_SERVICE=gmail

# Gmail: use an App Password (Google Account → Security → 2-Step Verification → App Passwords)

# Google Calendar OAuth (optional; enables interview prep calendar sync)
# GOOGLE_CLIENT_ID=your-google-client-id
# GOOGLE_CLIENT_SECRET=your-google-client-secret
# GOOGLE_REDIRECT_URI=http://localhost:8000/api/google-calendar/callback
# Ethereal: use credentials from https://ethereal.email
EMAIL_USER=your_email@gmail.com
EMAIL_PASS=xxxx xxxx xxxx xxxx
Expand Down
4 changes: 4 additions & 0 deletions backend/config/validateEnv.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const optionalEnvGroups = Object.freeze([
feature: "Jobs for You",
keys: ["ADZUNA_APP_ID", "ADZUNA_API_KEY"],
},
{
feature: "Google Calendar integration",
keys: ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REDIRECT_URI"],
},
]);

const validateEnv = () => {
Expand Down
99 changes: 99 additions & 0 deletions backend/controllers/googleCalendarController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const User = require("../models/User");
const {
getGoogleCalendarAuthUrl,
exchangeGoogleCalendarCode,
createOrUpdateGoogleCalendarEvent,
getGoogleCalendarConnectionStatus,
encryptSecret,
} = require("../services/googleCalendarService.js");

const connectGoogleCalendar = async (req, res) => {
try {
const { authUrl } = getGoogleCalendarAuthUrl(req.user?._id?.toString() || "anonymous");
return res.status(200).json({ success: true, authUrl });
} catch (error) {
console.error("Google Calendar connect error:", error.message);
return res.status(500).json({ success: false, message: error.message || "Unable to connect Google Calendar" });
}
};

const handleGoogleCalendarCallback = async (req, res) => {
try {
const { code, state, error } = req.query;

if (error) {
return res.status(400).json({ success: false, message: "Google authorization failed" });
}

if (!code || !state) {
return res.status(400).json({ success: false, message: "Invalid Google callback payload" });
}

const [userId] = state.split(":");
const tokens = await exchangeGoogleCalendarCode(code);

const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ success: false, message: "User not found" });
}

user.googleCalendarConnected = true;
user.googleCalendarEmail = user.email;
user.googleCalendarConnectedAt = new Date();
user.googleCalendarAccessToken = encryptSecret(tokens.accessToken);
user.googleCalendarRefreshToken = encryptSecret(tokens.refreshToken);
user.googleCalendarTokenExpiry = tokens.expiryDate ? new Date(tokens.expiryDate) : null;
await user.save();

return res.status(200).json({ success: true, message: "Google Calendar connected successfully" });
} catch (error) {
console.error("Google Calendar callback error:", error.message);
return res.status(500).json({ success: false, message: error.message || "Unable to complete Google Calendar connection" });
}
};

const getGoogleCalendarStatus = async (req, res) => {
try {
const status = await getGoogleCalendarConnectionStatus(req.user);
return res.status(200).json({ success: true, ...status });
} catch (error) {
console.error("Google Calendar status error:", error.message);
return res.status(500).json({ success: false, message: error.message || "Unable to fetch Google Calendar status" });
}
};

const syncGoogleCalendarEvent = async (req, res) => {
try {
const { title, description, startTime, endTime, reminderMinutes, eventId } = req.body || {};

if (!title || !startTime || !endTime) {
return res.status(400).json({ success: false, message: "title, startTime, and endTime are required" });
}

const result = await createOrUpdateGoogleCalendarEvent(req.user, {
title,
description,
startTime,
endTime,
reminderMinutes,
eventId,
});

await User.findByIdAndUpdate(req.user._id, {
googleCalendarLastSyncedAt: new Date(),
});

return res.status(200).json({ success: true, event: result });
} catch (error) {
console.error("Google Calendar sync error:", error.message);
const statusCode = error.message === "expired" ? 401 : 500;
return res.status(statusCode).json({ success: false, message: error.message === "expired" ? "Google OAuth token has expired. Please reconnect Google Calendar." : error.message || "Unable to sync Google Calendar event" });
}
};

module.exports = {
connectGoogleCalendar,
handleGoogleCalendarCallback,
getGoogleCalendarStatus,
syncGoogleCalendarEvent,
};
39 changes: 38 additions & 1 deletion backend/controllers/sessionController.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const Session = require("../models/Session");
const Question = require("../models/Question");
const User = require("../models/User");
const mongoose = require("mongoose");

const { getInterviewStreakUpdate } = require("../utils/interviewStreak");

const MAX_SESSIONS = Number(process.env.MAX_SESSIONS) || 50;
const MAX_EXPERIENCE = 50;
Expand Down Expand Up @@ -92,9 +93,38 @@ await createdSession[0].save({
session: mongoSession,
});

const user = await User.findById(userId).session(mongoSession);
if (!user) {
throw new Error("USER_NOT_FOUND");
}

const practiceDate = new Date().toISOString().split("T")[0];
const streakUpdate = getInterviewStreakUpdate(
{
interviewStreak: Number(user.interviewStreak || 0),
longestInterviewStreak: Number(user.longestInterviewStreak || 0),
interviewStreakBadges: Array.isArray(user.interviewStreakBadges) ? user.interviewStreakBadges : [],
interviewStreakLastPracticeDate: user.interviewStreakLastPracticeDate,
},
practiceDate
);

if (streakUpdate.changed) {
user.interviewStreak = Math.max(0, streakUpdate.streak);
user.longestInterviewStreak = Math.max(0, streakUpdate.longestStreak);
user.interviewStreakBadges = streakUpdate.badges;
user.interviewStreakLastPracticeDate = practiceDate;
await user.save({ session: mongoSession });
}

res.status(201).json({
success: true,
session: createdSession[0],
streakSummary: {
current: user.interviewStreak,
longest: user.longestInterviewStreak,
badges: user.interviewStreakBadges,
},
});
});
} catch (err) {
Expand All @@ -105,6 +135,13 @@ await createdSession[0].save({
});
}

if (err.message === "USER_NOT_FOUND") {
return res.status(404).json({
success: false,
message: "User not found.",
});
}

console.error("Create session error:", err);
return res.status(500).json({
success: false,
Expand Down
14 changes: 14 additions & 0 deletions backend/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ const UserSchema = new mongoose.Schema(
},

unlockedAchievements: { type: [String], default: [] },
interviewStreak: { type: Number, default: 0, min: 0 },
longestInterviewStreak: { type: Number, default: 0, min: 0 },
interviewStreakBadges: { type: [String], default: [] },
interviewStreakLastPracticeDate: { type: String, default: null },

googleCalendarConnected: { type: Boolean, default: false },
googleCalendarEmail: { type: String, default: null },
googleCalendarConnectedAt: { type: Date, default: null },
googleCalendarAccessToken: { type: String, default: null },
googleCalendarRefreshToken: { type: String, default: null },
googleCalendarTokenExpiry: { type: Date, default: null },
googleCalendarLastSyncedAt: { type: Date, default: null },

//Email Verification
isEmailVerified: { type: Boolean, default: false },
Expand Down Expand Up @@ -90,6 +102,8 @@ UserSchema.set("toJSON", {
delete ret.emailVerificationToken;
delete ret.emailVerificationExpires;
delete ret.tokenVersion;
delete ret.googleCalendarAccessToken;
delete ret.googleCalendarRefreshToken;
return ret;
},
});
Expand Down
Loading
Loading