diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..74f06dc7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,107 @@ +name: Deploy to DigitalOcean + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + DEPLOY_SERVER: ${{ secrets.DEPLOY_SERVER }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_PATH: /root/CrackCode-Main/crackcode + +jobs: + test: + runs-on: ubuntu-latest + name: Test Build + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies (Server) + working-directory: crackcode/server + run: npm ci --omit=dev + + - name: Install dependencies (Client) + working-directory: crackcode/client + run: npm ci + + - name: Build client + working-directory: crackcode/client + run: npm run build + + - name: Lint check (Server) + working-directory: crackcode/server + run: npm run lint 2>/dev/null || echo "No lint script configured" + + deploy: + needs: test + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + name: Deploy to Production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install SSH key + uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.SSH_PRIVATE_KEY }} + known_hosts: ${{ secrets.KNOWN_HOSTS }} + + - name: Pull latest code + run: | + ssh -p 22 ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_SERVER }} \ + "cd ${{ env.DEPLOY_PATH }} && git pull origin main" + + - name: Rebuild and deploy containers + run: | + ssh -p 22 ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_SERVER }} \ + "cd ${{ env.DEPLOY_PATH }} && \ + docker compose down && \ + docker system prune -f && \ + docker compose up -d --build" + + - name: Verify deployment + run: | + ssh -p 22 ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_SERVER }} \ + "cd ${{ env.DEPLOY_PATH }} && \ + docker compose ps && \ + echo '--- Server Logs ---' && \ + docker compose logs server | head -20" + + - name: Health check + run: | + for i in {1..30}; do + if curl -f http://${{ env.DEPLOY_SERVER }}:4173 > /dev/null 2>&1; then + echo "✅ Application is UP" + exit 0 + fi + echo "Checking... ($i/30)" + sleep 2 + done + echo "❌ Application failed to start" + exit 1 + + - name: Deployment Success Notification + if: success() + run: | + echo "🚀 Deployment successful!" + echo "Application: http://${{ env.DEPLOY_SERVER }}:4173" + + - name: Deployment Failure Notification + if: failure() + run: | + echo "❌ Deployment failed - Check logs above" + exit 1 diff --git a/crackcode/server/server.js b/crackcode/server/server.js index 0c8ead81..f3e7d07d 100644 --- a/crackcode/server/server.js +++ b/crackcode/server/server.js @@ -189,6 +189,7 @@ import shopRoutes from "./src/routes/Shop.routes.js"; // AI Routes (ERROR DIAGNOSIS + ASSISTANT) import aiRoutes from "./src/services/aiRoutes.js"; import { logAIConfig } from "./src/services/aiConfig.js"; +import { verifyBrevoApiKey } from "./src/modules/notifications/brevo.client.js"; import authRoutes from "./src/modules/auth/routes.js"; import userRoutes from "./src/modules/user/routes.js"; @@ -200,6 +201,7 @@ import sessionRoutes from "./src/modules/session/routes.js"; import rewardsRoutes from "./src/modules/rewards/routes.js"; import codeEditorRoutes from "./src/modules/codeEditor/routes.js"; import badgeRoutes from "./src/modules/badges/routes.js"; +import brevoRoutes from "./src/modules/notifications/brevo.routes.js"; // Session cleanup utility import { cleanupExpiredSessions } from "./src/modules/session/session.service.js"; @@ -300,6 +302,7 @@ app.use("/api/session", sessionRoutes); app.use("/api/rewards", rewardsRoutes); app.use("/api/codeEditor", codeEditorRoutes); app.use("/api/badges", badgeRoutes); +app.use("/api/admin/brevo", brevoRoutes); // Career Map APIs app.use("/api/questions", questionRoutes); @@ -377,6 +380,20 @@ const startServer = async () => { console.error('❌ AI enabled but GEMINI_API_KEY missing in .env'); process.exit(1); } + + // Check Brevo connectivity and log status + try { + const brevoOk = await verifyBrevoApiKey(); + if (brevoOk) { + console.log('✅ Brevo API reachable'); + } else if (process.env.BREVO_API_KEY) { + console.warn('⚠️ BREVO_API_KEY set but Brevo API did not respond (check key/network)'); + } else { + console.warn('⚠️ BREVO_API_KEY not configured. Email sending will be disabled.'); + } + } catch (err) { + console.warn('⚠️ Could not verify Brevo API:', err?.message || err); + } await connectRedisOrExit(); diff --git a/crackcode/server/src/modules/Career Map/questions/question.controller.js b/crackcode/server/src/modules/Career Map/questions/question.controller.js index 1bb9bb4f..f605de70 100644 --- a/crackcode/server/src/modules/Career Map/questions/question.controller.js +++ b/crackcode/server/src/modules/Career Map/questions/question.controller.js @@ -8,6 +8,7 @@ import { import User from "../../auth/User.model.js"; import { checkAndUnlockMultipleBadges } from "../../badges/badge.service.js"; import UserProgress from "../../learn/UserProgress.model.js"; +import Question, { findQuestionByIdentifier } from "../../learn/Question.model.js"; // Valid career paths const VALID_CAREERS = ["MLEngineer", "DataScientist", "SoftwareEngineer"]; @@ -123,13 +124,18 @@ export const submitAnswer = async (req, res) => { // If answer is correct, update progress and check for badge unlocks if (isCorrect) { try { + // Normalize question identifier (support problemId strings) + let qid = questionId; + const qdoc = await findQuestionByIdentifier(questionId); + if (qdoc) qid = qdoc._id; + // Check if already completed to avoid duplicate rewards - const existingProgress = await UserProgress.findOne({ userId, questionId }); + const existingProgress = await UserProgress.findOne({ userId, questionId: qid }); const isFirstCompletion = !existingProgress || existingProgress.status !== 'completed'; // Update progress await UserProgress.findOneAndUpdate( - { userId, questionId }, + { userId, questionId: qid }, { status: "completed", completedAt: new Date(), diff --git a/crackcode/server/src/modules/auth/controller.js b/crackcode/server/src/modules/auth/controller.js index 07e948d6..842435bb 100644 --- a/crackcode/server/src/modules/auth/controller.js +++ b/crackcode/server/src/modules/auth/controller.js @@ -2,7 +2,7 @@ import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import User from "./User.model.js"; import PendingRegistration from "./PendingRegistration.model.js"; -import transporter from "./nodemailer.config.js"; +import { sendTransactionalEmail } from "../notifications/brevo.client.js"; import { createSession, invalidateSession, @@ -107,20 +107,27 @@ export const register = async (req, res) =>{ }); await pending.save(); - // Try to send OTP email. In development we don't want SMTP problems to block signup, - // so swallow sendMail failures and expose the tempId (and OTP in logs) so devs can verify. + // Send OTP email via Brevo API only. Fail fast if API key not configured. try { - await transporter.sendMail({ - from: process.env.SENDER_EMAIL, - to: email, - subject: "Account Verification OTP", - text: `Your OTP is ${otp}. Verify your account using this OTP.`, - }); + const apiKey = process.env.BREVO_API_KEY || process.env.SENDINBLUE_API_KEY || process.env.SIB_API_KEY; + if (!apiKey) { + // In non-production, log OTP to help local testing. In production, return error. + if (process.env.NODE_ENV !== "production") { + console.log(`DEV OTP for ${email}: ${otp} (tempId=${pending._id})`); + } else { + console.error("BREVO_API_KEY missing - cannot send verification email"); + return res.status(500).json({ success: false, message: "Email provider not configured (BREVO_API_KEY missing)" }); + } + } else { + const html = `

