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 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/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..c85da16 --- /dev/null +++ b/config/resend.js @@ -0,0 +1,12 @@ +/** + * 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) + */ + +// This file is deprecated - Resend clients are created in nodeMailer.js +module.exports = {}; 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/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..27dab05 --- /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 && info.relatedEvent !== "null") ? 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..84df9ed 100644 --- a/controllers/registration/registrationController.js +++ b/controllers/registration/registrationController.js @@ -6,6 +6,19 @@ const { markAttendance, exportAttendance, } = require("./markAttendance"); +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"); +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 +27,17 @@ module.exports = { getAttendanceCode, markAttendance, exportAttendance, + leaveTeam, + inviteTeamMember, + getTeamInviteLink, + renameTeam, + removeTeamMember, + // [v2] New team management exports + createTeam, + searchTeams, + joinTeam, + sendJoinRequest, + respondJoinRequest, + checkJoinRequestUpdates, + checkAllJoinRequestUpdates, }; diff --git a/controllers/registration/removeTeamMember.js b/controllers/registration/removeTeamMember.js new file mode 100644 index 0000000..7b3ab94 --- /dev/null +++ b/controllers/registration/removeTeamMember.js @@ -0,0 +1,134 @@ +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 +//@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")); + } + + // 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: { + 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 + }); + + // 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 & informed through ${normalizedEmail}` + }); + + } 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/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..7d33b64 --- /dev/null +++ b/controllers/registration/respondJoinRequest.js @@ -0,0 +1,244 @@ +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 = (formId, toastType, name) => { + let url = `${frontendUrl}/Events/${formId}/team`; + 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 } + }); + return redirectToTeam(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 } + }); + + // 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(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(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(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(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(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(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(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..fd37cb5 --- /dev/null +++ b/controllers/registration/sendJoinRequest.js @@ -0,0 +1,175 @@ +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).`)); + } + + // 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: { + 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/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 new file mode 100644 index 0000000..be00fc1 --- /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 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 new file mode 100644 index 0000000..8171a67 --- /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..468613a --- /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..cb46b6b --- /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..6339e30 --- /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/index.js b/index.js index 8aa91cd..60d3e4f 100644 --- a/index.js +++ b/index.js @@ -107,33 +107,38 @@ 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" +]; + +// 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, true); // reflect request origin + } + callback(null, false); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + 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()); -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" - ]; - - if (allowedOrigins.includes(origin)) { - callback(null, true); - } else { - callback(null, true); // Allow all in dev, restrict in production - } - }, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - credentials: true, -})); -app.options('*', cors()); // handle preflight requests +// 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", 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/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..f797084 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,26 @@ 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); +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); +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( diff --git a/utils/email/nodeMailer.js b/utils/email/nodeMailer.js index f0ed3fe..ca30675 100644 --- a/utils/email/nodeMailer.js +++ b/utils/email/nodeMailer.js @@ -1,60 +1,106 @@ -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", +/** + * 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 ") + */ + +const { Resend } = require("resend"); + +// Initialize Resend clients for both domains +const resendPrimary = process.env.RESEND_API_KEY + ? new Resend(process.env.RESEND_API_KEY) + : null; + +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 + * 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) + * @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 = []) { + // Validate environment + if (!resendPrimary && !resendSecondary) { + throw new Error("[Email] No Resend API keys configured. Set RESEND_API_KEY in .env"); + } + + // Build email options + const emailOptions = { + to: to, + subject: subject, html: htmlContent, text: textContent || htmlContent.replace(/<[^>]+>/g, ""), - ...(attachments.length > 0 && { attachments }), + reply_to: "fedkiit@gmail.com", }; - // 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); - } + // Add attachments if present + if (attachments && attachments.length > 0) { + emailOptions.attachments = attachments.map(att => ({ + filename: att.filename, + content: att.content, + })); + } + + // ============ TRY PRIMARY SENDER ============ + if (resendPrimary && process.env.EMAIL_FROM) { + try { + console.log(`[Email] Sending via PRIMARY: ${process.env.EMAIL_FROM}`); + + const { data, error } = await resendPrimary.emails.send({ + ...emailOptions, + from: process.env.EMAIL_FROM, }); - } else { - console.log("Primary email sent successfully:", info); + if (!error && data) { + console.log(`[Email] SUCCESS via PRIMARY:`, data.id); + return data; + } + + console.error(`[Email] PRIMARY failed:`, error?.message || "Unknown error"); + } catch (err) { + console.error(`[Email] PRIMARY exception:`, err.message); } - }); + } + + // ============ TRY SECONDARY SENDER (FALLBACK) ============ + if (resendSecondary && process.env.EMAIL_FROM_2) { + try { + console.log(`[Email] Sending via SECONDARY: ${process.env.EMAIL_FROM_2}`); + + const { data, error } = await resendSecondary.emails.send({ + ...emailOptions, + from: process.env.EMAIL_FROM_2, + }); + + 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 };