From 6074d45c540bbf3cad7956550a1e3308fe0df05b Mon Sep 17 00:00:00 2001 From: Ritam-Vaskar Date: Mon, 12 Jan 2026 22:16:54 +0530 Subject: [PATCH 01/15] Update CORS configuration to include new allowed origin and restrict access in production --- index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 8aa91cd..e6720ff 100644 --- a/index.js +++ b/index.js @@ -121,13 +121,14 @@ app.use(cors({ "http://localhost:5173", "http://localhost:3000", "https://fedkiit.com", - "https://www.fedkiit.com" + "https://www.fedkiit.com", + "https://awsapi2.fedkiit.com" ]; if (allowedOrigins.includes(origin)) { callback(null, true); } else { - callback(null, true); // Allow all in dev, restrict in production + callback(new Error('Not allowed by CORS')); } }, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], From 77747c448f00a0a8cccb72f0052ee564c747a6c5 Mon Sep 17 00:00:00 2001 From: Ritam-Vaskar Date: Mon, 12 Jan 2026 22:30:00 +0530 Subject: [PATCH 02/15] Update CORS configuration to allow all origins --- index.js | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/index.js b/index.js index e6720ff..452100c 100644 --- a/index.js +++ b/index.js @@ -114,22 +114,8 @@ app.use(cookieParser()); app.use(cors({ origin: function (origin, callback) { - // Allow requests with no origin (like mobile apps or curl) - if (!origin) return callback(null, true); - - const allowedOrigins = [ - "http://localhost:5173", - "http://localhost:3000", - "https://fedkiit.com", - "https://www.fedkiit.com", - "https://awsapi2.fedkiit.com" - ]; - - if (allowedOrigins.includes(origin)) { - callback(null, true); - } else { - callback(new Error('Not allowed by CORS')); - } + // Allow all origins by returning the requesting origin + callback(null, origin || '*'); }, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], credentials: true, From ad9e4c528593608335b85917efbda9545bc3f2ec Mon Sep 17 00:00:00 2001 From: Ritam-Vaskar Date: Tue, 13 Jan 2026 01:54:07 +0530 Subject: [PATCH 03/15] Update CORS configuration to allow all origins with credentials --- index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 452100c..912a324 100644 --- a/index.js +++ b/index.js @@ -114,8 +114,8 @@ app.use(cookieParser()); app.use(cors({ origin: function (origin, callback) { - // Allow all origins by returning the requesting origin - callback(null, origin || '*'); + // Allow all origins - return true to allow any origin with credentials + callback(null, true); }, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], credentials: true, From d8bdb3dcc919386c4a54208a9dbcd7057bf77030 Mon Sep 17 00:00:00 2001 From: Ritam-Vaskar Date: Tue, 13 Jan 2026 02:07:01 +0530 Subject: [PATCH 04/15] Update CORS configuration to restrict allowed origins --- index.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 912a324..e038098 100644 --- a/index.js +++ b/index.js @@ -107,18 +107,29 @@ const PORT = process.env.PORT || 3000; const frontendUrl = process.env.DOMAIN; console.log("Frontend URL:", frontendUrl); + +const allowedOrigins = [ + "https://www.fedkiit.com", + "https://fedkiit.com", + "http://localhost:5173", + "http://localhost:3000" +]; + // Middlewares app.use(express.json({ limit: '16kb' })); app.use(express.urlencoded({ extended: true, limit: "16kb" })); app.use(cookieParser()); app.use(cors({ - origin: function (origin, callback) { - // Allow all origins - return true to allow any origin with credentials - callback(null, true); - }, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - credentials: true, + origin: (origin, callback) => { + if (!origin) return callback(null, true); + if (allowedOrigins.includes(origin)) { + return callback(null, origin); + } + callback(null, false); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], })); app.options('*', cors()); // handle preflight requests From 67139829e2c71381fd5023daaddf851440f308d1 Mon Sep 17 00:00:00 2001 From: Ritam-Vaskar Date: Tue, 13 Jan 2026 02:23:15 +0530 Subject: [PATCH 05/15] Refactor CORS middleware to use shared options for normal and preflight requests --- index.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index e038098..60d3e4f 100644 --- a/index.js +++ b/index.js @@ -115,23 +115,30 @@ const allowedOrigins = [ "http://localhost:3000" ]; -// Middlewares -app.use(express.json({ limit: '16kb' })); -app.use(express.urlencoded({ extended: true, limit: "16kb" })); -app.use(cookieParser()); - -app.use(cors({ +// Shared CORS options for both normal and preflight requests +const corsOptions = { origin: (origin, callback) => { if (!origin) return callback(null, true); if (allowedOrigins.includes(origin)) { - return callback(null, origin); + return callback(null, true); // reflect request origin } callback(null, false); }, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], -})); -app.options('*', cors()); // handle preflight requests + allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], + exposedHeaders: ["Content-Length"] +}; + +// Middlewares +app.use(express.json({ limit: '16kb' })); +app.use(express.urlencoded({ extended: true, limit: "16kb" })); +app.use(cookieParser()); + +// Apply CORS for normal requests +app.use(cors(corsOptions)); +// Ensure preflight responses use the same options (no wildcard when credentials) +app.options('*', cors(corsOptions)); // const allowedOrigins = [ // "https://fedkiit.com", From 5ff99df52a817fa5e7ac9407e7c85fc6048550a4 Mon Sep 17 00:00:00 2001 From: Ritam Vaskar Date: Thu, 15 Jan 2026 00:35:28 +0530 Subject: [PATCH 06/15] Refactor Dockerfile for efficiency and Puppeteer config Updated Dockerfile to streamline package installation and skip Chromium download. --- Dockerfile | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index fad2978..88995f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,7 @@ RUN apt-get update && apt-get install -y \ xdg-utils \ wget \ --no-install-recommends && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* + apt-get clean && rm -rf /var/lib/apt/lists/* # Set working directory WORKDIR /app @@ -43,17 +42,20 @@ WORKDIR /app ARG DATABASE_URL ENV DATABASE_URL=${DATABASE_URL} -# Copy package files and install deps +# Skip Puppeteer Chromium download +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + +# Copy package files and install dependencies COPY package*.json ./ -RUN npm install --omit=dev +RUN npm ci --omit=dev -# Copy source +# Copy source code COPY . . # Make build.sh executable RUN chmod +x ./build.sh -# Expose app port +# Expose port EXPOSE 5000 # Start From b65fa00552b72987b2327bc2d9890c77dd3ffcf5 Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Thu, 15 Jan 2026 01:59:39 +0530 Subject: [PATCH 07/15] feat: Migrate email from Nodemailer to Resend API with fallback support --- config/nodeMailer.js | 19 ++++++ config/resend.js | 7 ++ package-lock.json | 53 +++++++++++++++ package.json | 1 + utils/email/nodeMailer.js | 133 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 config/resend.js diff --git a/config/nodeMailer.js b/config/nodeMailer.js index effadfd..bc74138 100644 --- a/config/nodeMailer.js +++ b/config/nodeMailer.js @@ -1,4 +1,12 @@ // config/nodeMailer.js +// DEPRECATED: This file has been replaced by Resend API +// See: config/resend.js and utils/email/nodeMailer.js for new implementation +// Date: January 2026 + +/* ============================================================ + ORIGINAL NODEMAILER CONFIGURATION (COMMENTED OUT) + ============================================================ + const nodemailer = require("nodemailer"); // Primary transporter (e.g., Gmail) @@ -43,3 +51,14 @@ module.exports = { tertiary: mailTransporterTertiary, mailerSend: mailTransporterMailerSend, }; + +============================================================ */ + +// Export empty object to prevent import errors during transition +// This file is no longer used - Resend API is used instead +module.exports = { + primary: null, + secondary: null, + tertiary: null, + mailerSend: null, +}; diff --git a/config/resend.js b/config/resend.js new file mode 100644 index 0000000..85eed5c --- /dev/null +++ b/config/resend.js @@ -0,0 +1,7 @@ +// config/resend.js +const { Resend } = require('resend'); + +// Initialize Resend with API key from environment variable +const resend = new Resend(process.env.RESEND_API_KEY); + +module.exports = { resend }; diff --git a/package-lock.json b/package-lock.json index 06b43cd..a8b2efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "puppeteer-extra-plugin-stealth": "^2.11.2", "qrcode": "^1.5.4", "react-otp-input": "^3.1.1", + "resend": "^6.7.0", "sharp": "^0.33.4", "uuid": "^10.0.0", "xlsx": "^0.18.5" @@ -745,6 +746,12 @@ "node": ">=12" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -2379,6 +2386,12 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -4870,6 +4883,26 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resend": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.7.0.tgz", + "integrity": "sha512-2ZV0NDZsh4Gh+Nd1hvluZIitmGJ59O4+OxMufymG6Y8uz1Jgt2uS1seSENnkIUlmwg7/dwmfIJC9rAufByz7wA==", + "license": "MIT", + "dependencies": { + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5309,6 +5342,16 @@ "node": ">=0.8" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -5392,6 +5435,16 @@ "node": ">=4" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tar-fs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", diff --git a/package.json b/package.json index 5703c01..05733d2 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "puppeteer-extra-plugin-stealth": "^2.11.2", "qrcode": "^1.5.4", "react-otp-input": "^3.1.1", + "resend": "^6.7.0", "sharp": "^0.33.4", "uuid": "^10.0.0", "xlsx": "^0.18.5" diff --git a/utils/email/nodeMailer.js b/utils/email/nodeMailer.js index f0ed3fe..531217d 100644 --- a/utils/email/nodeMailer.js +++ b/utils/email/nodeMailer.js @@ -1,3 +1,134 @@ +// RESEND API IMPLEMENTATION WITH FALLBACK +// Replaced Nodemailer with Resend API for email sending +// Supports multiple sender domains with automatic fallback +// Date: January 2026 + +const { resend } = require("../../config/resend"); + +/** + * Get list of sender emails from environment variables + * Supports EMAIL_FROM, EMAIL_FROM_2, EMAIL_FROM_3, etc. + * @returns {Array} Array of sender emails + */ +function getSenderEmails() { + const senders = []; + + // Primary sender + if (process.env.EMAIL_FROM) { + senders.push(process.env.EMAIL_FROM); + } + + // Secondary sender + if (process.env.EMAIL_FROM_2) { + senders.push(process.env.EMAIL_FROM_2); + } + + // Tertiary sender + if (process.env.EMAIL_FROM_3) { + senders.push(process.env.EMAIL_FROM_3); + } + + // Add more as needed (EMAIL_FROM_4, EMAIL_FROM_5, etc.) + for (let i = 4; i <= 10; i++) { + const envKey = `EMAIL_FROM_${i}`; + if (process.env[envKey]) { + senders.push(process.env[envKey]); + } + } + + // Default fallback if no senders configured + if (senders.length === 0) { + senders.push('FED KIIT Compliance '); + } + + return senders; +} + +/** + * Send email using Resend API with automatic fallback to secondary domains + * @param {string} to - Recipient email address + * @param {string} subject - Email subject + * @param {string} htmlContent - HTML content of the email + * @param {string} textContent - Plain text content (optional, auto-generated from HTML if not provided) + * @param {Array} attachments - Array of attachment objects (optional) + * Format: [{ filename: 'file.pdf', content: Buffer }] + */ +async function sendMail(to, subject, htmlContent, textContent, attachments = []) { + // Get all configured sender emails + const senderEmails = getSenderEmails(); + + // Convert attachments to Resend format if present + const resendAttachments = attachments.map(att => ({ + filename: att.filename, + content: att.content, + })); + + // Build base email options + const baseEmailOptions = { + to: to, + subject: subject, + html: htmlContent, + text: textContent || htmlContent.replace(/<[^>]+>/g, ""), + reply_to: "fedkiit@gmail.com", + }; + + // Add attachments if present + if (resendAttachments.length > 0) { + baseEmailOptions.attachments = resendAttachments; + } + + // Try each sender in sequence until one succeeds + let lastError = null; + + for (let i = 0; i < senderEmails.length; i++) { + const senderEmail = senderEmails[i]; + const isLastAttempt = i === senderEmails.length - 1; + + try { + console.log(`[Resend] Attempting to send email using sender ${i + 1}: ${senderEmail}`); + + const emailOptions = { + ...baseEmailOptions, + from: senderEmail, + }; + + const { data, error } = await resend.emails.send(emailOptions); + + if (error) { + console.error(`[Resend] Sender ${i + 1} failed:`, error.message); + lastError = error; + + if (!isLastAttempt) { + console.log(`[Resend] Trying fallback sender...`); + continue; // Try next sender + } + } else { + console.log(`[Resend] Email sent successfully via sender ${i + 1}:`, data); + return data; + } + } catch (error) { + console.error(`[Resend] Sender ${i + 1} threw exception:`, error.message); + lastError = error; + + if (!isLastAttempt) { + console.log(`[Resend] Trying fallback sender...`); + continue; // Try next sender + } + } + } + + // All senders failed + console.error("[Resend] All email senders failed. Last error:", lastError); + throw new Error(`Email sending failed after trying ${senderEmails.length} sender(s): ${lastError?.message || 'Unknown error'}`); +} + +module.exports = { sendMail }; + + +/* ============================================================ + ORIGINAL NODEMAILER IMPLEMENTATION (COMMENTED OUT) + ============================================================ + const { primary, secondary, tertiary, mailerSend } = require("../../config/nodeMailer"); function sendMail(to, subject, htmlContent, textContent, attachments = []) { @@ -58,3 +189,5 @@ function sendMail(to, subject, htmlContent, textContent, attachments = []) { } module.exports = { sendMail }; + +============================================================ */ From ce59708227ca0698c422081363b78c62ecdea780 Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Thu, 15 Jan 2026 02:09:40 +0530 Subject: [PATCH 08/15] feat: Migrate email from Nodemailer to Resend API --- config/resend.js | 17 +-- utils/email/nodeMailer.js | 221 ++++++++++++-------------------------- 2 files changed, 78 insertions(+), 160 deletions(-) diff --git a/config/resend.js b/config/resend.js index 85eed5c..c85da16 100644 --- a/config/resend.js +++ b/config/resend.js @@ -1,7 +1,12 @@ -// config/resend.js -const { Resend } = require('resend'); +/** + * RESEND EMAIL CONFIGURATION + * This file is kept for backward compatibility but is no longer used. + * Email sending is now handled directly in utils/email/nodeMailer.js + * + * The Resend clients are initialized directly in nodeMailer.js using: + * - RESEND_API_KEY + EMAIL_FROM (primary) + * - RESEND_API_KEY_2 + EMAIL_FROM_2 (fallback) + */ -// Initialize Resend with API key from environment variable -const resend = new Resend(process.env.RESEND_API_KEY); - -module.exports = { resend }; +// This file is deprecated - Resend clients are created in nodeMailer.js +module.exports = {}; diff --git a/utils/email/nodeMailer.js b/utils/email/nodeMailer.js index 531217d..ca30675 100644 --- a/utils/email/nodeMailer.js +++ b/utils/email/nodeMailer.js @@ -1,70 +1,45 @@ -// RESEND API IMPLEMENTATION WITH FALLBACK -// Replaced Nodemailer with Resend API for email sending -// Supports multiple sender domains with automatic fallback -// Date: January 2026 - -const { resend } = require("../../config/resend"); - /** - * Get list of sender emails from environment variables - * Supports EMAIL_FROM, EMAIL_FROM_2, EMAIL_FROM_3, etc. - * @returns {Array} Array of sender emails + * RESEND EMAIL SERVICE + * Production-ready email sending with 2-domain fallback support + * + * Environment Variables Required: + * - RESEND_API_KEY: API key for primary domain + * - EMAIL_FROM: Primary sender (e.g., "FED KIIT ") + * - RESEND_API_KEY_2: API key for fallback domain + * - EMAIL_FROM_2: Fallback sender (e.g., "FED KIIT ") */ -function getSenderEmails() { - const senders = []; - // Primary sender - if (process.env.EMAIL_FROM) { - senders.push(process.env.EMAIL_FROM); - } - - // Secondary sender - if (process.env.EMAIL_FROM_2) { - senders.push(process.env.EMAIL_FROM_2); - } - - // Tertiary sender - if (process.env.EMAIL_FROM_3) { - senders.push(process.env.EMAIL_FROM_3); - } - - // Add more as needed (EMAIL_FROM_4, EMAIL_FROM_5, etc.) - for (let i = 4; i <= 10; i++) { - const envKey = `EMAIL_FROM_${i}`; - if (process.env[envKey]) { - senders.push(process.env[envKey]); - } - } +const { Resend } = require("resend"); - // Default fallback if no senders configured - if (senders.length === 0) { - senders.push('FED KIIT Compliance '); - } +// Initialize Resend clients for both domains +const resendPrimary = process.env.RESEND_API_KEY + ? new Resend(process.env.RESEND_API_KEY) + : null; - return senders; -} +const resendSecondary = process.env.RESEND_API_KEY_2 + ? new Resend(process.env.RESEND_API_KEY_2) + : null; /** - * Send email using Resend API with automatic fallback to secondary domains + * Send email using Resend API with automatic fallback + * Tries primary domain first, falls back to secondary if primary fails + * * @param {string} to - Recipient email address * @param {string} subject - Email subject * @param {string} htmlContent - HTML content of the email - * @param {string} textContent - Plain text content (optional, auto-generated from HTML if not provided) - * @param {Array} attachments - Array of attachment objects (optional) - * Format: [{ filename: 'file.pdf', content: Buffer }] + * @param {string} textContent - Plain text content (optional, auto-generated from HTML) + * @param {Array} attachments - Array of attachments [{filename, content}] + * @returns {Promise} - Resend response data + * @throws {Error} - If both primary and secondary fail */ async function sendMail(to, subject, htmlContent, textContent, attachments = []) { - // Get all configured sender emails - const senderEmails = getSenderEmails(); - - // Convert attachments to Resend format if present - const resendAttachments = attachments.map(att => ({ - filename: att.filename, - content: att.content, - })); + // Validate environment + if (!resendPrimary && !resendSecondary) { + throw new Error("[Email] No Resend API keys configured. Set RESEND_API_KEY in .env"); + } - // Build base email options - const baseEmailOptions = { + // Build email options + const emailOptions = { to: to, subject: subject, html: htmlContent, @@ -73,121 +48,59 @@ async function sendMail(to, subject, htmlContent, textContent, attachments = []) }; // Add attachments if present - if (resendAttachments.length > 0) { - baseEmailOptions.attachments = resendAttachments; + if (attachments && attachments.length > 0) { + emailOptions.attachments = attachments.map(att => ({ + filename: att.filename, + content: att.content, + })); } - // Try each sender in sequence until one succeeds - let lastError = null; - - for (let i = 0; i < senderEmails.length; i++) { - const senderEmail = senderEmails[i]; - const isLastAttempt = i === senderEmails.length - 1; - + // ============ TRY PRIMARY SENDER ============ + if (resendPrimary && process.env.EMAIL_FROM) { try { - console.log(`[Resend] Attempting to send email using sender ${i + 1}: ${senderEmail}`); - - const emailOptions = { - ...baseEmailOptions, - from: senderEmail, - }; - - const { data, error } = await resend.emails.send(emailOptions); + console.log(`[Email] Sending via PRIMARY: ${process.env.EMAIL_FROM}`); - if (error) { - console.error(`[Resend] Sender ${i + 1} failed:`, error.message); - lastError = error; + const { data, error } = await resendPrimary.emails.send({ + ...emailOptions, + from: process.env.EMAIL_FROM, + }); - if (!isLastAttempt) { - console.log(`[Resend] Trying fallback sender...`); - continue; // Try next sender - } - } else { - console.log(`[Resend] Email sent successfully via sender ${i + 1}:`, data); + if (!error && data) { + console.log(`[Email] SUCCESS via PRIMARY:`, data.id); return data; } - } catch (error) { - console.error(`[Resend] Sender ${i + 1} threw exception:`, error.message); - lastError = error; - if (!isLastAttempt) { - console.log(`[Resend] Trying fallback sender...`); - continue; // Try next sender - } + console.error(`[Email] PRIMARY failed:`, error?.message || "Unknown error"); + } catch (err) { + console.error(`[Email] PRIMARY exception:`, err.message); } } - // All senders failed - console.error("[Resend] All email senders failed. Last error:", lastError); - throw new Error(`Email sending failed after trying ${senderEmails.length} sender(s): ${lastError?.message || 'Unknown error'}`); -} - -module.exports = { sendMail }; - - -/* ============================================================ - ORIGINAL NODEMAILER IMPLEMENTATION (COMMENTED OUT) - ============================================================ - -const { primary, secondary, tertiary, mailerSend } = require("../../config/nodeMailer"); - -function sendMail(to, subject, htmlContent, textContent, attachments = []) { - const mailDetails = { - from: `"FED KIIT Compliance" <${process.env.MAIL_USER}>`, - to, - subject, - replyTo: "fedkiit@gmail.com", - html: htmlContent, - text: textContent || htmlContent.replace(/<[^>]+>/g, ""), - ...(attachments.length > 0 && { attachments }), - }; + // ============ TRY SECONDARY SENDER (FALLBACK) ============ + if (resendSecondary && process.env.EMAIL_FROM_2) { + try { + console.log(`[Email] Sending via SECONDARY: ${process.env.EMAIL_FROM_2}`); - // Try sending with primary - primary.sendMail(mailDetails, (err, info) => { - if (err) { - console.error("Primary email failed:", err); - - // Try fallback sender - const fallbackDetails = { - ...mailDetails, - from: process.env.MAIL_USER_SECONDARY, - }; - - secondary.sendMail(fallbackDetails, (err2, info2) => { - if (err2) { - console.error("Secondary email also failed:", err2); - - // Try tertiary sender - const tertiaryDetails = { - ...mailDetails, - from: process.env.MAIL_USER_TERTIARY, - }; - tertiary.sendMail(tertiaryDetails, (err3, info3) => { - if (err3) { - mailerSend.sendMail({ ...mailDetails, from: '"FED KIIT Compliance" ' }, (err4, info4) => { - if (err4) { - console.error("MailerSend email also failed:", err4); - } else { - console.log("MailerSend email sent successfully:", info4); - } - }); - console.error("Tertiary email also failed:", err3); - } - else { - console.log("Tertiary email sent successfully:", info3); - } - }); - } else { - console.log("Fallback email sent successfully:", info2); - } + const { data, error } = await resendSecondary.emails.send({ + ...emailOptions, + from: process.env.EMAIL_FROM_2, }); - } else { - console.log("Primary email sent successfully:", info); + if (!error && data) { + console.log(`[Email] SUCCESS via SECONDARY:`, data.id); + return data; + } + + console.error(`[Email] SECONDARY failed:`, error?.message || "Unknown error"); + throw new Error(`Email failed: ${error?.message || "Secondary sender failed"}`); + } catch (err) { + console.error(`[Email] SECONDARY exception:`, err.message); + throw err; } - }); + } + + // Both failed or not configured + throw new Error("[Email] All email senders failed or not configured"); } module.exports = { sendMail }; - -============================================================ */ From f1bb7feff2f56e6f23291f6276e214fe9e8d240c Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Thu, 26 Feb 2026 01:37:38 +0530 Subject: [PATCH 09/15] Team event members backend with Unstop Features --- Services/team.js | 65 ----- controllers/registration/addRegistration.js | 154 ++++++----- .../checkAllJoinRequestUpdates.js | 54 ++++ .../registration/checkJoinRequestUpdates.js | 70 +++++ controllers/registration/createTeam.js | 113 ++++++++ controllers/registration/getTeamDetails.js | 32 ++- controllers/registration/getTeamInviteLink.js | 63 +++++ controllers/registration/inviteTeamMember.js | 110 ++++++++ controllers/registration/joinTeam.js | 135 ++++++++++ controllers/registration/leaveTeam.js | 145 ++++++++++ .../registration/registrationController.js | 24 ++ controllers/registration/renameTeam.js | 106 ++++++++ .../registration/respondJoinRequest.js | 247 ++++++++++++++++++ controllers/registration/searchTeams.js | 100 +++++++ controllers/registration/sendJoinRequest.js | 162 ++++++++++++ emailTemplates/teamInvitation.html | 96 +++++++ emailTemplates/teamJoinAccepted.html | 87 ++++++ emailTemplates/teamJoinRejected.html | 89 +++++++ emailTemplates/teamJoinRequest.html | 140 ++++++++++ prisma/schema/teamJoinRequest.prisma | 18 ++ routes/api/forms/formRoutes.js | 22 +- 21 files changed, 1889 insertions(+), 143 deletions(-) delete mode 100644 Services/team.js create mode 100644 controllers/registration/checkAllJoinRequestUpdates.js create mode 100644 controllers/registration/checkJoinRequestUpdates.js create mode 100644 controllers/registration/createTeam.js create mode 100644 controllers/registration/getTeamInviteLink.js create mode 100644 controllers/registration/inviteTeamMember.js create mode 100644 controllers/registration/joinTeam.js create mode 100644 controllers/registration/leaveTeam.js create mode 100644 controllers/registration/renameTeam.js create mode 100644 controllers/registration/respondJoinRequest.js create mode 100644 controllers/registration/searchTeams.js create mode 100644 controllers/registration/sendJoinRequest.js create mode 100644 emailTemplates/teamInvitation.html create mode 100644 emailTemplates/teamJoinAccepted.html create mode 100644 emailTemplates/teamJoinRejected.html create mode 100644 emailTemplates/teamJoinRequest.html create mode 100644 prisma/schema/teamJoinRequest.prisma diff --git a/Services/team.js b/Services/team.js deleted file mode 100644 index 2e49947..0000000 --- a/Services/team.js +++ /dev/null @@ -1,65 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); -const prisma = new PrismaClient(); -const { ApiError } = require("../utils/error/ApiError"); -// const { nanoid } = require('nanoid'); -const status = require("http-status"); - -const getTeam = async (id, teamCode, data) => { - try { - const teams = await prisma.team.findFirst({ - where: { - OR: [{ userId: id }, { teamId: teamCode }], - }, - }); - - if (!teams) { - if (data) { - return await createTeam(data); - } - - throw new ApiError(status.NOT_FOUND, "Team not found"); - } - - await prisma.participant.create({ - data: { - userId: id, - type: 1, // Member - formId: data.formId, - team: { connect: { id: teams.id } }, - }, - }); - - return teams; - } catch (error) { - throw new ApiError(status.INTERNAL_SERVER_ERROR, "Error fetching team"); - } -}; - -const createTeam = async (data) => { - try { - const newTeam = await prisma.team.create({ - data: { - teamId: `OM_`, - user: { connect: { id: data.userId } }, - form: { connect: { id: data.formId } }, - teamName: data.teamName, - participants: { - create: data.participants.map((participant) => ({ - user: { connect: { id: participant.userId } }, - type: participant.type, - formId: participant.formId, - })), - }, - }, - }); - - if (!newTeam) { - throw new ApiError(status.BAD_REQUEST, "Team not created"); - } - return newTeam; - } catch (error) { - throw new ApiError(status.INTERNAL_SERVER_ERROR, "Error creating team"); - } -}; - -module.exports = { getTeam, createTeam }; diff --git a/controllers/registration/addRegistration.js b/controllers/registration/addRegistration.js index bcf5e89..55dab11 100644 --- a/controllers/registration/addRegistration.js +++ b/controllers/registration/addRegistration.js @@ -17,7 +17,8 @@ const validateCurrentForm = expressAsyncHandler(async (form, user, userSubmitted throw new ApiError(400, "Sorry ! Registration has been closed for this event. If you feel this is an error, kindly contact us on fedkiit@gmail.com"); } - if (!isPublic && req.user.access != AccessTypes.ADMIN) { + // [v1] if (!isPublic && req.user.access != AccessTypes.ADMIN) { + if (!isPublic && user.access != AccessTypes.ADMIN) { throw new ApiError(401, "Registering to a private form is not allowed. If you feel this is an error, kindly contact us on fedkiit@gmail.com"); } @@ -117,78 +118,91 @@ const addRegistration = expressAsyncHandler(async (req, res, next) => { if (info.participationType !== "Individual") { - // console.log("related", relatedEventForm.info.eventTitle) - // console.log("eventTitle", info.eventTitle) - // console.log("count", form.formAnalytics[0]?.regUserEmails.length); - teamCode = await generateTeamCode(relatedEventForm?.info.eventTitle, info.eventTitle, form.formAnalytics[0]?.regUserEmails.length); - - - createTeamSection = sections.find(section => section.name === "Create Team"); - joinTeamSection = !createTeamSection ? sections.find(section => section.name === "Join Team") : null; - - - if (createTeamSection) { - const teamNameField = createTeamSection.fields.find(field => field.name === "Team Name"); - if (teamNameField) { - teamName = [teamNameField.value.toUpperCase().trim()]; - if (form.formAnalytics[0]?.regTeamNames.includes(teamName[0])) { - return next(new ApiError(400, "! This team name already taken !\n Please choose a different one.")); - } - regTeamMemEmails.push(req.user.email); - } else { - return next(new ApiError(400, "Team Name field is required for Create Team")); - } - } else if (joinTeamSection) { - const teamCodeField = joinTeamSection.fields.find(field => field.name === "Team Code"); - - if (teamCodeField) { - teamExists = await prisma.formRegistration.findUnique({ - where: { - formId_teamCode: { - formId: _id, - teamCode: teamCodeField.value - } - }, - }); - - if (!teamExists) { - console.log("Team does not exist"); - return next(new ApiError(404, "Invalid team code")); - } - - if (teamExists.regTeamMemEmails.length >= (parseInt(info.maxTeamSize) || 1)) { - console.log("team full"); - return next(new ApiError(400, "This team is full")); - } - - // Log the teamExists object in a readable format - console.log("team Exists", JSON.stringify(teamExists, null, 2)); - - - teamName = [teamExists.teamName]; - // console.log("team name array joining creating team", teamName) - // teamName = [...new Set([...teamName, ...(form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : [])])]; - console.log("team name before ", teamName) - console.log("existing team names", form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : []); - - // console.log("Team name array after joining team") - - teamCode = teamCodeField.value; - regTeamMemEmails = [...teamExists.regTeamMemEmails, req.user.email]; - } - - - // sections.user_id = req.user.id; - // sections.user_email = req.user.email; - // sections.user_name = req.user.name; - - - // sectionsObject.push({ sections }); - } + // [v2] — Teamless registration: always register as UNAFFILIATED + // Team creation/joining now happens post-registration on the Team Management page + teamCode = `SOLO-${req.user.id}-${Math.floor(1000 + Math.random() * 9000)}`; + teamName = ["UNAFFILIATED"]; + regTeamMemEmails.push(req.user.email); + + // [v1] Old team section detection logic — commented out, not deleted + // [v1] // console.log("related", relatedEventForm.info.eventTitle) + // [v1] // console.log("eventTitle", info.eventTitle) + // [v1] // console.log("count", form.formAnalytics[0]?.regUserEmails.length); + // [v1] teamCode = await generateTeamCode(relatedEventForm?.info.eventTitle, info.eventTitle, form.formAnalytics[0]?.regUserEmails.length); + // [v1] + // [v1] + // [v1] createTeamSection = sections.find(section => section.name === "Create Team"); + // [v1] joinTeamSection = !createTeamSection ? sections.find(section => section.name === "Join Team") : null; + // [v1] + // [v1] + // [v1] if (createTeamSection) { + // [v1] const teamNameField = createTeamSection.fields.find(field => field.name === "Team Name"); + // [v1] if (teamNameField) { + // [v1] teamName = [teamNameField.value.toUpperCase().trim()]; + // [v1] if (form.formAnalytics[0]?.regTeamNames.includes(teamName[0])) { + // [v1] return next(new ApiError(400, "! This team name already taken !\n Please choose a different one.")); + // [v1] } + // [v1] regTeamMemEmails.push(req.user.email); + // [v1] } else { + // [v1] return next(new ApiError(400, "Team Name field is required for Create Team")); + // [v1] } + // [v1] } else if (joinTeamSection) { + // [v1] const teamCodeField = joinTeamSection.fields.find(field => field.name === "Team Code"); + // [v1] + // [v1] if (teamCodeField) { + // [v1] teamExists = await prisma.formRegistration.findUnique({ + // [v1] where: { + // [v1] formId_teamCode: { + // [v1] formId: _id, + // [v1] teamCode: teamCodeField.value + // [v1] } + // [v1] }, + // [v1] }); + // [v1] + // [v1] if (!teamExists) { + // [v1] console.log("Team does not exist"); + // [v1] return next(new ApiError(404, "Invalid team code")); + // [v1] } + // [v1] + // [v1] if (teamExists.regTeamMemEmails.length >= (parseInt(info.maxTeamSize) || 1)) { + // [v1] console.log("team full"); + // [v1] return next(new ApiError(400, "This team is full")); + // [v1] } + // [v1] + // [v1] // Log the teamExists object in a readable format + // [v1] console.log("team Exists", JSON.stringify(teamExists, null, 2)); + // [v1] + // [v1] + // [v1] teamName = [teamExists.teamName]; + // [v1] // console.log("team name array joining creating team", teamName) + // [v1] // teamName = [...new Set([...teamName, ...(form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : [])])]; + // [v1] console.log("team name before ", teamName) + // [v1] console.log("existing team names", form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : []); + // [v1] + // [v1] // console.log("Team name array after joining team") + // [v1] + // [v1] teamCode = teamCodeField.value; + // [v1] regTeamMemEmails = [...teamExists.regTeamMemEmails, req.user.email]; + // [v1] } + // [v1] + // [v1] + // [v1] // sections.user_id = req.user.id; + // [v1] // sections.user_email = req.user.email; + // [v1] // sections.user_name = req.user.name; + // [v1] + // [v1] + // [v1] // sectionsObject.push({ sections }); + // [v1] } } - formTrackerTeamNameList = [...new Set([...teamName, ...(form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : [])])]; + // [v2] For teamless (UNAFFILIATED) registrations, do NOT push team name to tracker + if (teamName[0] !== "UNAFFILIATED") { + formTrackerTeamNameList = [...new Set([...teamName, ...(form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : [])])]; + } else { + formTrackerTeamNameList = form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : []; + } + // [v1] formTrackerTeamNameList = [...new Set([...teamName, ...(form.formAnalytics?.length > 0 ? form.formAnalytics[0].regTeamNames : [])])]; console.log("set data ", formTrackerTeamNameList); console.log("reg team members ", regTeamMemEmails) diff --git a/controllers/registration/checkAllJoinRequestUpdates.js b/controllers/registration/checkAllJoinRequestUpdates.js new file mode 100644 index 0000000..8062aa9 --- /dev/null +++ b/controllers/registration/checkAllJoinRequestUpdates.js @@ -0,0 +1,54 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Get ALL unseen join request updates across all events for the current user +//@route GET /api/form/allJoinRequestUpdates +//@access Private (USER) +// Used globally on login/app mount to show rejection/acceptance toasts +const checkAllJoinRequestUpdates = expressAsyncHandler(async (req, res, next) => { + try { + const { email } = req.user; + + // Fetch all resolved requests that the requester hasn't seen yet + const unseenUpdates = await prisma.teamJoinRequest.findMany({ + where: { + requesterEmail: email, + status: { not: "PENDING" }, + seenByRequester: false + }, + select: { + id: true, + status: true, + teamName: true, + formId: true, + respondedAt: true, + createdAt: true + }, + orderBy: { respondedAt: "desc" } + }); + + // Mark them as seen + if (unseenUpdates.length > 0) { + await prisma.teamJoinRequest.updateMany({ + where: { + id: { in: unseenUpdates.map(u => u.id) } + }, + data: { seenByRequester: true } + }); + } + + res.status(200).json({ + success: true, + data: { updates: unseenUpdates } + }); + + } catch (error) { + console.error("Error in checkAllJoinRequestUpdates:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error checking join request updates", error)); + } +}); + +module.exports = { checkAllJoinRequestUpdates }; diff --git a/controllers/registration/checkJoinRequestUpdates.js b/controllers/registration/checkJoinRequestUpdates.js new file mode 100644 index 0000000..c96b35a --- /dev/null +++ b/controllers/registration/checkJoinRequestUpdates.js @@ -0,0 +1,70 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Get unseen join request updates (accepted/rejected/expired) for the current user +//@route GET /api/form/joinRequestUpdates/:formId +//@access Private (USER) +const checkJoinRequestUpdates = expressAsyncHandler(async (req, res, next) => { + try { + const { formId } = req.params; + const { email } = req.user; + + if (!formId) { + return next(new ApiError(400, "Form ID is required")); + } + + // Fetch resolved requests that the requester hasn't seen yet + const unseenUpdates = await prisma.teamJoinRequest.findMany({ + where: { + formId, + requesterEmail: email, + status: { not: "PENDING" }, + seenByRequester: false + }, + select: { + id: true, + status: true, + teamName: true, + respondedAt: true, + createdAt: true + }, + orderBy: { respondedAt: "desc" } + }); + + // Also return count of currently pending requests + const pendingCount = await prisma.teamJoinRequest.count({ + where: { + formId, + requesterEmail: email, + status: "PENDING" + } + }); + + // Mark them as seen + if (unseenUpdates.length > 0) { + await prisma.teamJoinRequest.updateMany({ + where: { + id: { in: unseenUpdates.map(u => u.id) } + }, + data: { seenByRequester: true } + }); + } + + res.status(200).json({ + success: true, + data: { + updates: unseenUpdates, + pendingCount + } + }); + + } catch (error) { + console.error("Error in checkJoinRequestUpdates:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error checking join request updates", error)); + } +}); + +module.exports = { checkJoinRequestUpdates }; diff --git a/controllers/registration/createTeam.js b/controllers/registration/createTeam.js new file mode 100644 index 0000000..c93a255 --- /dev/null +++ b/controllers/registration/createTeam.js @@ -0,0 +1,113 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Create a team (teamless user sets team name, becomes leader) +//@route POST /api/form/createTeam +//@access Private (USER) +const createTeam = expressAsyncHandler(async (req, res, next) => { + try { + const { formId, teamName } = req.body; + const { email, id: userId } = req.user; + + if (!formId || !teamName) { + return next(new ApiError(400, "Form ID and team name are required")); + } + + const trimmedName = teamName.trim().toUpperCase(); + if (!trimmedName) { + return next(new ApiError(400, "Team name cannot be empty")); + } + + // Find user's teamless registration + const userRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: email } + }, + include: { + form: { + select: { info: true } + } + } + }); + + if (!userRegistration) { + return next(new ApiError(404, "You are not registered for this event. Please register first.")); + } + + // Verify user is currently teamless + if (userRegistration.teamName !== "UNAFFILIATED") { + return next(new ApiError(400, "You are already on a team. Leave your current team first.")); + } + + // Verify user is the owner of this registration (they should be, since it's their solo record) + if (userRegistration.userId !== userId) { + return next(new ApiError(403, "Registration mismatch")); + } + + const { info } = userRegistration.form; + + // Check if registration is still open + if (info.isRegistrationClosed === 'true' || info.isEventPast === 'true') { + return next(new ApiError(400, "Registration is closed. Team creation is no longer allowed.")); + } + + // Check if team name is already taken + const tracker = await prisma.registrationTracker.findUnique({ + where: { formId } + }); + + if (tracker?.regTeamNames.includes(trimmedName)) { + return next(new ApiError(400, "This team name is already taken. Please choose a different one.")); + } + + // Generate a proper team code + const eventTitle = info.eventTitle || "EV"; + const eventCode = eventTitle.slice(0, 2).toUpperCase(); + const randomNum = Math.floor(1000 + Math.random() * 9000).toString(); + const teamCount = (tracker?.regTeamNames?.length || 0).toString().padStart(3, '0'); + const newTeamCode = `${eventCode}-${teamCount}-${randomNum}`; + + // Transaction: update registration + update tracker + const result = await prisma.$transaction(async (tx) => { + // Update the user's solo registration to become a team + const updatedReg = await tx.formRegistration.update({ + where: { id: userRegistration.id }, + data: { + teamName: trimmedName, + teamCode: newTeamCode + } + }); + + // Add team name to registration tracker + await tx.registrationTracker.update({ + where: { formId }, + data: { + regTeamNames: { + push: trimmedName + } + } + }); + + return updatedReg; + }); + + res.status(200).json({ + success: true, + message: `Team "${trimmedName}" created successfully!`, + data: { + teamName: result.teamName, + teamCode: result.teamCode + } + }); + + } catch (error) { + console.error("Error in createTeam:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error creating team", error)); + } +}); + +module.exports = { createTeam }; diff --git a/controllers/registration/getTeamDetails.js b/controllers/registration/getTeamDetails.js index 3779f18..f9febf1 100644 --- a/controllers/registration/getTeamDetails.js +++ b/controllers/registration/getTeamDetails.js @@ -59,13 +59,41 @@ const getTeamDetails = expressAsyncHandler(async (req, res, next) => { return next(new ApiError(400, "This is not a team event")); } + // [v2] Check if user is teamless (UNAFFILIATED) + if (teamRegistration.teamName === "UNAFFILIATED") { + return res.status(200).json({ + success: true, + message: "User is registered but not yet on a team", + data: { + isTeamless: true, + eventTitle: teamRegistration.form.info.eventTitle, + maxTeamSize: parseInt(teamRegistration.form.info.maxTeamSize) || 1, + minTeamSize: parseInt(teamRegistration.form.info.minTeamSize) || 1, + isRegistrationClosed: teamRegistration.form.info.isRegistrationClosed || false, + isEventPast: teamRegistration.form.info.isEventPast || false, + formId: teamRegistration.formId, + } + }); + } + + // Fetch leader's email for identification + const leaderUser = await prisma.user.findUnique({ + where: { id: teamRegistration.userId }, + select: { email: true } + }); + const teamDetails = { teamName: teamRegistration.teamName, teamCode: teamRegistration.teamCode, teamSize: teamRegistration.teamSize, - maxTeamSize: teamRegistration.form.info.maxTeamSize || 1, + maxTeamSize: parseInt(teamRegistration.form.info.maxTeamSize) || 1, + minTeamSize: parseInt(teamRegistration.form.info.minTeamSize) || 1, members: teamMembers, - eventTitle: teamRegistration.form.info.eventTitle + eventTitle: teamRegistration.form.info.eventTitle, + leaderEmail: leaderUser?.email || null, + isRegistrationClosed: teamRegistration.form.info.isRegistrationClosed || false, + isEventPast: teamRegistration.form.info.isEventPast || false, + formId: teamRegistration.formId, }; res.status(200).json({ diff --git a/controllers/registration/getTeamInviteLink.js b/controllers/registration/getTeamInviteLink.js new file mode 100644 index 0000000..ae252c3 --- /dev/null +++ b/controllers/registration/getTeamInviteLink.js @@ -0,0 +1,63 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Get shareable team invite link and team code (leader only) +//@route GET /api/form/inviteLink/:formId +//@access Private (USER) +const getTeamInviteLink = expressAsyncHandler(async (req, res, next) => { + try { + const { formId } = req.params; + const { email, id: userId } = req.user; + + if (!formId) { + return next(new ApiError(400, "Form ID is required")); + } + + // Find the team registration + const teamRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: email } + }, + include: { + form: { + select: { info: true, id: true } + } + } + }); + + if (!teamRegistration) { + return next(new ApiError(404, "No team registration found")); + } + + // Verify requester is the leader + if (teamRegistration.userId !== userId) { + return next(new ApiError(403, "Only the team leader can generate invite links")); + } + + // Build invite link — dynamically uses the request origin (localhost in dev, fedkiit.com in prod) + const baseUrl = req.headers.origin || process.env.FRONTEND_URL || "https://fedkiit.com"; + const inviteLink = `${baseUrl}/Events/${teamRegistration.form.id}/Form?teamCode=${teamRegistration.teamCode}`; + + const shareText = `Join my team "${teamRegistration.teamName}" for ${teamRegistration.form.info.eventTitle || "an event"}!\n\nTeam Code: ${teamRegistration.teamCode}\nJoin here: ${inviteLink}`; + + res.status(200).json({ + success: true, + data: { + inviteLink, + teamCode: teamRegistration.teamCode, + teamName: teamRegistration.teamName, + shareText + } + }); + + } catch (error) { + console.error("Error in getTeamInviteLink:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error generating invite link", error)); + } +}); + +module.exports = { getTeamInviteLink }; diff --git a/controllers/registration/inviteTeamMember.js b/controllers/registration/inviteTeamMember.js new file mode 100644 index 0000000..fa38a75 --- /dev/null +++ b/controllers/registration/inviteTeamMember.js @@ -0,0 +1,110 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); +const { sendMail } = require("../../utils/email/nodeMailer"); +const loadTemplate = require("../../utils/email/loadTemplate"); + +//@description Send email invitation to join team (leader only) +//@route POST /api/form/inviteTeamMember +//@access Private (USER) +const inviteTeamMember = expressAsyncHandler(async (req, res, next) => { + try { + const { formId, inviteeEmail } = req.body; + const { email, id: userId, name: inviterName } = req.user; + + if (!formId || !inviteeEmail) { + return next(new ApiError(400, "Form ID and invitee email are required")); + } + + // Normalize email + const normalizedEmail = inviteeEmail.trim().toLowerCase(); + + if (normalizedEmail === email) { + return next(new ApiError(400, "You cannot invite yourself")); + } + + // Find the team registration + const teamRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: email } + }, + include: { + form: { + select: { info: true, id: true } + } + } + }); + + if (!teamRegistration) { + return next(new ApiError(404, "No team registration found")); + } + + // Verify requester is the leader + if (teamRegistration.userId !== userId) { + return next(new ApiError(403, "Only the team leader can invite members")); + } + + const { info } = teamRegistration.form; + + // Check if registration is still open + if (info.isRegistrationClosed === 'true' || info.isEventPast === 'true') { + return next(new ApiError(400, "Registration is closed. Invitations are no longer allowed.")); + } + + // Check if team is full + const maxSize = parseInt(info.maxTeamSize) || 1; + if (teamRegistration.teamSize >= maxSize) { + return next(new ApiError(400, `Team is full (${teamRegistration.teamSize}/${maxSize} members).`)); + } + + // Check if invitee is already a member of this team + if (teamRegistration.regTeamMemEmails.includes(normalizedEmail)) { + return next(new ApiError(400, "This person is already on your team")); + } + + // Check if invitee is already registered for this form (on another team) + // [v2] UNAFFILIATED users CAN be invited — they need a team + const inviteeRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: normalizedEmail } + } + }); + if (inviteeRegistration && inviteeRegistration.teamName !== "UNAFFILIATED") { + return next(new ApiError(400, "This person is already on another team for this event")); + } + + // Build invite link — dynamically uses the request origin (localhost in dev, fedkiit.com in prod) + const baseUrl = req.headers.origin || process.env.FRONTEND_URL || "https://fedkiit.com"; + const inviteLink = `${baseUrl}/Events/${teamRegistration.form.id}/Form?teamCode=${teamRegistration.teamCode}`; + + // Send invitation email + const htmlContent = loadTemplate("teamInvitation", { + eventName: info.eventTitle || "Event", + teamName: teamRegistration.teamName, + teamCode: teamRegistration.teamCode, + inviterName: inviterName || "Your teammate", + inviteLink: inviteLink + }); + + await sendMail( + normalizedEmail, + `You're invited to join team "${teamRegistration.teamName}" for ${info.eventTitle || "an event"}`, + htmlContent + ); + + res.status(200).json({ + success: true, + message: `Invitation sent to ${normalizedEmail}` + }); + + } catch (error) { + console.error("Error in inviteTeamMember:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error sending invitation", error)); + } +}); + +module.exports = { inviteTeamMember }; diff --git a/controllers/registration/joinTeam.js b/controllers/registration/joinTeam.js new file mode 100644 index 0000000..2ab6d60 --- /dev/null +++ b/controllers/registration/joinTeam.js @@ -0,0 +1,135 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Join team — used by invite links AND accepted requests. Auto-expires other pending requests. +//@route POST /api/form/joinTeam +//@access Private (USER) +const joinTeam = expressAsyncHandler(async (req, res, next) => { + try { + const { formId, teamCode } = req.body; + const { email, id: userId } = req.user; + + if (!formId || !teamCode) { + return next(new ApiError(400, "Form ID and team code are required")); + } + + // Find user's solo (teamless) registration + const userRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: email } + } + }); + + if (!userRegistration) { + return next(new ApiError(404, "You are not registered for this event. Please register first.")); + } + + // Verify user is currently teamless + if (userRegistration.teamName !== "UNAFFILIATED") { + return next(new ApiError(400, "You are already on a team. Leave your current team first.")); + } + + // Find the target team + const targetTeam = await prisma.formRegistration.findUnique({ + where: { + formId_teamCode: { + formId, + teamCode + } + }, + include: { + form: { + select: { info: true } + } + } + }); + + if (!targetTeam) { + return next(new ApiError(404, "Team not found. The team code may be invalid.")); + } + + if (targetTeam.teamName === "UNAFFILIATED") { + return next(new ApiError(400, "Cannot join a teamless registration")); + } + + const { info } = targetTeam.form; + + // Check if registration is still open + if (info.isRegistrationClosed === 'true' || info.isEventPast === 'true') { + return next(new ApiError(400, "Registration is closed. Team changes are no longer allowed.")); + } + + // Check if team is full + const maxSize = parseInt(info.maxTeamSize) || 1; + if (targetTeam.teamSize >= maxSize) { + return next(new ApiError(400, `Team is full (${targetTeam.teamSize}/${maxSize} members).`)); + } + + // Get user's value[] entry from their solo record + const userValue = userRegistration.value && userRegistration.value.length > 0 + ? userRegistration.value[0] + : null; + + await prisma.$transaction(async (tx) => { + // 1. Move user's data to the target team record + const updateData = { + regTeamMemEmails: { + push: email + }, + teamSize: { + increment: 1 + } + }; + + // Push user's value entry if it exists + if (userValue) { + updateData.value = { + push: userValue + }; + } + + await tx.formRegistration.update({ + where: { id: targetTeam.id }, + data: updateData + }); + + // 2. Delete user's solo registration record + await tx.formRegistration.delete({ + where: { id: userRegistration.id } + }); + + // 3. Auto-expire all PENDING join requests from this user for this form + await tx.teamJoinRequest.updateMany({ + where: { + formId, + requesterEmail: email, + status: "PENDING" + }, + data: { + status: "AUTO_EXPIRED", + respondedAt: new Date() + } + }); + }); + + res.status(200).json({ + success: true, + message: `Successfully joined team "${targetTeam.teamName}"!`, + data: { + teamName: targetTeam.teamName, + teamCode: targetTeam.teamCode, + eventId: info.relatedEvent || formId + } + }); + + } catch (error) { + console.error("Error in joinTeam:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error joining team", error)); + } +}); + +module.exports = { joinTeam }; diff --git a/controllers/registration/leaveTeam.js b/controllers/registration/leaveTeam.js new file mode 100644 index 0000000..a503c08 --- /dev/null +++ b/controllers/registration/leaveTeam.js @@ -0,0 +1,145 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Leave team (self-removal) or dissolve team (leader only if sole member) +//@route POST /api/form/leaveTeam +//@access Private (USER) +// [v2] User becomes UNAFFILIATED instead of being deleted — no re-registration needed +const leaveTeam = expressAsyncHandler(async (req, res, next) => { + try { + const { formId } = req.body; + const { email, id: userId } = req.user; + + if (!formId) { + return next(new ApiError(400, "Form ID is required")); + } + + // Find the team registration where the user is a member + const teamRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: email } + }, + include: { + form: { + select: { info: true } + } + } + }); + + if (!teamRegistration) { + return next(new ApiError(404, "No team registration found for this user")); + } + + // Can't leave if already UNAFFILIATED + if (teamRegistration.teamName === "UNAFFILIATED") { + return next(new ApiError(400, "You are not currently on a team.")); + } + + const { info } = teamRegistration.form; + + // Check if registration is still open + if (info.isRegistrationClosed === 'true' || info.isEventPast === 'true') { + return next(new ApiError(400, "Registration is closed. Team changes are no longer allowed.")); + } + + const isLeader = teamRegistration.userId === userId; + + if (isLeader && teamRegistration.teamSize > 1) { + return next(new ApiError(400, "You must remove all team members before leaving. As the leader, you cannot leave while other members are on the team.")); + } + + // Extract the user's form response data from the team record + const userValue = teamRegistration.value?.filter( + entry => entry.user_email === email + ) || []; + + // Generate a unique solo team code + const soloTeamCode = `SOLO-${userId}-${Math.floor(1000 + Math.random() * 9000)}`; + + // Fetch registration tracker + const tracker = await prisma.registrationTracker.findUnique({ + where: { formId } + }); + + if (!tracker) { + return next(new ApiError(500, "Registration tracker not found")); + } + + const oldTeamName = teamRegistration.teamName; + + await prisma.$transaction(async (tx) => { + if (isLeader && teamRegistration.teamSize === 1) { + // LEADER (sole member) — convert current record to UNAFFILIATED + await tx.formRegistration.update({ + where: { id: teamRegistration.id }, + data: { + teamName: "UNAFFILIATED", + teamCode: soloTeamCode, + } + }); + + // Remove old team name from tracker (user stays in regUserEmails) + const updatedTeamNames = tracker.regTeamNames.filter( + name => name !== oldTeamName + ); + + await tx.registrationTracker.update({ + where: { formId }, + data: { + regTeamNames: { set: updatedTeamNames } + } + }); + } else { + // REGULAR MEMBER — remove from team, create UNAFFILIATED solo record + const updatedValue = teamRegistration.value.filter( + entry => entry.user_email !== email + ); + const updatedEmails = teamRegistration.regTeamMemEmails.filter( + e => e !== email + ); + + // 1. Remove user from the team + await tx.formRegistration.update({ + where: { id: teamRegistration.id }, + data: { + value: { set: updatedValue }, + regTeamMemEmails: { set: updatedEmails }, + teamSize: { decrement: 1 } + } + }); + + // 2. Create a new UNAFFILIATED solo record with user's form data + await tx.formRegistration.create({ + data: { + formId, + userId, + teamName: "UNAFFILIATED", + teamCode: soloTeamCode, + teamSize: 1, + regTeamMemEmails: [email], + value: userValue + } + }); + + // Tracker stays the same — user is still registered, no count changes + } + }); + + const action = isLeader ? "dissolved" : "left"; + res.status(200).json({ + success: true, + message: `Successfully ${action} the team "${oldTeamName}". You can now create or join another team.` + }); + + } catch (error) { + console.error("Error in leaveTeam:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error leaving team", error)); + } +}); + +module.exports = { leaveTeam }; + diff --git a/controllers/registration/registrationController.js b/controllers/registration/registrationController.js index a7b64bc..2ea09d9 100644 --- a/controllers/registration/registrationController.js +++ b/controllers/registration/registrationController.js @@ -6,6 +6,18 @@ const { markAttendance, exportAttendance, } = require("./markAttendance"); +const { leaveTeam } = require("./leaveTeam"); +const { inviteTeamMember } = require("./inviteTeamMember"); +const { getTeamInviteLink } = require("./getTeamInviteLink"); +const { renameTeam } = require("./renameTeam"); +// [v2] New team management controllers +const { createTeam } = require("./createTeam"); +const { searchTeams } = require("./searchTeams"); +const { joinTeam } = require("./joinTeam"); +const { sendJoinRequest } = require("./sendJoinRequest"); +const { respondJoinRequest } = require("./respondJoinRequest"); +const { checkJoinRequestUpdates } = require("./checkJoinRequestUpdates"); +const { checkAllJoinRequestUpdates } = require("./checkAllJoinRequestUpdates"); module.exports = { addRegistration, @@ -14,4 +26,16 @@ module.exports = { getAttendanceCode, markAttendance, exportAttendance, + leaveTeam, + inviteTeamMember, + getTeamInviteLink, + renameTeam, + // [v2] New team management exports + createTeam, + searchTeams, + joinTeam, + sendJoinRequest, + respondJoinRequest, + checkJoinRequestUpdates, + checkAllJoinRequestUpdates, }; diff --git a/controllers/registration/renameTeam.js b/controllers/registration/renameTeam.js new file mode 100644 index 0000000..623cb92 --- /dev/null +++ b/controllers/registration/renameTeam.js @@ -0,0 +1,106 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Rename team (leader only) +//@route PATCH /api/form/renameTeam +//@access Private (USER) +const renameTeam = expressAsyncHandler(async (req, res, next) => { + try { + const { formId, newTeamName } = req.body; + const { email, id: userId } = req.user; + + if (!formId || !newTeamName) { + return next(new ApiError(400, "Form ID and new team name are required")); + } + + const trimmedName = newTeamName.toUpperCase().trim(); + + if (!trimmedName) { + return next(new ApiError(400, "Team name cannot be empty")); + } + + // Find the team registration + const teamRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: email } + }, + include: { + form: { + select: { info: true } + } + } + }); + + if (!teamRegistration) { + return next(new ApiError(404, "No team registration found")); + } + + // Verify requester is the leader + if (teamRegistration.userId !== userId) { + return next(new ApiError(403, "Only the team leader can rename the team")); + } + + const { info } = teamRegistration.form; + + // Check if registration is still open + if (info.isRegistrationClosed === 'true' || info.isEventPast === 'true') { + return next(new ApiError(400, "Registration is closed. Team changes are no longer allowed.")); + } + + // If the name hasn't changed, no-op + if (trimmedName === teamRegistration.teamName) { + return res.status(200).json({ + success: true, + message: "Team name unchanged", + data: { teamName: trimmedName } + }); + } + + // Check for duplicate team name via registrationTracker + const tracker = await prisma.registrationTracker.findUnique({ + where: { formId } + }); + + if (tracker?.regTeamNames.includes(trimmedName)) { + return next(new ApiError(400, "This team name is already taken. Please choose a different one.")); + } + + await prisma.$transaction(async (tx) => { + // Update formRegistration teamName + await tx.formRegistration.update({ + where: { id: teamRegistration.id }, + data: { teamName: trimmedName } + }); + + // Update registrationTracker: swap old name with new name + if (tracker) { + const updatedNames = tracker.regTeamNames.map( + name => name === teamRegistration.teamName ? trimmedName : name + ); + + await tx.registrationTracker.update({ + where: { formId }, + data: { + regTeamNames: { set: updatedNames } + } + }); + } + }); + + res.status(200).json({ + success: true, + message: `Team renamed to "${trimmedName}"`, + data: { teamName: trimmedName } + }); + + } catch (error) { + console.error("Error in renameTeam:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error renaming team", error)); + } +}); + +module.exports = { renameTeam }; diff --git a/controllers/registration/respondJoinRequest.js b/controllers/registration/respondJoinRequest.js new file mode 100644 index 0000000..f9d7610 --- /dev/null +++ b/controllers/registration/respondJoinRequest.js @@ -0,0 +1,247 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const jwt = require("jsonwebtoken"); +const { sendMail } = require("../../utils/email/nodeMailer"); +const loadTemplate = require("../../utils/email/loadTemplate"); + +//@description Handle Accept/Reject from email links (PUBLIC — no auth middleware) +//@route GET /api/form/respondJoinRequest?token=&action=accept|reject +//@access Public (token-based authentication) +const respondJoinRequest = async (req, res) => { + const { token, action } = req.query; + const frontendBase = process.env.DOMAIN || "http://localhost:5173/"; + // Remove trailing slash for clean URL building + const frontendUrl = frontendBase.endsWith('/') ? frontendBase.slice(0, -1) : frontendBase; + + // Helper to redirect to team page with toast + const redirectToTeam = (eventId, formId, toastType, name) => { + let url = `${frontendUrl}/Events/${eventId}/team/${formId}`; + const params = []; + if (toastType) params.push(`toast=${toastType}`); + if (name) params.push(`name=${encodeURIComponent(name)}`); + if (params.length > 0) url += `?${params.join('&')}`; + return res.redirect(url); + }; + + // Helper to redirect with a generic error + const redirectError = (message) => { + return res.redirect(`${frontendUrl}/?error=${encodeURIComponent(message)}`); + }; + + try { + // Validate basic params + if (!token || !action || !['accept', 'reject'].includes(action)) { + return redirectError("Invalid request. Missing token or action."); + } + + // Verify and decode JWT + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } catch (jwtError) { + if (jwtError.name === 'TokenExpiredError') { + // Try to decode without verification to get formId for redirect + const payload = jwt.decode(token); + if (payload?.formId) { + // Find form to get eventId + const form = await prisma.form.findUnique({ + where: { id: payload.formId }, + select: { info: true } + }); + const eventId = form?.info?.relatedEvent || payload.formId; + return redirectToTeam(eventId, payload.formId, "expired"); + } + return redirectError("This request has expired."); + } + return redirectError("Invalid or tampered token."); + } + + const { requestId, requesterEmail, teamRegistrationId, formId, leaderEmail } = decoded; + + // Find the join request + const joinRequest = await prisma.teamJoinRequest.findUnique({ + where: { id: requestId } + }); + + if (!joinRequest) { + return redirectError("Join request not found."); + } + + // Find the form to get eventId for redirects + const form = await prisma.form.findUnique({ + where: { id: formId }, + select: { info: true } + }); + + const eventId = form?.info?.relatedEvent || formId; + + // Check if request is still PENDING + if (joinRequest.status !== "PENDING") { + const statusToasts = { + "ACCEPTED": "already_accepted", + "REJECTED": "already_rejected", + "AUTO_EXPIRED": "already_joined", + "EXPIRED": "expired" + }; + return redirectToTeam(eventId, formId, statusToasts[joinRequest.status] || "invalid", joinRequest.requesterName); + } + + // Check if request has passed its expiry time (even if JWT is valid) + if (new Date() > new Date(joinRequest.expiresAt)) { + await prisma.teamJoinRequest.update({ + where: { id: requestId }, + data: { status: "EXPIRED", respondedAt: new Date() } + }); + return redirectToTeam(eventId, formId, "expired"); + } + + // === REJECT === + if (action === "reject") { + await prisma.teamJoinRequest.update({ + where: { id: requestId }, + data: { status: "REJECTED", respondedAt: new Date() } + }); + + // Send rejection email to requester + try { + const htmlContent = loadTemplate("teamJoinRejected", { + requesterName: joinRequest.requesterName || requesterEmail, + teamName: "", // We'll fill this below + eventName: form?.info?.eventTitle || "Event" + }); + + // Get team name for the email + const targetTeam = await prisma.formRegistration.findUnique({ + where: { id: teamRegistrationId }, + select: { teamName: true } + }); + + const rejectionHtml = loadTemplate("teamJoinRejected", { + requesterName: joinRequest.requesterName || requesterEmail, + teamName: targetTeam?.teamName || "the team", + eventName: form?.info?.eventTitle || "Event" + }); + + await sendMail( + requesterEmail, + `Your join request for "${targetTeam?.teamName || "a team"}" was declined`, + rejectionHtml + ); + } catch (emailError) { + console.error("Error sending rejection email:", emailError); + // Non-critical — continue with redirect + } + + return redirectToTeam(eventId, formId, "rejected", joinRequest.requesterName); + } + + // === ACCEPT === + // Check if user is still teamless + const userRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: requesterEmail } + } + }); + + if (!userRegistration || userRegistration.teamName !== "UNAFFILIATED") { + // User already joined another team + await prisma.teamJoinRequest.update({ + where: { id: requestId }, + data: { status: "AUTO_EXPIRED", respondedAt: new Date() } + }); + return redirectToTeam(eventId, formId, "already_joined", joinRequest.requesterName); + } + + // Check the target team still exists and is not full + const targetTeam = await prisma.formRegistration.findUnique({ + where: { id: teamRegistrationId }, + include: { + form: { select: { info: true } } + } + }); + + if (!targetTeam) { + return redirectToTeam(eventId, formId, "invalid"); + } + + const maxSize = parseInt(targetTeam.form.info.maxTeamSize) || 1; + if (targetTeam.teamSize >= maxSize) { + await prisma.teamJoinRequest.update({ + where: { id: requestId }, + data: { status: "AUTO_EXPIRED", respondedAt: new Date() } + }); + return redirectToTeam(eventId, formId, "team_full", joinRequest.requesterName); + } + + // Get user's value[] entry from their solo record + const userValue = userRegistration.value && userRegistration.value.length > 0 + ? userRegistration.value[0] + : null; + + // Execute join in a transaction + await prisma.$transaction(async (tx) => { + // 1. Move user's data to the target team + const updateData = { + regTeamMemEmails: { push: requesterEmail }, + teamSize: { increment: 1 } + }; + if (userValue) { + updateData.value = { push: userValue }; + } + + await tx.formRegistration.update({ + where: { id: targetTeam.id }, + data: updateData + }); + + // 2. Delete user's solo registration + await tx.formRegistration.delete({ + where: { id: userRegistration.id } + }); + + // 3. Mark THIS request as ACCEPTED + await tx.teamJoinRequest.update({ + where: { id: requestId }, + data: { status: "ACCEPTED", respondedAt: new Date() } + }); + + // 4. Auto-expire ALL other PENDING requests from this user + await tx.teamJoinRequest.updateMany({ + where: { + formId, + requesterEmail, + status: "PENDING", + id: { not: requestId } + }, + data: { status: "AUTO_EXPIRED", respondedAt: new Date() } + }); + }); + + // Send confirmation email to requester + try { + const acceptHtml = loadTemplate("teamJoinAccepted", { + requesterName: joinRequest.requesterName || requesterEmail, + teamName: targetTeam.teamName, + eventName: form?.info?.eventTitle || "Event" + }); + + await sendMail( + requesterEmail, + `🎉 You've joined team "${targetTeam.teamName}"!`, + acceptHtml + ); + } catch (emailError) { + console.error("Error sending acceptance email:", emailError); + // Non-critical — continue with redirect + } + + return redirectToTeam(eventId, formId, "joined", joinRequest.requesterName); + + } catch (error) { + console.error("Error in respondJoinRequest:", error); + return redirectError("An unexpected error occurred. Please try again."); + } +}; + +module.exports = { respondJoinRequest }; diff --git a/controllers/registration/searchTeams.js b/controllers/registration/searchTeams.js new file mode 100644 index 0000000..99ca8dd --- /dev/null +++ b/controllers/registration/searchTeams.js @@ -0,0 +1,100 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Search/browse available teams for a form (teamless users) +//@route GET /api/form/searchTeams/:formId?search= +//@access Private (USER) +const searchTeams = expressAsyncHandler(async (req, res, next) => { + try { + const { formId } = req.params; + const { search } = req.query; + const { email } = req.user; + + if (!formId) { + return next(new ApiError(400, "Form ID is required")); + } + + // Get the form info to know maxTeamSize + const form = await prisma.form.findUnique({ + where: { id: formId }, + select: { info: true } + }); + + if (!form) { + return next(new ApiError(404, "Form not found")); + } + + const maxTeamSize = parseInt(form.info.maxTeamSize) || 1; + + // Find all non-teamless registrations for this form that are not full + const teamRegistrations = await prisma.formRegistration.findMany({ + where: { + formId, + teamName: { not: "UNAFFILIATED" }, + teamSize: { lt: maxTeamSize } + }, + select: { + id: true, + teamName: true, + teamSize: true, + userId: true + } + }); + + // Filter by search query if provided (case-insensitive substring match) + let filteredTeams = teamRegistrations; + if (search && search.trim()) { + const searchLower = search.trim().toLowerCase(); + filteredTeams = teamRegistrations.filter(team => + team.teamName.toLowerCase().includes(searchLower) + ); + } + + // Get leader names for each team + const leaderIds = [...new Set(filteredTeams.map(t => t.userId))]; + const leaders = await prisma.user.findMany({ + where: { id: { in: leaderIds } }, + select: { id: true, name: true } + }); + const leaderMap = {}; + leaders.forEach(l => { leaderMap[l.id] = l.name; }); + + // Get pending join requests from this user for this form + const pendingRequests = await prisma.teamJoinRequest.findMany({ + where: { + formId, + requesterEmail: email, + status: "PENDING" + }, + select: { + teamRegistrationId: true + } + }); + const pendingTeamIds = new Set(pendingRequests.map(r => r.teamRegistrationId)); + + // Build response + const teams = filteredTeams.map(team => ({ + teamRegistrationId: team.id, + teamName: team.teamName, + teamSize: team.teamSize, + maxTeamSize, + leaderName: leaderMap[team.userId] || "Unknown", + spotsRemaining: maxTeamSize - team.teamSize, + hasPendingRequest: pendingTeamIds.has(team.id) + })); + + res.status(200).json({ + success: true, + data: { teams } + }); + + } catch (error) { + console.error("Error in searchTeams:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error searching teams", error)); + } +}); + +module.exports = { searchTeams }; diff --git a/controllers/registration/sendJoinRequest.js b/controllers/registration/sendJoinRequest.js new file mode 100644 index 0000000..9e58b2b --- /dev/null +++ b/controllers/registration/sendJoinRequest.js @@ -0,0 +1,162 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); +const jwt = require("jsonwebtoken"); +const { sendMail } = require("../../utils/email/nodeMailer"); +const loadTemplate = require("../../utils/email/loadTemplate"); + +//@description Send join request to team leader — creates DB record, signs JWT, sends email +//@route POST /api/form/sendJoinRequest +//@access Private (USER) +const sendJoinRequest = expressAsyncHandler(async (req, res, next) => { + try { + const { formId, teamRegistrationId } = req.body; + const { email, name } = req.user; + + if (!formId || !teamRegistrationId) { + return next(new ApiError(400, "Form ID and team registration ID are required")); + } + + // Verify user is registered and teamless + const userRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + regTeamMemEmails: { has: email } + } + }); + + if (!userRegistration) { + return next(new ApiError(404, "You are not registered for this event.")); + } + + if (userRegistration.teamName !== "UNAFFILIATED") { + return next(new ApiError(400, "You are already on a team.")); + } + + // Find the target team + const targetTeam = await prisma.formRegistration.findUnique({ + where: { id: teamRegistrationId }, + include: { + form: { + select: { info: true, id: true } + }, + user: { + select: { name: true, email: true } + } + } + }); + + if (!targetTeam) { + return next(new ApiError(404, "Team not found")); + } + + if (targetTeam.formId !== formId) { + return next(new ApiError(400, "Team does not belong to this form")); + } + + if (targetTeam.teamName === "UNAFFILIATED") { + return next(new ApiError(400, "Cannot request to join a teamless registration")); + } + + const { info } = targetTeam.form; + + // Check if registration is still open + if (info.isRegistrationClosed === 'true' || info.isEventPast === 'true') { + return next(new ApiError(400, "Registration is closed.")); + } + + // Check if team is full + const maxSize = parseInt(info.maxTeamSize) || 1; + if (targetTeam.teamSize >= maxSize) { + return next(new ApiError(400, `Team is full (${targetTeam.teamSize}/${maxSize} members).`)); + } + + // Check no existing PENDING request from this user to this team + const existingRequest = await prisma.teamJoinRequest.findFirst({ + where: { + formId, + requesterEmail: email, + teamRegistrationId, + status: "PENDING" + } + }); + + if (existingRequest) { + return next(new ApiError(400, "You already have a pending request for this team.")); + } + + const leaderEmail = targetTeam.user.email; + const leaderName = targetTeam.user.name; + const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours + + // Create join request record + const joinRequest = await prisma.teamJoinRequest.create({ + data: { + formId, + requesterEmail: email, + requesterName: name || email, + teamRegistrationId, + teamName: targetTeam.teamName, + leaderEmail, + status: "PENDING", + expiresAt + } + }); + + // Sign JWT token with request details (48h expiry) + const token = jwt.sign( + { + requestId: joinRequest.id, + requesterEmail: email, + teamRegistrationId, + formId, + leaderEmail + }, + process.env.JWT_SECRET, + { expiresIn: '48h' } + ); + + // Build Accept/Reject URLs + const baseUrl = req.headers.origin || process.env.FRONTEND_URL || "https://fedkiit.com"; + // These point to backend endpoints that will process the action and redirect to frontend + const backendBase = `${req.protocol}://${req.get('host')}`; + const acceptUrl = `${backendBase}/api/form/respondJoinRequest?token=${token}&action=accept`; + const rejectUrl = `${backendBase}/api/form/respondJoinRequest?token=${token}&action=reject`; + + // Send email to leader + const htmlContent = loadTemplate("teamJoinRequest", { + leaderName: leaderName || "Team Leader", + requesterName: name || email, + requesterEmail: email, + teamName: targetTeam.teamName, + eventName: info.eventTitle || "Event", + teamSize: targetTeam.teamSize.toString(), + maxTeamSize: maxSize.toString(), + acceptUrl, + rejectUrl, + expiryHours: "48" + }); + + await sendMail( + leaderEmail, + `Join Request: ${name || email} wants to join your team "${targetTeam.teamName}"`, + htmlContent + ); + + res.status(200).json({ + success: true, + message: `Join request sent to the team leader. They will receive an email with your request.`, + data: { + requestId: joinRequest.id + } + }); + + } catch (error) { + console.error("Error in sendJoinRequest:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error sending join request", error)); + } +}); + +module.exports = { sendJoinRequest }; diff --git a/emailTemplates/teamInvitation.html b/emailTemplates/teamInvitation.html new file mode 100644 index 0000000..f83ca7d --- /dev/null +++ b/emailTemplates/teamInvitation.html @@ -0,0 +1,96 @@ + + + + + + Team Invitation + + + +
+
+

You're Invited to Join a Team!

+
+
+

Hello,

+

{{inviterName}} has invited you to join their team for {{eventName}}.

+ +
+

Team Name: {{teamName}}

+

Team Code: {{teamCode}}

+

+ Join Team +

+
+ +

To join, click the button above or go to the event registration page and enter the team code when selecting "Join Team".

+ +

Thank you,

+

FED KIIT Team

+
+ +
+ + diff --git a/emailTemplates/teamJoinAccepted.html b/emailTemplates/teamJoinAccepted.html new file mode 100644 index 0000000..03ba37f --- /dev/null +++ b/emailTemplates/teamJoinAccepted.html @@ -0,0 +1,87 @@ + + + + + + + Join Request Accepted + + + + +
+
+

🎉 You're In!

+
+
+

Hi {{requesterName}},

+

Great news! Your request to join a team has been accepted.

+ +
+

Team: {{teamName}}

+

Event: {{eventName}}

+
+ +

You are now part of the team. Head over to the Team Management page to see your + team details.

+ +

Good luck! 🚀

+

FED KIIT Team

+
+ +
+ + + \ No newline at end of file diff --git a/emailTemplates/teamJoinRejected.html b/emailTemplates/teamJoinRejected.html new file mode 100644 index 0000000..7c8f695 --- /dev/null +++ b/emailTemplates/teamJoinRejected.html @@ -0,0 +1,89 @@ + + + + + + + Join Request Declined + + + + +
+
+

Join Request Update

+
+
+

Hi {{requesterName}},

+

Unfortunately, your request to join {{teamName}} for {{eventName}} was + declined by the team leader.

+ +
+

Don't worry! You can still:

+

• Browse other available teams

+

• Create your own team

+

• Ask another team leader for an invite link

+
+ +

Head over to the Team Management page to explore your options.

+ +

Best wishes,

+

FED KIIT Team

+
+ +
+ + + \ No newline at end of file diff --git a/emailTemplates/teamJoinRequest.html b/emailTemplates/teamJoinRequest.html new file mode 100644 index 0000000..999be89 --- /dev/null +++ b/emailTemplates/teamJoinRequest.html @@ -0,0 +1,140 @@ + + + + + + + Team Join Request + + + + +
+
+

🏆 Team Join Request

+
+
+

Hi {{leaderName}},

+

{{requesterName}} ({{requesterEmail}}) wants to join your team for + {{eventName}}.

+ +
+

Team: {{teamName}}

+

Current Members: {{teamSize}}/{{maxTeamSize}}

+
+ + + +

This request will expire in {{expiryHours}} hours.

+ +

Thank you,

+

FED KIIT Team

+
+ +
+ + + \ No newline at end of file diff --git a/prisma/schema/teamJoinRequest.prisma b/prisma/schema/teamJoinRequest.prisma new file mode 100644 index 0000000..84b7599 --- /dev/null +++ b/prisma/schema/teamJoinRequest.prisma @@ -0,0 +1,18 @@ +model teamJoinRequest { + id String @id @default(auto()) @map("_id") @db.ObjectId + formId String @db.ObjectId + requesterEmail String + requesterName String + teamRegistrationId String @db.ObjectId + teamName String? // Team name at time of request (for display in notifications) + leaderEmail String + status String @default("PENDING") // PENDING | ACCEPTED | REJECTED | AUTO_EXPIRED | EXPIRED + seenByRequester Boolean @default(false) // Has the requester seen the decision in-app? + createdAt DateTime @default(now()) + expiresAt DateTime + respondedAt DateTime? + + @@map("team_join_request") + @@index([formId, requesterEmail]) + @@index([teamRegistrationId, status]) +} diff --git a/routes/api/forms/formRoutes.js b/routes/api/forms/formRoutes.js index 676b775..74520f1 100644 --- a/routes/api/forms/formRoutes.js +++ b/routes/api/forms/formRoutes.js @@ -17,6 +17,9 @@ const upload = multer(); router.get("/getAllForms", formController.getAllForms); router.post("/contact", formController.contact); +// [v2] PUBLIC — email action link handler (no auth required) +router.get("/respondJoinRequest", registrationController.respondJoinRequest); + router.use(verifyToken); router.get("/teamDetails/:formId", checkAccess("USER"), getTeamDetails); @@ -28,18 +31,25 @@ router.use( registrationController.addRegistration ); +// Team management routes +router.post("/leaveTeam", checkAccess("USER"), registrationController.leaveTeam); +router.post("/inviteTeamMember", checkAccess("USER"), registrationController.inviteTeamMember); +router.get("/inviteLink/:formId", checkAccess("USER"), registrationController.getTeamInviteLink); +router.patch("/renameTeam", checkAccess("USER"), registrationController.renameTeam); +// [v2] New team management routes +router.post("/createTeam", checkAccess("USER"), registrationController.createTeam); +router.post("/joinTeam", checkAccess("USER"), registrationController.joinTeam); +router.get("/searchTeams/:formId", checkAccess("USER"), registrationController.searchTeams); +router.post("/sendJoinRequest", checkAccess("USER"), registrationController.sendJoinRequest); +router.get("/joinRequestUpdates/:formId", checkAccess("USER"), registrationController.checkJoinRequestUpdates); +router.get("/allJoinRequestUpdates", checkAccess("USER"), registrationController.checkAllJoinRequestUpdates); + router.get( "/export-attendance/:id", checkAccess("ADMIN"), registrationController.exportAttendance ); -router.use( - "/register", - checkAccess("USER"), - imageUpload.any(), - registrationController.addRegistration -); router.get("/getFormAnalytics/:id", formController.analytics); router.get( From 45ddf8ebfe82a9302ba5d401e81b5f4e07610c57 Mon Sep 17 00:00:00 2001 From: MdMobid Date: Thu, 26 Feb 2026 23:07:28 +0530 Subject: [PATCH 10/15] Added removeTeamMember --- .../registration/registrationController.js | 2 + controllers/registration/removeTeamMember.js | 117 ++++++++++++++++++ package-lock.json | 7 +- routes/api/forms/formRoutes.js | 1 + 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 controllers/registration/removeTeamMember.js diff --git a/controllers/registration/registrationController.js b/controllers/registration/registrationController.js index 2ea09d9..84df9ed 100644 --- a/controllers/registration/registrationController.js +++ b/controllers/registration/registrationController.js @@ -10,6 +10,7 @@ const { leaveTeam } = require("./leaveTeam"); const { inviteTeamMember } = require("./inviteTeamMember"); const { getTeamInviteLink } = require("./getTeamInviteLink"); const { renameTeam } = require("./renameTeam"); +const { removeTeamMember } = require("./removeTeamMember"); // [v2] New team management controllers const { createTeam } = require("./createTeam"); const { searchTeams } = require("./searchTeams"); @@ -30,6 +31,7 @@ module.exports = { inviteTeamMember, getTeamInviteLink, renameTeam, + removeTeamMember, // [v2] New team management exports createTeam, searchTeams, diff --git a/controllers/registration/removeTeamMember.js b/controllers/registration/removeTeamMember.js new file mode 100644 index 0000000..72f9876 --- /dev/null +++ b/controllers/registration/removeTeamMember.js @@ -0,0 +1,117 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); +const { ApiError } = require("../../utils/error/ApiError"); +const expressAsyncHandler = require("express-async-handler"); + +//@description Remove a team member (Leader only) +//@route POST /api/form/removeTeamMember +//@access Private (USER) +// [v2] Removed user becomes UNAFFILIATED instead of being deleted — no re-registration needed +const removeTeamMember = expressAsyncHandler(async (req, res, next) => { + try { + const { formId, memberEmail } = req.body; + const { email, id: userId } = req.user; + + if (!formId || !memberEmail) { + return next(new ApiError(400, "Form ID and member email are required")); + } + + // Find the team registration where the requesting user is the LEADER + const teamRegistration = await prisma.formRegistration.findFirst({ + where: { + formId, + userId, // req.user.id must be the creator/leader + }, + include: { + form: { + select: { info: true } + } + } + }); + + if (!teamRegistration) { + return next(new ApiError(404, "You are not the leader of any team for this form")); + } + + const { info } = teamRegistration.form; + + // Check if registration is still open + if (info.isRegistrationClosed === 'true' || info.isEventPast === 'true') { + return next(new ApiError(400, "Registration is closed. Team changes are no longer allowed.")); + } + + // Leader cannot remove themselves via this endpoint (use leaveTeam instead) + if (memberEmail === email) { + return next(new ApiError(400, "You cannot remove yourself. Use the Leave/Dissolve Team option.")); + } + + // Check if the member to be removed is actually in the team + if (!teamRegistration.regTeamMemEmails.includes(memberEmail)) { + return next(new ApiError(404, "The specified completed user is not in your team.")); + } + + // Extract the target user's form response data from the team record + const userValue = teamRegistration.value?.filter( + entry => entry.user_email === memberEmail + ) || []; + + // Need to find the target user's ID for their new solo record + const targetUser = await prisma.user.findUnique({ + where: { email: memberEmail }, + select: { id: true } + }); + + if (!targetUser) { + return next(new ApiError(404, "Target user not found in the system.")); + } + + // Generate a unique solo team code for the removed member + const soloTeamCode = `SOLO-${targetUser.id}-${Math.floor(1000 + Math.random() * 9000)}`; + + await prisma.$transaction(async (tx) => { + // 1. Remove user from the team + const updatedValue = teamRegistration.value.filter( + entry => entry.user_email !== memberEmail + ); + const updatedEmails = teamRegistration.regTeamMemEmails.filter( + e => e !== memberEmail + ); + + await tx.formRegistration.update({ + where: { id: teamRegistration.id }, + data: { + value: { set: updatedValue }, + regTeamMemEmails: { set: updatedEmails }, + teamSize: { decrement: 1 } + } + }); + + // 2. Create a new UNAFFILIATED solo record with user's form data + await tx.formRegistration.create({ + data: { + formId, + userId: targetUser.id, + teamName: "UNAFFILIATED", + teamCode: soloTeamCode, + teamSize: 1, + regTeamMemEmails: [memberEmail], + value: userValue + } + }); + + // Tracker stays the same — user is still registered, no count changes + }); + + res.status(200).json({ + success: true, + message: `Successfully removed ${memberEmail} from the team.` + }); + + } catch (error) { + console.error("Error in removeTeamMember:", error); + if (error instanceof ApiError) throw error; + next(new ApiError(500, "Error removing team member", error)); + } +}); + +module.exports = { removeTeamMember }; diff --git a/package-lock.json b/package-lock.json index a8b2efa..f373995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1892,7 +1892,8 @@ "version": "0.0.1464554", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1464554.tgz", "integrity": "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dijkstrajs": { "version": "1.0.3", @@ -3574,7 +3575,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -4279,6 +4279,7 @@ "integrity": "sha512-Z1Uqodk44diztImxALgJJfNl2Uisl9xDRvqybMKEBYJLNKNhDfAHf+ZIJbZyYiBhLMbKU9cYGdDVG5IIXEnL2Q==", "devOptional": true, "hasInstallScript": true, + "peer": true, "dependencies": { "@prisma/engines": "5.16.1" }, @@ -4383,6 +4384,7 @@ "integrity": "sha512-QbccB/LgxX4tSZRzr9KQ1Jajdvu3n35Dlf/Otjz0QfR+6mDoZdMWLcWF94uQoC3OJerCyYm5hlU2Ru4nBoId2A==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@puppeteer/browsers": "2.10.5", "chromium-bidi": "5.1.0", @@ -4963,7 +4965,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } diff --git a/routes/api/forms/formRoutes.js b/routes/api/forms/formRoutes.js index 74520f1..f797084 100644 --- a/routes/api/forms/formRoutes.js +++ b/routes/api/forms/formRoutes.js @@ -36,6 +36,7 @@ router.post("/leaveTeam", checkAccess("USER"), registrationController.leaveTeam) router.post("/inviteTeamMember", checkAccess("USER"), registrationController.inviteTeamMember); router.get("/inviteLink/:formId", checkAccess("USER"), registrationController.getTeamInviteLink); router.patch("/renameTeam", checkAccess("USER"), registrationController.renameTeam); +router.post("/removeTeamMember", checkAccess("USER"), registrationController.removeTeamMember); // [v2] New team management routes router.post("/createTeam", checkAccess("USER"), registrationController.createTeam); router.post("/joinTeam", checkAccess("USER"), registrationController.joinTeam); From 46a868ac64dc1291893261d8b477a1f18564f622 Mon Sep 17 00:00:00 2001 From: MdMobid Date: Fri, 27 Feb 2026 00:40:37 +0530 Subject: [PATCH 11/15] Sending Mail after removal --- controllers/registration/removeTeamMember.js | 19 ++++- emailTemplates/removedMember.html | 88 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 emailTemplates/removedMember.html diff --git a/controllers/registration/removeTeamMember.js b/controllers/registration/removeTeamMember.js index 72f9876..7b3ab94 100644 --- a/controllers/registration/removeTeamMember.js +++ b/controllers/registration/removeTeamMember.js @@ -2,6 +2,8 @@ const { PrismaClient } = require("@prisma/client"); const prisma = new PrismaClient(); const { ApiError } = require("../../utils/error/ApiError"); const expressAsyncHandler = require("express-async-handler"); +const { sendMail } = require("../../utils/email/nodeMailer"); +const loadTemplate = require("../../utils/email/loadTemplate"); //@description Remove a team member (Leader only) //@route POST /api/form/removeTeamMember @@ -16,6 +18,9 @@ const removeTeamMember = expressAsyncHandler(async (req, res, next) => { return next(new ApiError(400, "Form ID and member email are required")); } + // Normalize email + const normalizedEmail = memberEmail.trim().toLowerCase(); + // Find the team registration where the requesting user is the LEADER const teamRegistration = await prisma.formRegistration.findFirst({ where: { @@ -102,9 +107,21 @@ const removeTeamMember = expressAsyncHandler(async (req, res, next) => { // Tracker stays the same — user is still registered, no count changes }); + // Send invitation email + const htmlContent = loadTemplate("removedMember", { + eventName: info.eventTitle || "Event", + teamName: teamRegistration.teamName + }); + + await sendMail( + normalizedEmail, + `You're removed from "${teamRegistration.teamName}" from ${info.eventTitle || "an event"}`, + htmlContent + ); + res.status(200).json({ success: true, - message: `Successfully removed ${memberEmail} from the team.` + message: `Successfully removed ${memberEmail} from the team & informed through ${normalizedEmail}` }); } catch (error) { diff --git a/emailTemplates/removedMember.html b/emailTemplates/removedMember.html new file mode 100644 index 0000000..da79bcc --- /dev/null +++ b/emailTemplates/removedMember.html @@ -0,0 +1,88 @@ + + + + + + + Team Membership Update + + + + +
+
+

Team Membership Update

+
+
+

Hi there,

+

You have been removed from the team {{teamName}} for the event + {{eventName}}.

+ +
+

But don't worry! Your registration for the event is still active. You can:

+

• Browse and join other available teams

+

• Create your own brand new team

+
+ +

Head over to the Team Management page to explore your options and get back in + the action.

+ +

Best wishes,

+

FED KIIT Team

+
+ +
+ + + \ No newline at end of file From 55be99ef9afb7b01a1daa6f5a13fca4e5be2ef9c Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Fri, 27 Feb 2026 01:28:01 +0530 Subject: [PATCH 12/15] v2: Fix respondJoinRequest redirect URL, fix joinTeam relatedEvent null string --- controllers/registration/joinTeam.js | 2 +- .../registration/respondJoinRequest.js | 23 ++++++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/controllers/registration/joinTeam.js b/controllers/registration/joinTeam.js index 2ab6d60..27dab05 100644 --- a/controllers/registration/joinTeam.js +++ b/controllers/registration/joinTeam.js @@ -121,7 +121,7 @@ const joinTeam = expressAsyncHandler(async (req, res, next) => { data: { teamName: targetTeam.teamName, teamCode: targetTeam.teamCode, - eventId: info.relatedEvent || formId + eventId: (info.relatedEvent && info.relatedEvent !== "null") ? info.relatedEvent : formId } }); diff --git a/controllers/registration/respondJoinRequest.js b/controllers/registration/respondJoinRequest.js index f9d7610..7d33b64 100644 --- a/controllers/registration/respondJoinRequest.js +++ b/controllers/registration/respondJoinRequest.js @@ -14,8 +14,8 @@ const respondJoinRequest = async (req, res) => { const frontendUrl = frontendBase.endsWith('/') ? frontendBase.slice(0, -1) : frontendBase; // Helper to redirect to team page with toast - const redirectToTeam = (eventId, formId, toastType, name) => { - let url = `${frontendUrl}/Events/${eventId}/team/${formId}`; + const redirectToTeam = (formId, toastType, name) => { + let url = `${frontendUrl}/Events/${formId}/team`; const params = []; if (toastType) params.push(`toast=${toastType}`); if (name) params.push(`name=${encodeURIComponent(name)}`); @@ -48,8 +48,7 @@ const respondJoinRequest = async (req, res) => { where: { id: payload.formId }, select: { info: true } }); - const eventId = form?.info?.relatedEvent || payload.formId; - return redirectToTeam(eventId, payload.formId, "expired"); + return redirectToTeam(payload.formId, "expired"); } return redirectError("This request has expired."); } @@ -73,8 +72,6 @@ const respondJoinRequest = async (req, res) => { select: { info: true } }); - const eventId = form?.info?.relatedEvent || formId; - // Check if request is still PENDING if (joinRequest.status !== "PENDING") { const statusToasts = { @@ -83,7 +80,7 @@ const respondJoinRequest = async (req, res) => { "AUTO_EXPIRED": "already_joined", "EXPIRED": "expired" }; - return redirectToTeam(eventId, formId, statusToasts[joinRequest.status] || "invalid", joinRequest.requesterName); + return redirectToTeam(formId, statusToasts[joinRequest.status] || "invalid", joinRequest.requesterName); } // Check if request has passed its expiry time (even if JWT is valid) @@ -92,7 +89,7 @@ const respondJoinRequest = async (req, res) => { where: { id: requestId }, data: { status: "EXPIRED", respondedAt: new Date() } }); - return redirectToTeam(eventId, formId, "expired"); + return redirectToTeam(formId, "expired"); } // === REJECT === @@ -132,7 +129,7 @@ const respondJoinRequest = async (req, res) => { // Non-critical — continue with redirect } - return redirectToTeam(eventId, formId, "rejected", joinRequest.requesterName); + return redirectToTeam(formId, "rejected", joinRequest.requesterName); } // === ACCEPT === @@ -150,7 +147,7 @@ const respondJoinRequest = async (req, res) => { where: { id: requestId }, data: { status: "AUTO_EXPIRED", respondedAt: new Date() } }); - return redirectToTeam(eventId, formId, "already_joined", joinRequest.requesterName); + return redirectToTeam(formId, "already_joined", joinRequest.requesterName); } // Check the target team still exists and is not full @@ -162,7 +159,7 @@ const respondJoinRequest = async (req, res) => { }); if (!targetTeam) { - return redirectToTeam(eventId, formId, "invalid"); + return redirectToTeam(formId, "invalid"); } const maxSize = parseInt(targetTeam.form.info.maxTeamSize) || 1; @@ -171,7 +168,7 @@ const respondJoinRequest = async (req, res) => { where: { id: requestId }, data: { status: "AUTO_EXPIRED", respondedAt: new Date() } }); - return redirectToTeam(eventId, formId, "team_full", joinRequest.requesterName); + return redirectToTeam(formId, "team_full", joinRequest.requesterName); } // Get user's value[] entry from their solo record @@ -236,7 +233,7 @@ const respondJoinRequest = async (req, res) => { // Non-critical — continue with redirect } - return redirectToTeam(eventId, formId, "joined", joinRequest.requesterName); + return redirectToTeam(formId, "joined", joinRequest.requesterName); } catch (error) { console.error("Error in respondJoinRequest:", error); From 8706eb06828d4845b68b18472309f4d0731ec67a Mon Sep 17 00:00:00 2001 From: Krishna Das Date: Sat, 28 Feb 2026 13:32:25 +0530 Subject: [PATCH 13/15] User can request 3 teams at a time --- controllers/registration/sendJoinRequest.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/controllers/registration/sendJoinRequest.js b/controllers/registration/sendJoinRequest.js index 9e58b2b..fd37cb5 100644 --- a/controllers/registration/sendJoinRequest.js +++ b/controllers/registration/sendJoinRequest.js @@ -72,6 +72,19 @@ const sendJoinRequest = expressAsyncHandler(async (req, res, next) => { return next(new ApiError(400, `Team is full (${targetTeam.teamSize}/${maxSize} members).`)); } + // Limit: max 3 pending requests at a time per user per event + const pendingCount = await prisma.teamJoinRequest.count({ + where: { + formId, + requesterEmail: email, + status: "PENDING" + } + }); + + if (pendingCount >= 3) { + return next(new ApiError(400, "You already have 3 pending requests. Wait for a response before sending more.")); + } + // Check no existing PENDING request from this user to this team const existingRequest = await prisma.teamJoinRequest.findFirst({ where: { From 5b269ffd96c956f88dcb3fd525e5cce361fd3b2a Mon Sep 17 00:00:00 2001 From: Hardik Gupta Date: Sun, 1 Mar 2026 02:41:18 +0530 Subject: [PATCH 14/15] upd --- emailTemplates/teamJoinRequest.html | 2 +- package-lock.json | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/emailTemplates/teamJoinRequest.html b/emailTemplates/teamJoinRequest.html index 999be89..2762ae8 100644 --- a/emailTemplates/teamJoinRequest.html +++ b/emailTemplates/teamJoinRequest.html @@ -108,7 +108,7 @@
-

🏆 Team Join Request

+

Team Join Request

Hi {{leaderName}},

diff --git a/package-lock.json b/package-lock.json index f373995..a8b2efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1892,8 +1892,7 @@ "version": "0.0.1464554", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1464554.tgz", "integrity": "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dijkstrajs": { "version": "1.0.3", @@ -3575,6 +3574,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -4279,7 +4279,6 @@ "integrity": "sha512-Z1Uqodk44diztImxALgJJfNl2Uisl9xDRvqybMKEBYJLNKNhDfAHf+ZIJbZyYiBhLMbKU9cYGdDVG5IIXEnL2Q==", "devOptional": true, "hasInstallScript": true, - "peer": true, "dependencies": { "@prisma/engines": "5.16.1" }, @@ -4384,7 +4383,6 @@ "integrity": "sha512-QbccB/LgxX4tSZRzr9KQ1Jajdvu3n35Dlf/Otjz0QfR+6mDoZdMWLcWF94uQoC3OJerCyYm5hlU2Ru4nBoId2A==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@puppeteer/browsers": "2.10.5", "chromium-bidi": "5.1.0", @@ -4965,6 +4963,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } From 66d524cf8bea3ee3aefcb62fb16562739712270d Mon Sep 17 00:00:00 2001 From: Hardik Gupta Date: Sun, 1 Mar 2026 18:37:50 +0530 Subject: [PATCH 15/15] updated with some fixes --- controllers/blog/gemini.js | 10 ++--- controllers/forms/deleteForm.js | 37 ++++++++++++++----- .../individualEventRegistrationSuccess.html | 2 +- emailTemplates/newUserAutoRegistration.html | 2 +- emailTemplates/removedMember.html | 2 +- .../teamEventRegistrationSuccess.html | 2 +- emailTemplates/teamInvitation.html | 2 +- emailTemplates/teamJoinAccepted.html | 2 +- emailTemplates/teamJoinRejected.html | 2 +- emailTemplates/teamJoinRequest.html | 8 ++-- 10 files changed, 43 insertions(+), 26 deletions(-) diff --git a/controllers/blog/gemini.js b/controllers/blog/gemini.js index 0fd3762..e07ea68 100644 --- a/controllers/blog/gemini.js +++ b/controllers/blog/gemini.js @@ -21,10 +21,10 @@ const getSummary = async (req, res) => { const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); const result = await model.generateContent(prompt); const text = await result.response.text(); - console.log("✅ Gemini /summary response:", text); + console.log(" Gemini /summary response:", text); res.json({ summary: text.trim() }); } catch (err) { - console.error("❌ Error in /summary:", err); + console.error(" Error in /summary:", err); res.status(500).json({ error: "Something went wrong with Gemini Summary API." }); } }; @@ -76,7 +76,7 @@ const getAutofill = async (req, res) => { ); if (bannerImage) { - console.log("✅ Picked banner image:", bannerImage); + console.log(" Picked banner image:", bannerImage); } else { console.warn("⚠️ No suitable banner image found."); } @@ -136,7 +136,7 @@ ${blogText} const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); const result = await model.generateContent(prompt); const responseText = await result.response.text(); - console.log("✅ Gemini /autofill response:", responseText); + console.log(" Gemini /autofill response:", responseText); // 🧠 Parse Gemini response let parsed; @@ -162,7 +162,7 @@ ${blogText} }); } catch (err) { - console.error("❌ Error in /autofill:", err); + console.error(" Error in /autofill:", err); if (browser) await browser.close(); res.status(500).json({ error: "Something went wrong with Gemini Autofill." }); } diff --git a/controllers/forms/deleteForm.js b/controllers/forms/deleteForm.js index ccc21c1..07fec94 100644 --- a/controllers/forms/deleteForm.js +++ b/controllers/forms/deleteForm.js @@ -9,26 +9,43 @@ const deleteImage = require('../../utils/image/deleteImage'); //@route DELETE /api/form/deleteForm/:id //@access Admins const deleteForm = async (req, res, next) => { - console.log("deleteForm"); + console.log("deleteForm: start"); try { const formId = req.params.id; + console.log("deleteForm: formId", formId); + console.log("deleteForm: user", { + id: req.user?.id, + email: req.user?.email, + access: req.user?.access, + }); + + if (!formId) { + return next(new ApiError(400, "Form id is required")); + } - const deletedForm = await prisma.form.delete({ + const existingForm = await prisma.form.findUnique({ where: { id: formId }, }); + if (!existingForm) { + console.log("deleteForm: form not found", formId); + return next(new ApiError(404, "Form not found")); + } + + const [registrationsDeleted, trackersDeleted, deletedForm] = await prisma.$transaction([ + prisma.formRegistration.deleteMany({ where: { formId } }), + prisma.registrationTracker.deleteMany({ where: { formId } }), + prisma.form.delete({ where: { id: formId } }), + ]); + + console.log("deleteForm: registrationsDeleted", registrationsDeleted?.count); + console.log("deleteForm: trackersDeleted", trackersDeleted?.count); + // Delete image from cloudinary using promise const imageDeletePromise = deletedForm && deletedForm.info && deletedForm.info.eventImg ? deleteImage(deletedForm.info.eventImg, 'FormImages') : Promise.resolve(); - // Delete all registrations - if (req.body.deleteRegistrations) { - await prisma.formRegistration.deleteMany({ - where: { formId: formId } - }); - } - // Handle the image deletion promise imageDeletePromise .then((result) => { @@ -47,4 +64,4 @@ const deleteForm = async (req, res, next) => { return next(new ApiError(500, 'Error in deleting form', error)); } }; -module.exports = { deleteForm }; \ No newline at end of file +module.exports = { deleteForm }; diff --git a/emailTemplates/individualEventRegistrationSuccess.html b/emailTemplates/individualEventRegistrationSuccess.html index 2f9ab16..9f4c7fc 100644 --- a/emailTemplates/individualEventRegistrationSuccess.html +++ b/emailTemplates/individualEventRegistrationSuccess.html @@ -56,7 +56,7 @@

Registration Successful for {{eventName}}

FED TECH Team.

diff --git a/emailTemplates/newUserAutoRegistration.html b/emailTemplates/newUserAutoRegistration.html index afd8a78..95b275d 100644 --- a/emailTemplates/newUserAutoRegistration.html +++ b/emailTemplates/newUserAutoRegistration.html @@ -64,7 +64,7 @@

Welcome to FED KIIT

FED KIIT Team

diff --git a/emailTemplates/removedMember.html b/emailTemplates/removedMember.html index da79bcc..be00fc1 100644 --- a/emailTemplates/removedMember.html +++ b/emailTemplates/removedMember.html @@ -80,7 +80,7 @@

Team Membership Update

FED KIIT Team

diff --git a/emailTemplates/teamEventRegistrationSuccess.html b/emailTemplates/teamEventRegistrationSuccess.html index 6d73388..83d5426 100644 --- a/emailTemplates/teamEventRegistrationSuccess.html +++ b/emailTemplates/teamEventRegistrationSuccess.html @@ -74,7 +74,7 @@

Registration Successful for {{eventName}}

FED KIIT Team

diff --git a/emailTemplates/teamInvitation.html b/emailTemplates/teamInvitation.html index f83ca7d..8171a67 100644 --- a/emailTemplates/teamInvitation.html +++ b/emailTemplates/teamInvitation.html @@ -89,7 +89,7 @@

You're Invited to Join a Team!

FED KIIT Team

diff --git a/emailTemplates/teamJoinAccepted.html b/emailTemplates/teamJoinAccepted.html index 03ba37f..468613a 100644 --- a/emailTemplates/teamJoinAccepted.html +++ b/emailTemplates/teamJoinAccepted.html @@ -79,7 +79,7 @@

🎉 You're In!

FED KIIT Team

diff --git a/emailTemplates/teamJoinRejected.html b/emailTemplates/teamJoinRejected.html index 7c8f695..cb46b6b 100644 --- a/emailTemplates/teamJoinRejected.html +++ b/emailTemplates/teamJoinRejected.html @@ -81,7 +81,7 @@

Join Request Update

FED KIIT Team

diff --git a/emailTemplates/teamJoinRequest.html b/emailTemplates/teamJoinRequest.html index 2762ae8..6339e30 100644 --- a/emailTemplates/teamJoinRequest.html +++ b/emailTemplates/teamJoinRequest.html @@ -108,7 +108,7 @@
-

Team Join Request

+

Team Join Request

Hi {{leaderName}},

@@ -121,8 +121,8 @@

Team Join Request

This request will expire in {{expiryHours}} hours.

@@ -132,7 +132,7 @@

Team Join Request