From fbc3157e6066c6bb2c8c75701026c54dc77e8b98 Mon Sep 17 00:00:00 2001 From: Suprita Naik Date: Sun, 30 Aug 2026 21:20:26 +0530 Subject: [PATCH] fix: implement Google Calendar backend routes --- .../controllers/googleCalendarController.js | 250 ++++++++++++++++ backend/models/User.js | 11 + backend/routes/googleCalendarRoutes.js | 39 +-- backend/server.js | 14 +- .../googleCalendarController.unit.test.js | 267 ++++++++++++++++++ 5 files changed, 542 insertions(+), 39 deletions(-) create mode 100644 backend/controllers/googleCalendarController.js create mode 100644 backend/tests/googleCalendarController.unit.test.js diff --git a/backend/controllers/googleCalendarController.js b/backend/controllers/googleCalendarController.js new file mode 100644 index 00000000..373c2a20 --- /dev/null +++ b/backend/controllers/googleCalendarController.js @@ -0,0 +1,250 @@ +const { google } = require("googleapis"); +const jwt = require("jsonwebtoken"); +const User = require("../models/User"); + +const getOAuth2Client = () => { + const redirectUri = + process.env.GOOGLE_REDIRECT_URI || + "http://localhost:5000/api/google-calendar/callback"; + + return new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + redirectUri, + ); +}; + +/** + * @desc Generate Google OAuth URL for calendar connection + * @route GET /api/google-calendar/connect + * @access Private + */ +const connectCalendar = async (req, res) => { + try { + if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) { + return res.status(500).json({ + success: false, + message: "Google OAuth is not configured on the server", + }); + } + + const oauth2Client = getOAuth2Client(); + + // Create a signed JWT state containing the user ID to prevent CSRF in OAuth callback + const stateToken = jwt.sign( + { userId: req.user._id.toString(), action: "google_calendar_connect" }, + process.env.JWT_SECRET, + { expiresIn: "15m" }, + ); + + const authUrl = oauth2Client.generateAuthUrl({ + access_type: "offline", + prompt: "consent", + scope: [ + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/userinfo.email", + ], + state: stateToken, + }); + + return res.status(200).json({ + success: true, + authUrl, + }); + } catch (error) { + console.error("Connect Google Calendar error:", error); + return res.status(500).json({ + success: false, + message: "Failed to initiate Google Calendar connection", + }); + } +}; + +/** + * @desc OAuth callback route executed by Google redirect + * @route GET /api/google-calendar/callback + * @access Public (verifies signed state token) + */ +const handleCallback = async (req, res) => { + const frontendOrigin = + process.env.FRONTEND_ORIGIN?.trim() || "http://localhost:5173"; + + try { + const { code, state, error: oauthError } = req.query; + + if (oauthError || !code || !state) { + return res.redirect( + `${frontendOrigin}/interview-prep?error=calendar_connection_failed`, + ); + } + + let decoded; + try { + decoded = jwt.verify(state, process.env.JWT_SECRET); + } catch { + return res.redirect( + `${frontendOrigin}/interview-prep?error=invalid_state`, + ); + } + + if (!decoded?.userId) { + return res.redirect( + `${frontendOrigin}/interview-prep?error=invalid_user`, + ); + } + + const user = await User.findById(decoded.userId); + if (!user) { + return res.redirect( + `${frontendOrigin}/interview-prep?error=user_not_found`, + ); + } + + const oauth2Client = getOAuth2Client(); + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + let email = user.email; + try { + const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client }); + const userInfo = await oauth2.userinfo.get(); + if (userInfo.data?.email) { + email = userInfo.data.email; + } + } catch (err) { + console.warn("Could not retrieve Google user email:", err.message); + } + + const refreshToken = + tokens.refresh_token || user.googleCalendar?.refreshToken || null; + + user.googleCalendar = { + connected: true, + refreshToken, + email, + connectedAt: new Date(), + }; + + await user.save(); + + return res.redirect( + `${frontendOrigin}/interview-prep?google_calendar=connected`, + ); + } catch (error) { + console.error("Google Calendar callback error:", error); + return res.redirect( + `${frontendOrigin}/interview-prep?error=calendar_connection_error`, + ); + } +}; + +/** + * @desc Get Google Calendar connection status for the authenticated user + * @route GET /api/google-calendar/status + * @access Private + */ +const getCalendarStatus = async (req, res) => { + try { + const isConnected = !!( + req.user.googleCalendar?.connected && + req.user.googleCalendar?.refreshToken + ); + + return res.status(200).json({ + success: true, + connected: isConnected, + email: isConnected ? req.user.googleCalendar?.email || req.user.email : null, + }); + } catch (error) { + console.error("Get calendar status error:", error); + return res.status(500).json({ + success: false, + message: "Failed to load Google Calendar status", + }); + } +}; + +/** + * @desc Create/sync events to the authenticated user's Google Calendar + * @route POST /api/google-calendar/events & POST /api/google-calendar/sync-events + * @access Private + */ +const syncCalendarEvents = async (req, res) => { + try { + const user = req.user; + if (!user.googleCalendar?.connected || !user.googleCalendar?.refreshToken) { + return res.status(400).json({ + success: false, + message: "Google Calendar is not connected. Please connect your account first.", + }); + } + + const oauth2Client = getOAuth2Client(); + oauth2Client.setCredentials({ + refresh_token: user.googleCalendar.refreshToken, + }); + + const calendar = google.calendar({ version: "v3", auth: oauth2Client }); + + const payload = req.body || {}; + const eventsList = Array.isArray(payload.events) + ? payload.events + : [payload]; + + const createdEvents = []; + + for (const evt of eventsList) { + const summary = evt.title || evt.summary || "Interview Practice Session"; + const description = + evt.description || "PrepPilot interview practice session"; + + const startDateTime = evt.startTime || evt.start || new Date().toISOString(); + const endDateTime = + evt.endTime || + evt.end || + new Date(new Date(startDateTime).getTime() + 60 * 60 * 1000).toISOString(); + + const reminderMinutes = + typeof evt.reminderMinutes === "number" ? evt.reminderMinutes : 15; + + const eventResource = { + summary, + description, + start: { dateTime: new Date(startDateTime).toISOString() }, + end: { dateTime: new Date(endDateTime).toISOString() }, + reminders: { + useDefault: false, + overrides: [{ method: "popup", minutes: reminderMinutes }], + }, + }; + + const response = await calendar.events.insert({ + calendarId: "primary", + requestBody: eventResource, + }); + + createdEvents.push(response.data); + } + + return res.status(200).json({ + success: true, + message: "Google Calendar event synced", + count: createdEvents.length, + eventId: createdEvents[0]?.id || null, + }); + } catch (error) { + console.error("Sync Google Calendar events error:", error?.message || error); + return res.status(500).json({ + success: false, + message: "Failed to sync Google Calendar event", + error: "A server error occurred", + }); + } +}; + +module.exports = { + connectCalendar, + handleCallback, + getCalendarStatus, + syncCalendarEvents, +}; diff --git a/backend/models/User.js b/backend/models/User.js index cab7b717..99866034 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -63,6 +63,14 @@ const UserSchema = new mongoose.Schema( isEmailVerified: { type: Boolean, default: false }, emailVerificationToken: { type: String, default: null }, emailVerificationExpires: { type: Date, default: null }, + + // Google Calendar Connection + googleCalendar: { + connected: { type: Boolean, default: false }, + refreshToken: { type: String, default: null }, + email: { type: String, default: null }, + connectedAt: { type: Date, default: null }, + }, }, { timestamps: true } ); @@ -95,6 +103,9 @@ UserSchema.set("toJSON", { delete ret.emailVerificationToken; delete ret.emailVerificationExpires; delete ret.tokenVersion; + if (ret.googleCalendar) { + delete ret.googleCalendar.refreshToken; + } return ret; }, }); diff --git a/backend/routes/googleCalendarRoutes.js b/backend/routes/googleCalendarRoutes.js index 1305821c..1df893d0 100644 --- a/backend/routes/googleCalendarRoutes.js +++ b/backend/routes/googleCalendarRoutes.js @@ -1,40 +1,27 @@ const express = require("express"); const router = express.Router(); +const { protect } = require("../middlewares/authMiddleware"); +const { + connectCalendar, + handleCallback, + getCalendarStatus, + syncCalendarEvents, +} = require("../controllers/googleCalendarController"); // GET /api/google-calendar/connect -router.get("/connect", (req, res) => { - res.json({ - success: true, - message: "Google Calendar connect route is working", - }); -}); +router.get("/connect", protect, connectCalendar); // GET /api/google-calendar/callback -router.get("/callback", (req, res) => { - res.json({ - success: true, - message: "Google Calendar callback route is working", - }); -}); +router.get("/callback", handleCallback); // GET /api/google-calendar/status -router.get("/status", (req, res) => { - res.json({ - success: true, - connected: false, - }); -}); +router.get("/status", protect, getCalendarStatus); // POST /api/google-calendar/events -router.post("/events", (req, res) => { - const { events } = req.body; +router.post("/events", protect, syncCalendarEvents); - res.json({ - success: true, - message: "Calendar events received successfully", - count: Array.isArray(events) ? events.length : 0, - }); -}); +// POST /api/google-calendar/sync-events +router.post("/sync-events", protect, syncCalendarEvents); module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 96db4300..1d31d28a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -218,18 +218,6 @@ process.on("unhandledRejection", (reason, promise) => { process.exit(1); }); -app.use("/api/books", generalLimiter, booksRoutes); -app.use("/api/jobs", jobRoutes); - -app.use("/api/google-calendar", googleCalendarRoutes); - - -app.use( - "/api/google-calendar", - generalLimiter, - googleCalendarRoutes -); - - +app.use("/api/google-calendar", generalLimiter, googleCalendarRoutes); app.use("/api/courses", generalLimiter, coursesRoutes); diff --git a/backend/tests/googleCalendarController.unit.test.js b/backend/tests/googleCalendarController.unit.test.js new file mode 100644 index 00000000..6c859546 --- /dev/null +++ b/backend/tests/googleCalendarController.unit.test.js @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import jwt from "jsonwebtoken"; +import { google } from "googleapis"; + +vi.mock("../models/User"); +const User = require("../models/User"); + +const { + connectCalendar, + handleCallback, + getCalendarStatus, + syncCalendarEvents, +} = require("../controllers/googleCalendarController"); + +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); + res.redirect = vi.fn().mockReturnValue(res); + return res; +} + +const sampleUser = { + _id: "507f1f77bcf86cd799439011", + name: "Test User", + email: "user@example.com", + googleCalendar: { + connected: false, + refreshToken: null, + email: null, + }, + save: vi.fn().mockResolvedValue(true), +}; + +describe("googleCalendarController", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { + ...originalEnv, + JWT_SECRET: "test-secret-12345", + GOOGLE_CLIENT_ID: "mock-client-id", + GOOGLE_CLIENT_SECRET: "mock-client-secret", + GOOGLE_REDIRECT_URI: "http://localhost:5000/api/google-calendar/callback", + FRONTEND_ORIGIN: "http://localhost:5173", + }; + + vi.spyOn(google.auth.OAuth2.prototype, "generateAuthUrl").mockImplementation( + () => "https://accounts.google.com/o/oauth2/v2/auth?mock=1", + ); + + vi.spyOn(google.auth.OAuth2.prototype, "getToken").mockImplementation( + async () => ({ + tokens: { + access_token: "mock-access-token", + refresh_token: "mock-refresh-token", + }, + }), + ); + + vi.spyOn(google.auth.OAuth2.prototype, "setCredentials").mockImplementation(() => {}); + + vi.spyOn(google, "oauth2").mockImplementation(() => ({ + userinfo: { + get: vi.fn().mockResolvedValue({ + data: { email: "connected@gmail.com" }, + }), + }, + })); + + vi.spyOn(google, "calendar").mockImplementation(() => ({ + events: { + insert: vi.fn().mockResolvedValue({ + data: { id: "google-event-id-123" }, + }), + }, + })); + }); + + describe("connectCalendar", () => { + it("returns 500 if GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET is missing", async () => { + delete process.env.GOOGLE_CLIENT_ID; + const req = makeReq({}, {}, {}, sampleUser); + const res = makeRes(); + + await connectCalendar(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "Google OAuth is not configured on the server", + }), + ); + }); + + it("generates an authUrl with a signed state JWT for the user", async () => { + const req = makeReq({}, {}, {}, sampleUser); + const res = makeRes(); + + await connectCalendar(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + authUrl: "https://accounts.google.com/o/oauth2/v2/auth?mock=1", + }); + }); + }); + + describe("handleCallback", () => { + it("redirects to error page if code or state is missing", async () => { + const req = makeReq({}, {}, {}); + const res = makeRes(); + + await handleCallback(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/interview-prep?error=calendar_connection_failed", + ); + }); + + it("redirects to error page if state JWT signature is invalid", async () => { + const req = makeReq({}, {}, { code: "mock-code", state: "invalid-state-jwt" }); + const res = makeRes(); + + await handleCallback(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/interview-prep?error=invalid_state", + ); + }); + + it("exchanges code for tokens, saves connection for user, and redirects to frontend", async () => { + const validState = jwt.sign( + { userId: "507f1f77bcf86cd799439011", action: "google_calendar_connect" }, + "test-secret-12345", + ); + + User.findById = vi.fn().mockResolvedValue(sampleUser); + + const req = makeReq({}, {}, { code: "mock-code", state: validState }); + const res = makeRes(); + + await handleCallback(req, res); + + expect(User.findById).toHaveBeenCalledWith("507f1f77bcf86cd799439011"); + expect(sampleUser.save).toHaveBeenCalled(); + expect(sampleUser.googleCalendar.connected).toBe(true); + expect(sampleUser.googleCalendar.refreshToken).toBe("mock-refresh-token"); + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/interview-prep?google_calendar=connected", + ); + }); + }); + + describe("getCalendarStatus", () => { + it("returns connected: false when user has no active Google Calendar connection", async () => { + const userWithoutCalendar = { + ...sampleUser, + googleCalendar: { connected: false, refreshToken: null }, + }; + + const req = makeReq({}, {}, {}, userWithoutCalendar); + const res = makeRes(); + + await getCalendarStatus(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + connected: false, + email: null, + }); + }); + + it("returns connected: true and user email when Google Calendar is connected", async () => { + const connectedUser = { + ...sampleUser, + googleCalendar: { + connected: true, + refreshToken: "mock-refresh-token", + email: "user@gmail.com", + }, + }; + + const req = makeReq({}, {}, {}, connectedUser); + const res = makeRes(); + + await getCalendarStatus(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + connected: true, + email: "user@gmail.com", + }); + }); + }); + + describe("syncCalendarEvents", () => { + it("returns 400 if user has not connected Google Calendar", async () => { + const unconnectedUser = { + ...sampleUser, + googleCalendar: { connected: false, refreshToken: null }, + }; + + const req = makeReq( + { title: "Interview Practice", startTime: new Date().toISOString() }, + {}, + {}, + unconnectedUser, + ); + const res = makeRes(); + + await syncCalendarEvents(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "Google Calendar is not connected. Please connect your account first.", + }), + ); + }); + + it("syncs event using user's stored refresh token and returns success", async () => { + const connectedUser = { + ...sampleUser, + googleCalendar: { + connected: true, + refreshToken: "mock-refresh-token", + email: "user@gmail.com", + }, + }; + + const req = makeReq( + { + title: "SDE-2 practice session", + description: "PrepPilot session", + startTime: "2026-08-30T22:00:00.000Z", + endTime: "2026-08-30T23:00:00.000Z", + reminderMinutes: 15, + }, + {}, + {}, + connectedUser, + ); + const res = makeRes(); + + await syncCalendarEvents(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: "Google Calendar event synced", + count: 1, + eventId: "google-event-id-123", + }); + }); + }); +});