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.
+
+
+
+
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
+
+
+
+
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 @@
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 @@
+
+
+
+
+
+