diff --git a/backend/.env.example b/backend/.env.example index 44f4bbc6..826205df 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/config/validateEnv.js b/backend/config/validateEnv.js index 03adf692..c92952dc 100644 --- a/backend/config/validateEnv.js +++ b/backend/config/validateEnv.js @@ -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 = () => { diff --git a/backend/controllers/googleCalendarController.js b/backend/controllers/googleCalendarController.js new file mode 100644 index 00000000..21a73232 --- /dev/null +++ b/backend/controllers/googleCalendarController.js @@ -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, +}; diff --git a/backend/controllers/sessionController.js b/backend/controllers/sessionController.js index 4da73ca6..16d80475 100644 --- a/backend/controllers/sessionController.js +++ b/backend/controllers/sessionController.js @@ -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; @@ -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) { @@ -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, diff --git a/backend/models/User.js b/backend/models/User.js index 5d0d6b3c..0512f657 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -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 }, @@ -90,6 +102,8 @@ UserSchema.set("toJSON", { delete ret.emailVerificationToken; delete ret.emailVerificationExpires; delete ret.tokenVersion; + delete ret.googleCalendarAccessToken; + delete ret.googleCalendarRefreshToken; return ret; }, }); diff --git a/backend/package-lock.json b/backend/package-lock.json index 00e3c49f..1d6a94b0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "express-rate-limit": "^8.2.1", "file-type": "^18.5.0", "form-data": "^4.0.5", + "googleapis": "^174.0.1", "helmet": "^8.3.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2", @@ -152,6 +153,23 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -404,6 +422,16 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -918,6 +946,30 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1002,7 +1054,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -1242,6 +1293,24 @@ "node": ">=0.8" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1371,6 +1440,29 @@ "node": ">= 0.10" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1453,6 +1545,12 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1468,6 +1566,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1661,6 +1765,29 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-type": { "version": "18.7.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", @@ -1728,6 +1855,22 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -1765,6 +1908,18 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -1892,6 +2047,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1905,6 +2081,30 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/google-auth-library": { "version": "9.15.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", @@ -1931,6 +2131,233 @@ "node": ">=14" } }, + "node_modules/googleapis": { + "version": "174.0.1", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-174.0.1.tgz", + "integrity": "sha512-51B9r4WnyAyUXvsDhDswpSVZbqx3afSBEzrzshxBivysBKsB6kJ+X5CVOybRV/R3HIURiR01uHXLGkEm7On3dA==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "10.5.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.3.tgz", + "integrity": "sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis-common/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/googleapis/node_modules/gaxios": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/gcp-metadata/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/google-logging-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.2.0.tgz", + "integrity": "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2140,6 +2567,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2181,6 +2617,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/joi": { "version": "18.0.2", "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", @@ -2584,6 +3041,12 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2687,6 +3150,15 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mongodb-connection-string-url": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", @@ -2928,6 +3400,26 @@ "node": ">= 8.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -3052,6 +3544,12 @@ "wrappy": "1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3061,6 +3559,31 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -3344,6 +3867,21 @@ } } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rolldown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", @@ -3475,6 +4013,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -3560,6 +4119,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -3642,6 +4213,102 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strtok3": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.1.1.tgz", @@ -3882,6 +4549,12 @@ "node": ">= 0.8" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4104,6 +4777,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4120,6 +4802,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4137,6 +4834,97 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 821fd406..4e6adc23 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "express-rate-limit": "^8.2.1", "file-type": "^18.5.0", "form-data": "^4.0.5", + "googleapis": "^174.0.1", "helmet": "^8.3.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2", diff --git a/backend/routes/googleCalendarRoutes.js b/backend/routes/googleCalendarRoutes.js new file mode 100644 index 00000000..9ef70c3d --- /dev/null +++ b/backend/routes/googleCalendarRoutes.js @@ -0,0 +1,17 @@ +const express = require("express"); +const { protect } = require("../middlewares/authMiddleware"); +const { + connectGoogleCalendar, + handleGoogleCalendarCallback, + getGoogleCalendarStatus, + syncGoogleCalendarEvent, +} = require("../controllers/googleCalendarController"); + +const router = express.Router(); + +router.get("/connect", protect, connectGoogleCalendar); +router.get("/callback", handleGoogleCalendarCallback); +router.get("/status", protect, getGoogleCalendarStatus); +router.post("/events", protect, syncGoogleCalendarEvent); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 1a6341b8..5be29095 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,6 +19,7 @@ const { const { protect } = require("./middlewares/authMiddleware"); const authRoutes = require("./routes/authRoutes"); const sessionRoutes = require("./routes/sessionRoutes"); +const googleCalendarRoutes = require("./routes/googleCalendarRoutes"); const questionRoutes = require("./routes/questionRoutes"); const aiRoutes = require("./routes/aiRoutes"); const resumeRoutes = require("./routes/resumeRoutes"); @@ -93,6 +94,7 @@ app.use(cookieParser()); //Routes app.use("/api/auth", sensitiveRouteHeaders,authRoutes); +app.use("/api/google-calendar", sensitiveRouteHeaders, googleCalendarRoutes); app.use("/api/sessions", generalLimiter, sessionRoutes); app.use("/api/question", generalLimiter, questionRoutes); app.use("/api", aiRoutes); diff --git a/backend/services/googleCalendarService.js b/backend/services/googleCalendarService.js new file mode 100644 index 00000000..5a016c0c --- /dev/null +++ b/backend/services/googleCalendarService.js @@ -0,0 +1,175 @@ +const crypto = require("crypto"); +const { google } = require("googleapis"); + +const GOOGLE_CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.events"]; +const ENCRYPTION_ALGORITHM = "aes-256-gcm"; +const ENCRYPTION_KEY = crypto + .createHash("sha256") + .update(process.env.JWT_SECRET || "prep-pilot-google-calendar") + .digest(); + +const getGoogleOAuthClient = () => { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + const redirectUri = process.env.GOOGLE_REDIRECT_URI; + + if (!clientId || !clientSecret || !redirectUri) { + throw new Error("Google Calendar integration is not configured."); + } + + return new google.auth.OAuth2(clientId, clientSecret, redirectUri); +}; + +const encryptSecret = (value) => { + if (!value) return null; + + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, ENCRYPTION_KEY, iv); + const encrypted = Buffer.concat([ + cipher.update(Buffer.from(value, "utf8")), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([iv, tag, encrypted]).toString("base64"); +}; + +const decryptSecret = (value) => { + if (!value) return null; + + try { + const buffer = Buffer.from(value, "base64"); + const iv = buffer.subarray(0, 12); + const tag = buffer.subarray(12, 28); + const encrypted = buffer.subarray(28); + const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, ENCRYPTION_KEY, iv); + decipher.setAuthTag(tag); + + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"); + } catch (error) { + console.error("Failed to decrypt Google Calendar token:", error.message); + return null; + } +}; + +const getGoogleCalendarAuthUrl = (userId) => { + const oauth2Client = getGoogleOAuthClient(); + const state = `${userId}:${crypto.randomBytes(8).toString("hex")}`; + const authUrl = oauth2Client.generateAuthUrl({ + access_type: "offline", + prompt: "consent", + scope: GOOGLE_CALENDAR_SCOPES, + state, + }); + + return { authUrl, state }; +}; + +const exchangeGoogleCalendarCode = async (code) => { + const oauth2Client = getGoogleOAuthClient(); + const { tokens } = await oauth2Client.getToken(code); + + return { + accessToken: tokens.access_token || null, + refreshToken: tokens.refresh_token || null, + expiryDate: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null, + scope: tokens.scope || GOOGLE_CALENDAR_SCOPES.join(" "), + }; +}; + +const buildCalendarEventPayload = (payload) => { + const startTime = payload.startTime ? new Date(payload.startTime) : new Date(Date.now() + 60 * 60 * 1000); + const endTime = payload.endTime ? new Date(payload.endTime) : new Date(startTime.getTime() + 60 * 60 * 1000); + const reminderMinutes = Number(payload.reminderMinutes || 10); + + return { + summary: payload.title || "PrepPilot Interview Prep", + description: payload.description || "Synced from PrepPilot", + start: { + dateTime: startTime.toISOString(), + timeZone: "UTC", + }, + end: { + dateTime: endTime.toISOString(), + timeZone: "UTC", + }, + reminders: { + useDefault: false, + overrides: [{ method: "popup", minutes: reminderMinutes }], + }, + }; +}; + +const createOrUpdateGoogleCalendarEvent = async (user, payload) => { + if (!user) { + throw new Error("User is required"); + } + + const accessToken = decryptSecret(user.googleCalendarAccessToken); + const refreshToken = decryptSecret(user.googleCalendarRefreshToken); + + if (!accessToken || !refreshToken) { + throw new Error("Google Calendar is not connected"); + } + + const oauth2Client = getGoogleOAuthClient(); + oauth2Client.setCredentials({ + access_token: accessToken, + refresh_token: refreshToken, + expiry_date: user.googleCalendarTokenExpiry ? new Date(user.googleCalendarTokenExpiry).getTime() : undefined, + }); + + if (user.googleCalendarTokenExpiry && new Date(user.googleCalendarTokenExpiry).getTime() <= Date.now()) { + try { + const { credentials } = await oauth2Client.refreshAccessToken(); + return { + credentials: { + accessToken: credentials.access_token || accessToken, + refreshToken: credentials.refresh_token || refreshToken, + expiryDate: credentials.expiry_date ? new Date(credentials.expiry_date).toISOString() : null, + }, + }; + } catch (error) { + throw new Error("expired"); + } + } + + const calendar = google.calendar({ version: "v3", auth: oauth2Client }); + const requestBody = buildCalendarEventPayload(payload); + const calendarId = payload.calendarId || "primary"; + + const response = payload.eventId + ? await calendar.events.update({ calendarId, eventId: payload.eventId, requestBody }) + : await calendar.events.insert({ calendarId, requestBody }); + + return { + eventId: response.data.id, + htmlLink: response.data.htmlLink, + credentials: { + accessToken, + refreshToken, + expiryDate: user.googleCalendarTokenExpiry ? new Date(user.googleCalendarTokenExpiry).toISOString() : null, + }, + }; +}; + +const getGoogleCalendarConnectionStatus = async (user) => { + if (!user) { + return { connected: false }; + } + + return { + connected: Boolean(user.googleCalendarConnected), + email: user.googleCalendarEmail || null, + connectedAt: user.googleCalendarConnectedAt || null, + }; +}; + +module.exports = { + encryptSecret, + decryptSecret, + getGoogleCalendarAuthUrl, + exchangeGoogleCalendarCode, + createOrUpdateGoogleCalendarEvent, + getGoogleCalendarConnectionStatus, +}; diff --git a/backend/tests/googleCalendarController.unit.test.js b/backend/tests/googleCalendarController.unit.test.js new file mode 100644 index 00000000..3b21477a --- /dev/null +++ b/backend/tests/googleCalendarController.unit.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; + +process.env.GOOGLE_CLIENT_ID = 'client-id'; +process.env.GOOGLE_CLIENT_SECRET = 'client-secret'; +process.env.GOOGLE_REDIRECT_URI = 'http://localhost:8000/api/google-calendar/callback'; + +const { + connectGoogleCalendar, + getGoogleCalendarStatus, + syncGoogleCalendarEvent, +} = await import('../controllers/googleCalendarController.js'); + +function makeRes() { + const res = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + res.redirect = vi.fn().mockReturnValue(res); + return res; +} + +function makeReq(body = {}, user = { _id: 'user-123' }) { + return { body, user }; +} + +describe('google calendar controller', () => { + it('returns an auth URL for a connected user', async () => { + const res = makeRes(); + await connectGoogleCalendar(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, authUrl: expect.any(String) })); + }); + + it('rejects sync requests that are missing required event details', async () => { + const res = makeRes(); + await syncGoogleCalendarEvent(makeReq({ title: 'Mock Interview' }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + }); + + it('returns status for the current user', async () => { + const res = makeRes(); + await getGoogleCalendarStatus(makeReq({}, { _id: 'user-123', googleCalendarConnected: true, googleCalendarEmail: 'test@example.com' }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, connected: true })); + }); +}); diff --git a/backend/tests/interviewStreak.unit.test.js b/backend/tests/interviewStreak.unit.test.js new file mode 100644 index 00000000..01b9b2ba --- /dev/null +++ b/backend/tests/interviewStreak.unit.test.js @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { getInterviewStreakUpdate } from '../utils/interviewStreak'; + +describe('interview streak tracking', () => { + it('starts a new streak on the first practice day', () => { + const result = getInterviewStreakUpdate({ + interviewStreak: 0, + longestInterviewStreak: 0, + interviewStreakBadges: [], + interviewStreakLastPracticeDate: null, + }, '2026-08-01'); + + expect(result.streak).toBe(1); + expect(result.longestStreak).toBe(1); + expect(result.badges).toEqual([]); + expect(result.changed).toBe(true); + }); + + it('increments the streak on consecutive practice days', () => { + const result = getInterviewStreakUpdate({ + interviewStreak: 1, + longestInterviewStreak: 1, + interviewStreakBadges: [], + interviewStreakLastPracticeDate: '2026-07-31', + }, '2026-08-01'); + + expect(result.streak).toBe(2); + expect(result.longestStreak).toBe(2); + expect(result.changed).toBe(true); + }); + + it('resets the streak after missing a day', () => { + const result = getInterviewStreakUpdate({ + interviewStreak: 2, + longestInterviewStreak: 2, + interviewStreakBadges: [], + interviewStreakLastPracticeDate: '2026-07-29', + }, '2026-08-01'); + + expect(result.streak).toBe(1); + expect(result.longestStreak).toBe(2); + expect(result.changed).toBe(true); + }); + + it('unlocks milestone badges at 3, 7 and 30 days', () => { + const threeDayResult = getInterviewStreakUpdate({ + interviewStreak: 2, + longestInterviewStreak: 2, + interviewStreakBadges: [], + interviewStreakLastPracticeDate: '2026-07-30', + }, '2026-07-31'); + + const sevenDayResult = getInterviewStreakUpdate({ + interviewStreak: 6, + longestInterviewStreak: 6, + interviewStreakBadges: ['3 days'], + interviewStreakLastPracticeDate: '2026-07-30', + }, '2026-07-31'); + + const thirtyDayResult = getInterviewStreakUpdate({ + interviewStreak: 29, + longestInterviewStreak: 29, + interviewStreakBadges: ['3 days', '7 days'], + interviewStreakLastPracticeDate: '2026-07-31', + }, '2026-08-01'); + + expect(threeDayResult.badges).toEqual(['3 days']); + expect(sevenDayResult.badges).toEqual(['3 days', '7 days']); + expect(thirtyDayResult.badges).toEqual(['3 days', '7 days', '30 days']); + }); + + it('does not increment twice for duplicate practice on the same day', () => { + const result = getInterviewStreakUpdate({ + interviewStreak: 3, + longestInterviewStreak: 3, + interviewStreakBadges: ['3 days'], + interviewStreakLastPracticeDate: '2026-08-01', + }, '2026-08-01'); + + expect(result.streak).toBe(3); + expect(result.longestStreak).toBe(3); + expect(result.badges).toEqual(['3 days']); + expect(result.changed).toBe(false); + }); + + it('updates the longest streak when a new peak is reached', () => { + const result = getInterviewStreakUpdate({ + interviewStreak: 4, + longestInterviewStreak: 4, + interviewStreakBadges: ['3 days'], + interviewStreakLastPracticeDate: '2026-07-30', + }, '2026-07-31'); + + expect(result.streak).toBe(5); + expect(result.longestStreak).toBe(5); + }); +}); diff --git a/backend/utils/interviewStreak.js b/backend/utils/interviewStreak.js new file mode 100644 index 00000000..6691b865 --- /dev/null +++ b/backend/utils/interviewStreak.js @@ -0,0 +1,67 @@ +const MILESTONE_BADGES = ['3 days', '7 days', '30 days']; + +const parseDateOnly = (value) => { + if (!value || typeof value !== 'string') return null; + + const [year, month, day] = value.split('-').map(Number); + if (!year || !month || !day) return null; + + return new Date(Date.UTC(year, month - 1, day)); +}; + +const getInterviewStreakUpdate = (userData = {}, practiceDate) => { + if (!practiceDate || typeof practiceDate !== 'string') { + return { + streak: Number(userData.interviewStreak || 0), + longestStreak: Number(userData.longestInterviewStreak || 0), + badges: Array.isArray(userData.interviewStreakBadges) ? userData.interviewStreakBadges : [], + changed: false, + }; + } + + const currentStreak = Number(userData.interviewStreak || 0); + const longestStreak = Number(userData.longestInterviewStreak || 0); + const badges = Array.isArray(userData.interviewStreakBadges) ? [...userData.interviewStreakBadges] : []; + const lastPracticeDate = userData.interviewStreakLastPracticeDate; + + const today = parseDateOnly(practiceDate); + const lastPractice = parseDateOnly(lastPracticeDate); + const sameDay = Boolean(today && lastPractice && today.getTime() === lastPractice.getTime()); + + let nextStreak = currentStreak; + let changed = false; + + if (!sameDay) { + if (lastPractice && today) { + const diffDays = Math.round((today.getTime() - lastPractice.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays === 1) { + nextStreak = currentStreak + 1; + } else { + nextStreak = 1; + } + } else { + nextStreak = 1; + } + + changed = true; + } + + const nextLongest = Math.max(longestStreak, nextStreak); + const nextBadges = [...badges]; + + for (const milestone of MILESTONE_BADGES) { + const threshold = Number(milestone.split(' ')[0]); + if (nextLongest >= threshold && !nextBadges.includes(milestone)) { + nextBadges.push(milestone); + } + } + + return { + streak: nextStreak, + longestStreak: nextLongest, + badges: nextBadges, + changed, + }; +}; + +module.exports = { getInterviewStreakUpdate, MILESTONE_BADGES }; diff --git a/frontend/src/pages/Home/CreateSessionForm.jsx b/frontend/src/pages/Home/CreateSessionForm.jsx index 444f32cd..de52ecbe 100644 --- a/frontend/src/pages/Home/CreateSessionForm.jsx +++ b/frontend/src/pages/Home/CreateSessionForm.jsx @@ -1,8 +1,9 @@ -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; import { useNavigate } from "react-router-dom"; import SpinnerLoader from "../../components/Loader/SpinnerLoader"; import axiosInstance from "../../utils/axiosinstance"; import { API_PATHS } from "../../utils/apiPaths"; +import { UserContext } from "../../context/userContext"; import { Target, Briefcase, Code2, FileText, Sparkles } from "lucide-react"; const MAX_EXPERIENCE = 50; @@ -18,6 +19,7 @@ const CreateSessionForm = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const { user, updateUser } = useContext(UserContext); const navigate = useNavigate(); @@ -80,6 +82,14 @@ if (experienceValue > MAX_EXPERIENCE) { }); if (response.data?.session?._id) { + if (response.data?.streakSummary) { + updateUser({ + ...(user || {}), + interviewStreak: response.data.streakSummary.current, + longestInterviewStreak: response.data.streakSummary.longest, + interviewStreakBadges: response.data.streakSummary.badges || [], + }); + } navigate(`/interview-prep/${response.data?.session?._id}`); } } catch (error) { diff --git a/frontend/src/pages/Home/Dashboard.jsx b/frontend/src/pages/Home/Dashboard.jsx index ada3170a..69bb379d 100644 --- a/frontend/src/pages/Home/Dashboard.jsx +++ b/frontend/src/pages/Home/Dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { LuPlus } from "react-icons/lu"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; @@ -13,9 +13,11 @@ import DeleteAlertContent from "../../components/DeleteAlertContent"; import axiosInstance from "../../utils/axiosinstance"; import { API_PATHS } from "../../utils/apiPaths"; import { CARD_BG } from "../../utils/data"; +import { UserContext } from "../../context/userContext"; const Dashboard = () => { const navigate = useNavigate(); + const { user } = useContext(UserContext); const [sessions, setSessions] = useState([]); const [openCreateModal, setOpenCreateModal] = useState(false); const [openDeleteAlert, setOpenDeleteAlert] = useState({ @@ -75,6 +77,50 @@ const Dashboard = () => { +
+
+
+

+ Interview Streak +

+

+ Keep your practice rhythm alive +

+
+
+ 🔥 +
+
+ +
+
+

Current Streak

+

+ {user?.interviewStreak ?? 0} days +

+
+
+

Best Streak

+

+ {user?.longestInterviewStreak ?? 0} days +

+
+
+

Earned Badges

+
+ {(user?.interviewStreakBadges?.length ? user.interviewStreakBadges : ["No badges yet"]).map((badge) => ( + + {badge} + + ))} +
+
+
+
+ {/* Sessions Grid */} {sessions.length > 0 ? (