Your OTP is ${otp}. Verify your account using this OTP.

`; + await sendTransactionalEmail({ to: email, subject: "Account Verification OTP", html, senderEmail: process.env.SENDER_EMAIL }); + } } catch (mailErr) { - console.warn("⚠️ Failed to send verification email:", mailErr?.message || mailErr); - // In non-production, log the OTP so local testing can proceed without SMTP + console.error("⚠️ Failed to send verification email:", mailErr?.message || mailErr); if (process.env.NODE_ENV !== "production") { console.log(`DEV OTP for ${email}: ${otp} (tempId=${pending._id})`); + } else { + return res.status(500).json({ success: false, message: "Failed to send verification email" }); } } @@ -311,14 +318,20 @@ export const sendVerifyOtp = async (req, res) => { await pending.save(); try { - await transporter.sendMail({ - from: process.env.SENDER_EMAIL, - to: pending.email, - subject: "Account Verification OTP", - text: `Your OTP is ${otp}. Verify your account using this OTP.`, - }); + const apiKey = process.env.BREVO_API_KEY || process.env.SENDINBLUE_API_KEY || process.env.SIB_API_KEY; + if (!apiKey) { + if (process.env.NODE_ENV !== "production") { + console.log(`DEV OTP for ${pending.email}: ${otp}`); + } else { + console.error("BREVO_API_KEY missing - cannot send verification OTP"); + return res.status(500).json({ success: false, message: "Email provider not configured (BREVO_API_KEY missing)" }); + } + } else { + const html = `

Your OTP is ${otp}. Verify your account using this OTP.

`; + await sendTransactionalEmail({ to: pending.email, subject: "Account Verification OTP", html, senderEmail: process.env.SENDER_EMAIL }); + } } catch (mailErr) { - console.error("[Email] Failed to send verification OTP:", mailErr?.message); + console.error("[Email] Failed to send verification OTP:", mailErr?.message || mailErr); return res.status(500).json({ success: false, message: "Failed to send OTP email. Please try again." }); } @@ -455,14 +468,20 @@ export const sendResetOtp = async (req, res) => { await user.save(); try { - await transporter.sendMail({ - from: process.env.SENDER_EMAIL, - to: user.email, - subject: "Password Reset OTP", - text: `Your OTP for password reset is ${otp}. It will expire in 15 minutes.`, - }); + const apiKey = process.env.BREVO_API_KEY || process.env.SENDINBLUE_API_KEY || process.env.SIB_API_KEY; + if (!apiKey) { + if (process.env.NODE_ENV !== "production") { + console.log(`DEV OTP for ${user.email}: ${otp}`); + } else { + console.error("BREVO_API_KEY missing - cannot send password reset OTP"); + return res.status(500).json({ success: false, message: "Email provider not configured (BREVO_API_KEY missing)" }); + } + } else { + const html = `

Your OTP for password reset is ${otp}. It will expire in 15 minutes.

`; + await sendTransactionalEmail({ to: user.email, subject: "Password Reset OTP", html, senderEmail: process.env.SENDER_EMAIL }); + } } catch (mailErr) { - console.error("[Email] Failed to send password reset OTP:", mailErr?.message); + console.error("[Email] Failed to send password reset OTP:", mailErr?.message || mailErr); return res.status(500).json({ success: false, message: "Failed to send OTP email. Please try again." }); } diff --git a/crackcode/server/src/modules/auth/nodemailer.config.js b/crackcode/server/src/modules/auth/nodemailer.config.js deleted file mode 100644 index cb146b25..00000000 --- a/crackcode/server/src/modules/auth/nodemailer.config.js +++ /dev/null @@ -1,41 +0,0 @@ -import nodemailer from "nodemailer"; -import dotenv from "dotenv"; -import path from "path"; -import { fileURLToPath } from "url"; - -// Load env variables from server/.env -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -dotenv.config({ path: path.join(__dirname, "../../../.env") }); - -const transporter = nodemailer.createTransport({ // configure the transporter with SMTP settings from environment variables - host: process.env.SMTP_HOST || "smtp.gmail.com", - port: process.env.SMTP_PORT || 587, - secure: false, - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASSWORD, - }, - tls: { - rejectUnauthorized: false, // dev only - }, -}); - -// Debug logs -console.log("SMTP USER:", process.env.SMTP_USER); -console.log( - "SMTP PASS:", - process.env.SMTP_PASSWORD ? "LOADED" : "MISSING" -); - -// Verify SMTP -transporter.verify((error) => { - if (error) { - console.error("❌ SMTP verification failed:", error.message); - } else { - console.log("✅ SMTP ready to send emails"); - } -}); - -export default transporter; - diff --git a/crackcode/server/src/modules/learn/Question.model.js b/crackcode/server/src/modules/learn/Question.model.js index 7d2c4c24..688cf7a7 100644 --- a/crackcode/server/src/modules/learn/Question.model.js +++ b/crackcode/server/src/modules/learn/Question.model.js @@ -96,3 +96,17 @@ questionSchema.index({ difficulty: 1 }); questionSchema.index({ language: 1 }); export default mongoose.model("Question", questionSchema); + +// Helper: find question by either ObjectId or problemId string +export async function findQuestionByIdentifier(idOrProblemId) { + if (!idOrProblemId) return null; + + // If it's a valid ObjectId, try by _id first + if (mongoose.Types.ObjectId.isValid(idOrProblemId)) { + const byId = await mongoose.model("Question").findById(idOrProblemId); + if (byId) return byId; + } + + // Otherwise, try problemId + return await mongoose.model("Question").findOne({ problemId: idOrProblemId }); +} diff --git a/crackcode/server/src/modules/learn/progress.controller.js b/crackcode/server/src/modules/learn/progress.controller.js index bfe08701..fe79cc52 100644 --- a/crackcode/server/src/modules/learn/progress.controller.js +++ b/crackcode/server/src/modules/learn/progress.controller.js @@ -1,5 +1,5 @@ import UserProgress from "./UserProgress.model.js"; -import Question from "./Question.model.js"; +import Question, { findQuestionByIdentifier } from "./Question.model.js"; import User from "../auth/User.model.js"; import { checkAndUnlockMultipleBadges } from "../badges/badge.service.js"; @@ -19,7 +19,14 @@ export const getUserProgress = async (req, res) => { export const updateProgress = async (req, res) => { try { const userId = req.userId; - const { questionId, status } = req.body; + const { questionId: rawQuestionId, status } = req.body; + + // Normalize question identifier to ObjectId when possible + let questionId = rawQuestionId; + if (rawQuestionId) { + const q = await findQuestionByIdentifier(rawQuestionId); + if (q) questionId = q._id; + } // detect previous state to know if this is a new completion const existing = await UserProgress.findOne({ userId, questionId }); diff --git a/crackcode/server/src/modules/notifications/brevo.client.js b/crackcode/server/src/modules/notifications/brevo.client.js new file mode 100644 index 00000000..f24c6f93 --- /dev/null +++ b/crackcode/server/src/modules/notifications/brevo.client.js @@ -0,0 +1,49 @@ +import axios from "axios"; + +const BREVO_API = "https://api.brevo.com/v3/smtp/email"; + +function getApiKey() { + return process.env.BREVO_API_KEY || process.env.SENDINBLUE_API_KEY || process.env.SIB_API_KEY; +} + +// Verify API key by requesting account info. Returns true if reachable. +export async function verifyBrevoApiKey() { + const apiKey = getApiKey(); + if (!apiKey) return false; + + try { + const res = await axios.get("https://api.brevo.com/v3/account", { + headers: { "api-key": apiKey }, + timeout: 5000, + }); + return res.status === 200; + } catch (err) { + return false; + } +} + +export async function sendTransactionalEmail({ to, subject, html, text, senderEmail }) { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error("BREVO_API_KEY is not configured in environment"); + } + + const payload = { + sender: { email: senderEmail || process.env.SENDER_EMAIL }, + to: Array.isArray(to) ? to.map((t) => ({ email: t })) : [{ email: to }], + subject: subject || "No subject", + htmlContent: html || text || "", + }; + + const res = await axios.post(BREVO_API, payload, { + headers: { + "api-key": apiKey, + "Content-Type": "application/json", + }, + timeout: 10000, + }); + + return res.data; +} + +export default { sendTransactionalEmail }; diff --git a/crackcode/server/src/modules/notifications/brevo.controller.js b/crackcode/server/src/modules/notifications/brevo.controller.js new file mode 100644 index 00000000..0eaea6f0 --- /dev/null +++ b/crackcode/server/src/modules/notifications/brevo.controller.js @@ -0,0 +1,34 @@ +import { sendTransactionalEmail } from "./brevo.client.js"; + +// Admin-protected endpoint to send a test/transactional email via Brevo +export const sendTestEmail = async (req, res) => { + try { + // Admin check: require ADMIN_EMAILS (csv) or single ADMIN_EMAIL in env + const adminCsv = process.env.ADMIN_EMAILS || process.env.ADMIN_EMAIL; + if (!adminCsv) { + return res.status(403).json({ success: false, message: "ADMIN_EMAILS not configured on server" }); + } + + const admins = adminCsv.split(",").map((s) => s.trim().toLowerCase()); + const requester = (req.user?.email || "").toLowerCase(); + if (!admins.includes(requester)) { + return res.status(403).json({ success: false, message: "Forbidden: admin only" }); + } + + const { to = process.env.DEBUG_EMAIL_TO || "info.crackcode@gmail.com", subject = "Test from Brevo", html } = req.body; + + const senderEmail = process.env.SENDER_EMAIL; + if (!senderEmail) { + return res.status(500).json({ success: false, message: "SENDER_EMAIL not configured" }); + } + + const result = await sendTransactionalEmail({ to, subject, html, senderEmail }); + + return res.json({ success: true, data: result }); + } catch (error) { + console.error("Brevo send error:", error?.response?.data || error.message || error); + return res.status(500).json({ success: false, message: error?.response?.data || error.message || "Failed to send" }); + } +}; + +export default { sendTestEmail }; diff --git a/crackcode/server/src/modules/notifications/brevo.routes.js b/crackcode/server/src/modules/notifications/brevo.routes.js new file mode 100644 index 00000000..20f8ce85 --- /dev/null +++ b/crackcode/server/src/modules/notifications/brevo.routes.js @@ -0,0 +1,10 @@ +import express from "express"; +import userAuth from "../auth/middleware.js"; +import { sendTestEmail } from "./brevo.controller.js"; + +const router = express.Router(); + +// POST /api/admin/brevo/send - requires authenticated admin user +router.post("/send", userAuth, sendTestEmail); + +export default router; diff --git a/crackcode/server/src/modules/rewards/rewards.controller.js b/crackcode/server/src/modules/rewards/rewards.controller.js index 8511f6c3..bc50bb98 100644 --- a/crackcode/server/src/modules/rewards/rewards.controller.js +++ b/crackcode/server/src/modules/rewards/rewards.controller.js @@ -1,6 +1,6 @@ import { awardXP, awardTokens, awardRewards } from "../session/transaction.service.js"; import UserProgress from "../learn/UserProgress.model.js"; -import Question from "../learn/Question.model.js"; +import Question, { findQuestionByIdentifier } from "../learn/Question.model.js"; import { getRewardConfig } from "./reward.config.js"; /* @@ -11,8 +11,8 @@ export const awardChallengeCompletion = async (req, res) => { const { questionId, isFirstAttempt = false } = req.body; const userId = req.userId; - // Get question details - const question = await Question.findById(questionId); + // Get question details (accept either ObjectId or problemId) + const question = await findQuestionByIdentifier(questionId); if (!question) { return res.status(404).json({ success: false, @@ -21,9 +21,10 @@ export const awardChallengeCompletion = async (req, res) => { } // Check if already completed (prevent farming) + const qid = question._id; const existingProgress = await UserProgress.findOne({ userId, - questionId, + questionId: qid, status: "completed", }); @@ -58,7 +59,7 @@ export const awardChallengeCompletion = async (req, res) => { // Update progress await UserProgress.findOneAndUpdate( - { userId, questionId }, + { userId, questionId: qid }, { status: "completed", completedAt: new Date(),