diff --git a/.husky/pre-commit b/.husky/pre-commit index 6c3b9637..64778a1e 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,3 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -cd frontend && npx lint-staged && cd ../backend && npx lint-staged diff --git a/backend/.env.example b/backend/.env.example index d1c8330e..f19495cf 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,8 +1,8 @@ MONGODB_URI = mongodb://localhost:27017/cosadatabase JWT_SECRET_TOKEN='secret-token' FRONTEND_URL=http://localhost:3000 -BACKEND_URL=http://localhost:5000 -PORT=5000 +BACKEND_URL=http://localhost:8000 +PORT=8000 GOOGLE_CLIENT_ID=OAuth_Client_ID_from_google_cloud_console GOOGLE_CLIENT_SECRET=OAuth_Client_Secret_from_google_cloud_console diff --git a/backend/db.js b/backend/config/db.js similarity index 85% rename from backend/db.js rename to backend/config/db.js index 18af6fca..cf506a7b 100644 --- a/backend/db.js +++ b/backend/config/db.js @@ -5,10 +5,8 @@ dotenv.config(); const connectDB = async () => { try { const ConnectDB = process.env.MONGODB_URI; - await mongoose.connect(ConnectDB, { - useNewUrlParser: true, - useUnifiedTopology: true, - }); + //Removing the options as they are no longer needed from mongoose6+ + await mongoose.connect(ConnectDB); console.log("MongoDB Connected"); } catch (error) { console.error("MongoDB Connection Error:", error); diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js new file mode 100644 index 00000000..1fc30ff9 --- /dev/null +++ b/backend/config/passportConfig.js @@ -0,0 +1,116 @@ +const passport = require("passport"); +const GoogleStrategy = require("passport-google-oauth20").Strategy; +const LocalStrategy = require("passport-local").Strategy; +const isIITBhilaiEmail = require("../utils/isIITBhilaiEmail"); +const User = require("../models/userSchema"); +const { loginValidate } = require("../utils/authValidate"); +const bcrypt = require("bcrypt"); +// Google OAuth Strategy +passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${process.env.BACKEND_URL}/auth/google/verify`, // Update with your callback URL + }, + async (accessToken, refreshToken, profile, done) => { + // Check if the user already exists in your database + const email = profile.emails?.[0]?.value; + if (!email) { + //console.log("No email found in Google profile"); + return done(null, false, { message: "Email not available from Google." }); + } + + if (!isIITBhilaiEmail(profile.emails[0].value)) { + console.log("Google OAuth blocked for: ", profile.emails[0].value); + return done(null, false, { + message: "Only @iitbhilai.ac.in emails are allowed.", + }); + } + try { + const user = await User.findOne({ username: email }); + //console.log("Looking for existing user with email:", email, "Found:", !!user); + + if (user) { + // If user exists, return the user + //console.log("Returning existing user:", user.username); + return done(null, user); + } + // If user doesn't exist, create a new user in your database + const newUser = await User.create({ + username: email, + role: "STUDENT", + strategy: "google", + personal_info: { + name: profile.displayName || "No Name", + email: email, + profilePic: + profile.photos && profile.photos.length > 0 + ? profile.photos[0].value + : "https://www.gravatar.com/avatar/?d=mp", + }, + onboardingComplete: false, + }); + //console.log("User is",newUser); + return done(null, newUser); + } catch (error) { + console.error("Error in Google strategy:", error); + return done(error); + } + }, + ), +); + +//Local Strategy +passport.use(new LocalStrategy(async (username, password, done) => { + + const result = loginValidate.safeParse({ username, password }); + + if (!result.success) { + let errors = result.error.issues.map((issue) => issue.message); + return done(null, false, {message: errors}); + } + + try{ + + const user = await User.findOne({ username }); + if (!user) { + return done(null, false, {message: "Invalid user credentials"}); + } + + + if (user.strategy !== "local" || !user.password) { + return done(null, false, { message: "Invalid login method" }); + } + + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) { + return done(null, false, { message: "Invalid user credentials" }); + } + return done(null, user); + }catch(err){ + return done(err); + } + +})); + + +//When login succeeds this will run +// serialize basically converts user obj into a format that can be transmitted(like a string, etc...) +// here take user obj and done callback and store only userId in session +passport.serializeUser((user, done) => { + done(null, user._id.toString()); +}); + +//When a request comes in, take the stored id, fetch full user from DB, and attach it to req.user. +passport.deserializeUser(async (id, done) => { + try { + let user = await User.findById(id); + if(!user) return done(null, false); + done(null, user); + } catch (err) { + done(err, null); + } +}); + +module.exports = passport; diff --git a/backend/controllers/analyticsController.js b/backend/controllers/analyticsController.js index d7e4e982..47ca6fbc 100644 --- a/backend/controllers/analyticsController.js +++ b/backend/controllers/analyticsController.js @@ -1,7 +1,13 @@ -const {User, Achievement, UserSkill, Event, Position, PositionHolder,OrganizationalUnit}=require('../models/schema'); const mongoose = require("mongoose"); const getCurrentTenureRange = require('../utils/getTenureRange'); +const User = require("../models/userSchema"); +const Achievement = require("../models/achievementSchema"); +const Position = require("../models/positionSchema"); +const PositionHolder = require("../models/positionHolderSchema"); +const OrganizationalUnit = require("../models/organizationSchema"); +const Event = require("../models/eventSchema"); +const { UserSkill } = require("../models/schema"); exports.getPresidentAnalytics= async (req,res) => { try { diff --git a/backend/controllers/certificateBatchController.js b/backend/controllers/certificateBatchController.js new file mode 100644 index 00000000..4ee875e9 --- /dev/null +++ b/backend/controllers/certificateBatchController.js @@ -0,0 +1,513 @@ +const User = require("../models/userSchema"); +const { CertificateBatch } = require("../models/certificateSchema"); +const { + validateBatchSchema, + validateBatchUsersIds, + zodObjectId, +} = require("../utils/batchValidate"); +const { findEvent } = require("../services/event.service"); +const { findTemplate } = require("../services/template.service"); +const { getUserPosition, getApprovers } = require("../services/user.service"); +const { getOrganization } = require("../services/organization.service"); +const { HttpError } = require("../utils/httpError"); + +async function createBatch(req, res) { + //console.log(req.user); + try { + const { id, role } = req.user; + //console.log(req.body); + //to get user club + // positionHolders({user_id: id}) -> positions({_id: position_id}) -> organizationalUnit({_id: unit_id}) -> unit_id = "Club name" + const { + title, + eventId, + templateId, + signatoryDetails, + students: users, + action, + } = req.body; + const validation = validateBatchSchema.safeParse({ + title, + eventId, + templateId, + signatoryDetails, + users, + }); + + if(!["Submitted", "Draft"].includes(action)){ + return res.status(400).json({message: "Invalid action"}); + } + + let lifecycleStatus, approvalStatus; + if (action === "Submitted") { + lifecycleStatus = "Submitted"; + approvalStatus = "Pending"; + } + + if (!validation.success) { + let errors = validation.error.issues.map((issue) => issue.message); + return res.status(400).json({ message: errors }); + } + + const event = await findEvent(eventId); + const template = await findTemplate(templateId); + + // Get coordinator's position and unit + const position = await getUserPosition(id); + + const eventOrgId = + event.organizing_unit_id && event.organizing_unit_id.toString(); + const positionUnitId = position.unit_id && position.unit_id.toString(); + + if ( + eventOrgId !== positionUnitId || + role.toUpperCase() !== position.title + ) { + return res + .status(403) + .json({ message: "You are not authorized to initiate batches." }); + } + + // Ensure org is a Club + const club = await getOrganization(position.unit_id); + if (club.type.toLowerCase() !== "club") { + return res.status(403).json({ message: "Organization is not a Club" }); + } + + // Resolve General Secretary and President objects for the club + const { gensecObj, presidentObj } = await getApprovers(club.category); + const approverIds = [gensecObj._id, presidentObj._id]; + + // Validate user ids and existence (bulk query + duplicate detection) + const uniqueUsers = [...new Set(users.map((u) => u.toString()))]; + const duplicates = uniqueUsers.length !== users.length; + if (duplicates) { + return res + .status(400) + .json({ message: "Duplicate user ids are not allowed in a batch" }); + } + + const existing = await User.find({ _id: { $in: users } }) + .select("_id") + .lean(); + const existingSet = new Set(existing.map((u) => u._id.toString())); + const missing = uniqueUsers + .filter((u) => !existingSet.has(u)) + .map((uid) => ({ uid, ok: false, reason: "User not found" })); + + if (missing.length > 0) { + return res + .status(400) + .json({ message: "Invalid user data sent", details: missing }); + } + + const newBatch = await CertificateBatch.create({ + title, + eventId: event._id, + templateId: template._id, + initiatedBy: id, + approverIds, + approvalStatus: approvalStatus, + lifecycleStatus: lifecycleStatus || "Draft", + users: users, + signatoryDetails, + }); + + res.json({ message: "New Batch created successfully" }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + res.status(500).json({ message: err.message || "Internal server error" }); + } +} + +async function editBatch(req, res) { + try { + const { id } = req.user; + const { + batchId, + title, + eventId, + templateId, + signatoryDetails, + students: users, + action, + } = req.body; + + const user = await User.findById(id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + if (!["Submitted", "Draft"].includes(action)) { + return res.status(400).json({ message: "Invalid action" }); + } + const userIds = users.map((user) => user._id); + const validation = validateBatchSchema.safeParse({ + title, + eventId: eventId._id || eventId, + templateId, + signatoryDetails, + users: userIds, + }); + + const objectId = zodObjectId.safeParse(batchId); + let errors = []; + if (!validation.success) errors.push(...validation.error.issues); + if (!objectId.success) errors.push(...objectId.error.issues); + + errors = errors.map((issue) => issue.message); + if (errors.length > 0) return res.status(400).json({ message: errors }); + + const batch = await CertificateBatch.findById(batchId); + Object.assign(batch, validation.data); + + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + + if (batchId && action === "Submitted") batch.approvalStatus = "Pending"; + batch.lifecycleStatus = action; + await batch.save(); + + return res.json({ message: "Batch updated successfully" }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + res.status(500).json({ message: err.message || "Internal server error" }); + } +} + +async function getBatchUsers(req, res) { + try { + let { userIds } = req.body; + userIds = userIds.map((user) => user._id); + + const validation = validateBatchUsersIds.safeParse(userIds); + if (!validation.success) { + let errors = validation.error.issues.map((issue) => issue.message); + return res.status(400).json({ message: errors }); + } + const users = await User.find({ _id: { $in: userIds } }).select(""); + const foundIds = users.map((u) => u._id.toString()); + const missingIds = userIds.filter( + (id) => !foundIds.includes(id.toString()), + ); + if (missingIds.length > 0) { + return res + .status(404) + .json({ message: `Users not found: ${missingIds.join(", ")}` }); + } + return res.json({ message: users }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + res.status(500).json({ message: err.message || "Internal server error" }); + } +} + +async function duplicateBatch(req, res) { + try { + const { batchId } = req.body; + const { id } = req.user; + + const objectIdValidation = zodObjectId.safeParse(batchId); + if (!objectIdValidation.success) { + return res.status(400).json({ message: "Invalid batch ID" }); + } + + const batch = await CertificateBatch.findById(batchId) + .lean() + .select( + "title eventId templateId initiatedBy approverIds users signatoryDetails -_id", + ); + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + + // Check authorization: only the initiator can duplicate + if (batch.initiatedBy.toString() !== id) { + return res.status(403).json({ + message: "You are not authorized to duplicate this batch", + }); + } + + batch.title = `${batch.title} (Copy)`; + const newBatch = await CertificateBatch.create({ + ...batch, + lifecycleStatus: "Draft", + }); + + return res.json({ message: "Batch duplicated successfully" }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + res.status(500).json({ message: err.message || "Internal server error" }); + } +} + +async function deleteBatch(req, res) { + try { + const { batchId } = req.body; + const { id } = req.user; + + const objectIdValidation = zodObjectId.safeParse(batchId); + if (!objectIdValidation.success) { + return res.status(400).json({ message: "Invalid batch ID" }); + } + + const batch = await CertificateBatch.findOneAndDelete({ + _id: batchId, + initiatedBy: id, + }); + if (!batch) { + return res + .status(403) + .json({ message: "Batch not found or unauthorized" }); + } + + return res.json({ message: "Batch deleted successfully" }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + return res + .status(500) + .json({ message: err.message || "Internal server error" }); + } +} + +async function archiveBatch(req, res) { + try { + const { batchId } = req.body; + const { id } = req.user; + + const objectIdValidation = zodObjectId.safeParse(batchId); + if (!objectIdValidation.success) { + return res.status(400).json({ message: "Invalid batch ID" }); + } + + const batch = await CertificateBatch.findOneAndUpdate( + { _id: batchId, initiatedBy: id }, + { lifecycleStatus: "Archived" }, + { new: true }, + ); + if (!batch) { + return res + .status(403) + .json({ message: "Batch not found or unauthorized" }); + } + + return res.json({ message: "Batch archived successfully" }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + return res + .status(500) + .json({ message: err.message || "Internal server error" }); + } +} + +async function getUserBatches(req, res) { + try { + const { id } = req.user; + const userId = req.params.userId; + let batches; + const user = await User.findById(id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + if (user.role === "PRESIDENT" || user.role.startsWith("GENSEC")) { + batches = await CertificateBatch.find({ + approverIds: id, + lifecycleStatus: { $ne: "Draft" }, + }); + } else { + if (id.toString() !== userId.toString()) { + return res.status(403).json({ message: "User is Unauthorized" }); + } + batches = await CertificateBatch.find({ + initiatedBy: id, + }); + } + + batches = await CertificateBatch.populate(batches, [ + { + path: "eventId", + select: "title organizing_unit_id schedule", + populate: { + path: "organizing_unit_id", + select: "name", + }, + }, + { + path: "initiatedBy", + select: "personal_info", + }, + { + path: "users", + select: "personal_info academic_info", + }, + ]); + if (!batches || batches.length === 0) { + return res + .status(404) + .json({ message: "No batches found for this user" }); + } + + return res.json({ message: batches }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + return res + .status(500) + .json({ message: err.message || "Internal server error" }); + } +} + +async function approverEditBatch(req, res) { + const { id } = req.user; + + const user = await User.findById(id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + if (user.role !== "PRESIDENT" && !user.role.startsWith("GENSEC")) { + return res.status(403).json({ message: "Access denied" }); + } + + let { users } = req.body; + const validation = validateBatchUsersIds.safeParse(users); + if (!validation.success) { + let errors = validation.error.issues.map((issue) => issue.message); + return res.status(400).json({ message: errors }); + } + + const { _id } = req.body; + const batch = await CertificateBatch.findById(_id); + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + batch.users = users; + await batch.save(); + res.status(200).json({ message: "Batch updated successfully" }); +} + +async function approveBatch(req, res) { + try { + const batchId = req.params.batchId; + const { id } = req.user; + + const validateId = zodObjectId.safeParse(batchId); + if (!validateId.success) { + return res.status(400).json({ message: "Invalid batch ID" }); + } + + const batch = await CertificateBatch.findOne({ + _id: batchId, + approverIds: id, + }); + + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + + const level = batch.currentApprovalLevel; + if (level === 0) { + batch.currentApprovalLevel = 1; + batch.lifecycleStatus = "Submitted"; + batch.approvalStatus = "Pending"; + } else if (level === 1) { + batch.currentApprovalLevel = 2; + batch.lifecycleStatus = "Active"; + batch.approvalStatus = "Approved"; + } + + await batch.save(); + return res.status(200).json({ message: "Batch approved successfully" }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + return res + .status(500) + .json({ message: err.message || "Internal server error" }); + } +} + +async function rejectBatch(req, res) { + try { + const batchId = req.params.batchId; + const { id } = req.user; + + const validateId = zodObjectId.safeParse(batchId); + if (!validateId.success) { + return res.status(400).json({ message: "Invalid batch ID" }); + } + + const batch = await CertificateBatch.findOne({ + _id: batchId, + approverIds: id, + }); + + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + + const level = batch.currentApprovalLevel; + if (level === 0 || level === 1) { + batch.currentApprovalLevel += 1; + batch.lifecycleStatus = "Submitted"; + batch.approvalStatus = "Rejected"; + } + + await batch.save(); + return res.status(200).json({ message: "Batch rejected successfully" }); + } catch (err) { + if (err instanceof HttpError) { + const payload = { message: err.message }; + if (err.details) payload.details = err.details; + return res.status(err.statusCode).json(payload); + } + return res + .status(500) + .json({ message: err.message || "Internal server error" }); + } +} + +module.exports = { + createBatch, + editBatch, + getBatchUsers, + duplicateBatch, + deleteBatch, + archiveBatch, + getUserBatches, + approverEditBatch, + approveBatch, + rejectBatch, +}; diff --git a/backend/controllers/certificateController.js b/backend/controllers/certificateController.js new file mode 100644 index 00000000..4e75d29d --- /dev/null +++ b/backend/controllers/certificateController.js @@ -0,0 +1,53 @@ + +const { Certificate } = require("../models/certificateSchema") + +/** + * { + _id: "1", + event: "Tech Fest 2024", + issuedBy: "Computer Science Club", + date: "2024-01-15", + status: "Approved", + certificateUrl: "#", + rejectionReason: undefined, + }, + */ +async function getCertificates(req, res){ + + const id = req.user._id; + const certificates = await Certificate.find({userId: id}).populate([ + { + path: "userId", + select: "personal_info" + }, + { + path: "batchId", + select: "title lifecycleStatus approvalStatus", + populate: { + path: "eventId", + select: "title schedule" + } + } + ]); + + if(certificates.length === 0){ + return res.status(404).json({message: "No certificates found"}); + } + //console.log(certificates); + + const certificateObjs = certificates.map(cert => ({ + _id: cert._id, + event: cert.batchId.eventId.title, + issuedBy: cert.userId.personal_info.name, + date: new Date(cert.createdAt).toLocaleDateString("en-GB"), + status: cert.status, + certificateUrl: cert.certificateUrl || "#", + rejectionReason: cert.status === "Approved" ? cert.rejectionReason : "", + })) + + return res.json({message: certificateObjs}); +} + +module.exports = { + getCertificates +} \ No newline at end of file diff --git a/backend/controllers/dashboardController.js b/backend/controllers/dashboardController.js index 193c6b26..c513aa91 100644 --- a/backend/controllers/dashboardController.js +++ b/backend/controllers/dashboardController.js @@ -1,14 +1,11 @@ // controllers/dashboardController.js -const { - Feedback, - Achievement, - UserSkill, - Skill, - Event, - PositionHolder, - Position, - OrganizationalUnit, -} = require("../models/schema"); +const Feedback = require("../models/feedbackSchema"); +const Achievement = require("../models/achievementSchema"); +const Position = require("../models/positionSchema"); +const PositionHolder = require("../models/positionHolderSchema"); +const OrganizationalUnit = require("../models/organizationSchema"); +const Event = require("../models/eventSchema"); +const { UserSkill, Skill } = require("../models/schema"); const ROLES = { PRESIDENT: "PRESIDENT", diff --git a/backend/controllers/eventControllers.js b/backend/controllers/eventControllers.js index 60832b0e..7c21d13a 100644 --- a/backend/controllers/eventControllers.js +++ b/backend/controllers/eventControllers.js @@ -1,4 +1,4 @@ -const {Event} = require('../models/schema'); +const Event = require('../models/eventSchema'); // fetch 4 most recently updated events exports.getLatestEvents = async (req, res) => { @@ -6,12 +6,15 @@ exports.getLatestEvents = async (req, res) => { const latestEvents = await Event.find({}) .sort({updated_at: -1}) .limit(4) - .select('title updated_at schedule.venue status'); + .select('title updatedAt schedule.venue status'); + if(!latestEvents){ + return res.status(404).json({message: "No events are created"}); + } const formatedEvents =latestEvents.map(event=>({ id: event._id, title: event.title, - date: event.updated_at.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + date: event.updatedAt?.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), venue: (event.schedule && event.schedule.venue) ? event.schedule.venue : 'TBA', status: event.status || 'TBD' })) diff --git a/backend/index.js b/backend/index.js index 4ad6d419..4224d904 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,13 +1,14 @@ const express = require("express"); require("dotenv").config(); // eslint-disable-next-line node/no-unpublished-require +const { connectDB } = require("./config/db.js"); +const MongoStore = require("connect-mongo"); +const cookieParser = require("cookie-parser"); const cors = require("cors"); const routes_auth = require("./routes/auth"); const routes_general = require("./routes/route"); const session = require("express-session"); -const bodyParser = require("body-parser"); -const { connectDB } = require("./db"); -const myPassport = require("./models/passportConfig"); // Adjust the path accordingly +const myPassport = require("./config/passportConfig.js"); // Adjust the path accordingly const onboardingRoutes = require("./routes/onboarding.js"); const profileRoutes = require("./routes/profile.js"); const feedbackRoutes = require("./routes/feedbackRoutes.js"); @@ -18,8 +19,11 @@ const positionsRoutes = require("./routes/positionRoutes.js"); const organizationalUnitRoutes = require("./routes/orgUnit.js"); const announcementRoutes = require("./routes/announcements.js"); const dashboardRoutes = require("./routes/dashboard.js"); - const analyticsRoutes = require("./routes/analytics.js"); +const certificateBatchRoutes = require("./routes/certificateBatch.js"); +const certificateRoutes = require("./routes/certificate.js"); +const templateRoutes = require("./routes/template.js"); + const porRoutes = require("./routes/por.js"); const app = express(); @@ -29,23 +33,33 @@ if (process.env.NODE_ENV === "production") { app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true })); -// Connect to MongoDB -connectDB(); +app.use(cookieParser()); -app.use(bodyParser.json()); +//Replaced bodyParser with express.json() - the new standard +app.use(express.json()); app.use( session({ - secret: "keyboard cat", + secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === "production", // HTTPS only in prod sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", // cross-origin in prod + maxAge: 60 * 60 * 1000, + httpOnly: true, }, + store: MongoStore.create({ + mongoUrl: process.env.MONGODB_URI, + //ttl option expects seconds + ttl: 60 * 60, //1hr in sec + collectionName: "sessions", + }), + name: "token", }), ); +//Needed to initialize passport and all helper methods to req object app.use(myPassport.initialize()); app.use(myPassport.session()); @@ -66,11 +80,23 @@ app.use("/api/positions", positionsRoutes); app.use("/api/orgUnit", organizationalUnitRoutes); app.use("/api/announcements", announcementRoutes); app.use("/api/dashboard", dashboardRoutes); -app.use("/api/announcements", announcementRoutes); app.use("/api/analytics", analyticsRoutes); +app.use("/api/batches", certificateBatchRoutes); +app.use("/api/certificates", certificateRoutes); +app.use("/api/templates", templateRoutes); app.use("/api/por", porRoutes); // Start the server -app.listen(process.env.PORT || 8000, () => { - console.log(`connected to port ${process.env.PORT || 8000}`); -}); + +(async function () { + // Connect to MongoDB + try { + await connectDB(); + app.listen(process.env.PORT || 8000, () => { + console.log(`connected to port ${process.env.PORT || 8000}`); + }); + } catch (error) { + console.error("Failed to start server:", error); + process.exit(1); + } +})(); diff --git a/backend/middlewares/authorizeRole.js b/backend/middlewares/authorizeRole.js index b2796fc3..a2da6928 100644 --- a/backend/middlewares/authorizeRole.js +++ b/backend/middlewares/authorizeRole.js @@ -1,8 +1,8 @@ const authorizeRole = (allowedRoles = []) => { return (req, res, next) => { - const userRole = req.user.role; + const userRole = req.user && req.user.role; if (!allowedRoles.includes(userRole)) { - return res.status(403).json({ error: "Forbidden: Insufficient role" }); + return res.status(403).json({ message: "Forbidden" }); } next(); }; diff --git a/backend/middlewares/isAuthenticated.js b/backend/middlewares/isAuthenticated.js index f04c46ef..25d1b169 100644 --- a/backend/middlewares/isAuthenticated.js +++ b/backend/middlewares/isAuthenticated.js @@ -1,7 +1,84 @@ +const jwt = require("jsonwebtoken"); + +//Passport based middleware to check whether the req are coming from authenticated users function isAuthenticated(req, res, next) { if (req.isAuthenticated && req.isAuthenticated()) { return next(); } return res.status(401).json({ message: "Unauthorized: Please login first" }); } -module.exports = isAuthenticated; + +//Token based middleware to check whether the req are coming from authenticated users or not + +function jwtIsAuthenticated(req, res, next) { + let token; + /** + * const headerData = req.headers.authorization; + if (!headerData || !headerData.startsWith("Bearer ")) { + return res.status(401).json({ message: "User not authenticated " }); + } + + token = headerData.split(" ")[1]; + */ + + token = req.cookies.token; + if(!token){ + return res.status(401).json({message: "User not authenticated"}); + } + + try { + const userData = jwt.verify(token, process.env.JWT_SECRET_TOKEN); + req.user = userData; + //console.log(userData); + next(); + } catch (err) { + res.status(401).json({ message: "Invalid or expired token sent" }); + } +} + +module.exports = { + isAuthenticated, + jwtIsAuthenticated, +}; + +/* + +const presidentObj = await User.findById(presidentId); + + console.log(presidentObj._id); + if(!gensecObj || !presidentObj) { + return res.status(500).json({ message: "Approvers not found" }); + } + + const approverIds = [gensecObj._id.toString(), presidentId]; + + const userChecks = await Promise.all( + users.map(async (uid) => { + const validation = zodObjectId.safeParse(uid); + if(!validation){ + return {uid, ok: false, reason:"Invalid ID"}; + } + + const userObj = await User.findById(uid); + if(!userObj) return {uid, ok:false, reason: "User not found"}; + + return {uid, ok: true}; + }) + ); + + const invalidData = userChecks.filter((c) => !c.ok); + if(invalidData.length > 0){ + return res.status(400).json({message: "Invalid user data sent", details: invalidData}); + } + + const newBatch = await CertificateBatch.create({ + title, + unit_id, + commonData, + template_id, + initiatedBy: id, + approverIds, + users + }); + +*/ diff --git a/backend/models/achievementSchema.js b/backend/models/achievementSchema.js new file mode 100644 index 00000000..9dbf6be8 --- /dev/null +++ b/backend/models/achievementSchema.js @@ -0,0 +1,54 @@ +const mongoose = require("mongoose"); +//achievements collection +const achievementSchema = new mongoose.Schema({ + achievement_id: { + type: String, + required: true, + unique: true, + }, + user_id: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + title: { + type: String, + required: true, + }, + description: String, + category: { + type: String, + required: true, + }, + type: { + type: String, + }, + level: { + type: String, + }, + date_achieved: { + type: Date, + required: true, + }, + position: { + type: String, + }, + event_id: { + type: mongoose.Schema.Types.ObjectId, + ref: "Event", + default: null, // optional + }, + certificate_url: String, + verified: { + type: Boolean, + default: false, + }, + verified_by: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + }, +}, { timestamps: true}); + +const Achievement = mongoose.model("Achievement", achievementSchema); +module.exports = Achievement; diff --git a/backend/models/certificateSchema.js b/backend/models/certificateSchema.js new file mode 100644 index 00000000..0970641b --- /dev/null +++ b/backend/models/certificateSchema.js @@ -0,0 +1,169 @@ +const mongoose = require("mongoose"); + +const certificateBatchSchema = new mongoose.Schema( + { + title: { type: String, required: true }, + eventId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Event", + }, + templateId: { type: mongoose.Schema.Types.ObjectId, ref: "Template" }, + initiatedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + approverIds: { + type: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + required: true, + }, + approvalStatus: { + type: String, + enum: ["Pending", "Approved", "Rejected"], + required: function () { + return this.lifecycleStatus !== "Draft"; + }, + }, + lifecycleStatus: { + type: String, + enum: ["Draft", "Submitted", "Active", "Archived"], + default: "Draft", + }, + currentApprovalLevel: { + type: Number, + default: 0, + max: 2, + }, + users: { + type: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + required: function () { + return this.lifecycleStatus === "Draft" ? false : true; + }, + }, + signatoryDetails: { + type: [ + { + name: { type: String, required: true }, + signature: { type: String, default: this.name }, + role: { type: String, required: true }, + }, + ], + required: function () { + return this.lifecycleStatus === "Draft" ? false : true; + }, + }, + }, + { + timestamps: true, + }, +); + +const certificateSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + batchId: { + type: mongoose.Schema.Types.ObjectId, + ref: "CertificateBatch", + required: true, + }, + status: { + type: String, + required: true, + enum: ["Pending", "Approved", "Rejected"], + default: "Pending", + }, + rejectionReason: { + type: String, + required: function () { + return this.status === "Rejected"; + }, + }, + certificateUrl: { + type: String, + required: function () { + return this.status === "Approved"; + }, + }, + certificateId: { + type: String, + required: function () { + return this.status === "Approved"; + }, + }, + }, + { + timestamps: true, + }, +); + +//Indexed to serve the purpose of "Get pending batches for the logged-in approver." +/* + +_id approverIds status +1 [A, B, C] PendingL1 +2 [B, D] PendingL1 +3 [A, D] PendingL2 +4 [B] PendingL1 + +Index entries for B + +approverIds _id +B 1 +B 2 +B 4 + +*/ +// For "Get pending batches for logged-in approver" +// Common filter: approverIds includes user, submitted batches, pending approval. +certificateBatchSchema.index( + { + approverIds: 1, + approvalStatus: 1, + lifecycleStatus: 1, + currentApprovalLevel: 1, + }, + { + partialFilterExpression: { + approvalStatus: "Pending", + lifecycleStatus: { $in : ["Submitted"] }, + }, + }, +); + +//This is done to ensure that within each batch only 1 certificate is issued per userId. +certificateSchema.index({ batchId: 1, userId: 1 }, { unique: true }); + +//This index is for this purpose -> Get all approved certificates for the logged-in student. + +certificateSchema.index( + { userId: 1, certificateId: 1 }, + { + unique: true, + partialFilterExpression: { certificateId: { $exists: true } }, + }, +); + +const CertificateBatch = mongoose.model( + "CertificateBatch", + certificateBatchSchema, +); +const Certificate = mongoose.model("Certificate", certificateSchema); + +module.exports = { + CertificateBatch, + Certificate, +}; + +/* + +if i use partialFilter when querying i have to specify its filter condition so mongodb uses that index +so here +certificateBatchSchema.index({approverIds: 1}, {partialFilterExpression: { status: {$in: ["PendingL1", "PendingL2"]}}} ) +i need to do +CertificateBatch.find({approverIds: id, status: {$in: ["PendingL1", "PendingL2"]} } ) + +*/ diff --git a/backend/models/eventSchema.js b/backend/models/eventSchema.js new file mode 100644 index 00000000..dc803ef2 --- /dev/null +++ b/backend/models/eventSchema.js @@ -0,0 +1,117 @@ +const mongoose = require("mongoose"); + +//events collection +const eventSchema = new mongoose.Schema( + { + event_id: { + type: String, + required: true, + unique: true, + }, + title: { + type: String, + required: true, + }, + description: { type: String }, + category: { + type: String, + enum: ["cultural", "technical", "sports", "academic", "other"], + required: true, + }, + type: { + type: String, + }, + organizing_unit_id: { + type: mongoose.Schema.Types.ObjectId, + ref: "Organizational_Unit", + required: true, + }, + organizers: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + ], + schedule: { + start: Date, + end: Date, + venue: { type: String, required: true }, + mode: { + type: String, + enum: ["online", "offline", "hybrid"], + }, + }, + registration: { + required: Boolean, + start: Date, + end: Date, + fees: Number, + max_participants: Number, + }, + budget: { + allocated: Number, + spent: Number, + sponsors: [ + { + type: String, + }, + ], + }, + status: { + type: String, + enum: ["planned", "ongoing", "completed", "cancelled"], + default: "planned", + }, + participants: { + type: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + ], + required: true, + }, + winners: [ + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + position: String, // e.g., "1st", "2nd", "Best Speaker", etc. + }, + ], + feedback_summary: { + type: Object, // You can define structure if fixed + }, + media: { + images: [String], + videos: [String], + documents: [String], + }, + room_requests: [ + { + date: { type: Date, required: true }, + time: { type: String, required: true }, + room: { type: String, required: true }, + description: { type: String }, + status: { + type: String, + enum: ["Pending", "Approved", "Rejected"], + default: "Pending", + }, + requested_at: { + type: Date, + default: Date.now, + }, + reviewed_by: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + }, + ], + }, + { timestamps: true }, +); + +const Event = mongoose.model("Event", eventSchema); +module.exports = Event; diff --git a/backend/models/feedbackSchema.js b/backend/models/feedbackSchema.js new file mode 100644 index 00000000..29abe91f --- /dev/null +++ b/backend/models/feedbackSchema.js @@ -0,0 +1,69 @@ +const mongoose = require("mongoose"); + +//feedback collection +const feedbackSchema = new mongoose.Schema({ + feedback_id: { + type: String, + required: true, + unique: true, + }, + type: { + type: String, + required: true, + }, + target_id: { + type: mongoose.Schema.Types.ObjectId, + //required: true, + // We'll dynamically interpret this field based on target_type + }, + target_type: { + type: String, + required: true, + }, + feedback_by: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + // category: { + // type: String, + // enum: ['organization', 'communication', 'leadership'], + // required: true + // }, + rating: { + type: Number, + min: 1, + max: 5, + }, + comments: { + type: String, + }, + is_anonymous: { + type: Boolean, + default: false, + }, + is_resolved: { + type: Boolean, + default: false, + }, + actions_taken: { + type: String, + default: "", + }, + created_at: { + type: Date, + default: Date.now, + }, + resolved_at: { + type: Date, + default: null, + }, + resolved_by: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + }, +}); + +const Feedback = mongoose.model("Feedback", feedbackSchema); +module.exports = Feedback; diff --git a/backend/models/organizationSchema.js b/backend/models/organizationSchema.js new file mode 100644 index 00000000..7bdc0523 --- /dev/null +++ b/backend/models/organizationSchema.js @@ -0,0 +1,77 @@ +const mongoose = require("mongoose"); + +//organizational unit +const organizationalUnitSchema = new mongoose.Schema({ + unit_id: { + type: String, + required: true, + unique: true, + }, + name: { + type: String, + required: true, + unique: true, + }, + type: { + type: String, + enum: ["Council", "Club", "Committee", "independent_position"], + required: true, + }, + description: { + type: String, + }, + parent_unit_id: { + type: mongoose.Schema.Types.ObjectId, + ref: "Organizational_Unit", + default: null, + }, + hierarchy_level: { + type: Number, + required: true, + }, + category: { + type: String, + enum: ["cultural", "scitech", "sports", "academic", "independent"], + required: true, + }, + is_active: { + type: Boolean, + default: true, + }, + contact_info: { + email: { + type: String, + required: true, + unique: true, + }, + social_media: [ + { + platform: { + type: String, + required: true, + }, + url: { + type: String, + required: true, + }, + }, + ], + }, + budget_info: { + allocated_budget: { + type: Number, + default: 0, + }, + spent_amount: { + type: Number, + default: 0, + }, + }, +}, {timestamps: true}); + +const OrganizationalUnit = mongoose.model( + "Organizational_Unit", + organizationalUnitSchema, +); + +module.exports = OrganizationalUnit; diff --git a/backend/models/passportConfig.js b/backend/models/passportConfig.js index 82cb533f..07c9fe8b 100644 --- a/backend/models/passportConfig.js +++ b/backend/models/passportConfig.js @@ -1,17 +1,8 @@ const passport = require("passport"); -const LocalStrategy = require("passport-local"); +//const LocalStrategy = require("passport-local"); const GoogleStrategy = require("passport-google-oauth20"); const isIITBhilaiEmail = require("../utils/isIITBhilaiEmail"); -const { User } = require("./schema"); -// Local Strategy -passport.use( - new LocalStrategy( - { - usernameField: "email", - }, - User.authenticate(), - ), -); +const User = require("./userSchema"); // Google OAuth Strategy passport.use( diff --git a/backend/models/positionHolderSchema.js b/backend/models/positionHolderSchema.js new file mode 100644 index 00000000..65406d03 --- /dev/null +++ b/backend/models/positionHolderSchema.js @@ -0,0 +1,58 @@ +const mongoose = require("mongoose"); + +//position holder collection; +const positionHolderSchema = new mongoose.Schema( + { + por_id: { + type: String, + required: true, + unique: true, + }, + user_id: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + position_id: { + type: mongoose.Schema.Types.ObjectId, + ref: "Position", + required: true, + }, + + tenure_year: { + type: String, + required: true, + }, + appointment_details: { + appointed_by: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + appointment_date: { + type: Date, + }, + }, + performance_metrics: { + events_organized: { + type: Number, + default: 0, + }, + budget_utilized: { + type: Number, + default: 0, + }, + feedback: { + type: String, + }, + }, + status: { + type: String, + enum: ["active", "completed", "terminated"], + required: true, + }, + }, + { timestamps: true }, +); + +const PositionHolder = mongoose.model("Position_Holder", positionHolderSchema); +module.exports = PositionHolder; diff --git a/backend/models/positionSchema.js b/backend/models/positionSchema.js new file mode 100644 index 00000000..9ed5cf11 --- /dev/null +++ b/backend/models/positionSchema.js @@ -0,0 +1,54 @@ +const mongoose = require("mongoose"); + +//position +const positionSchema = new mongoose.Schema({ + position_id: { + type: String, + required: true, + unique: true, + }, + title: { + type: String, + enum: ["PRESIDENT", "GENSEC_SCITECH", "GENSEC_ACADEMIC", "GENSEC_CULTURAL", "GENSEC_SPORTS", "CLUB_COORDINATOR"], + required: true, + }, + unit_id: { + type: mongoose.Schema.Types.ObjectId, + ref: "Organizational_Unit", + required: true, + }, + position_type: { + type: String, + required: true, + }, + responsibilities: [ + { + type: String, + }, + ], + requirements: { + min_cgpa: { + type: Number, + default: 0, + }, + min_year: { + type: Number, + default: 1, + }, + skills_required: [ + { + type: String, + }, + ], + }, + description: { + type: String, + }, + position_count: { + type: Number, + }, + +}, {timestamps: true}); + +const Position = mongoose.model("Position", positionSchema); +module.exports = Position; diff --git a/backend/models/schema.js b/backend/models/schema.js index 400bc856..34562771 100644 --- a/backend/models/schema.js +++ b/backend/models/schema.js @@ -1,407 +1,6 @@ const mongoose = require("mongoose"); -const passportLocalMongoose = require("passport-local-mongoose"); -var findOrCreate = require("mongoose-findorcreate"); -//user collection - -const userSchema = new mongoose.Schema({ - user_id: { - type: String, - }, - role: { - type: String, - required: true, - }, - strategy: { - type: String, - enum: ["local", "google"], - required: true, - }, - username: { - type: String, - required: true, - unique: true, - }, - onboardingComplete: { - type: Boolean, - default: false, - }, - personal_info: { - name: { - type: String, - required: true, - }, - email: { - type: String, - }, - phone: String, - date_of_birth: Date, - gender: String, - - profilePic: { - type: String, - default: "https://www.gravatar.com/avatar/?d=mp", - }, - - cloudinaryUrl: { - type: String, - default: "", - }, - }, - - academic_info: { - program: { - type: String, - //enum: ["B.Tech", "M.Tech", "PhD", "Msc","other"], - }, - branch: String, - batch_year: String, - current_year: String, - cgpa: Number, - }, - - contact_info: { - hostel: String, - room_number: String, - socialLinks: { - github: { type: String, default: "" }, - linkedin: { type: String, default: "" }, - instagram: { type: String, default: "" }, - other: { type: String, default: "" }, - }, - }, - - status: { - type: String, - enum: ["active", "inactive", "graduated"], - default: "active", - }, - created_at: { - type: Date, - default: Date.now, - }, - updated_at: { - type: Date, - default: Date.now, - }, -}); - -userSchema.index( - { user_id: 1 }, - { - unique: true, - partialFilterExpression: { user_id: { $exists: true, $type: "string" } }, - name: "user_id_partial_unique", - }, -); - -userSchema.plugin(passportLocalMongoose); -userSchema.plugin(findOrCreate); - -//organizational unit -const organizationalUnitSchema = new mongoose.Schema({ - unit_id: { - type: String, - required: true, - unique: true, - }, - name: { - type: String, - required: true, - unique: true, - }, - type: { - type: String, - enum: ["Council", "Club", "Committee", "independent_position"], - required: true, - }, - description: { - type: String, - }, - parent_unit_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "Organizational_Unit", - default: null, - }, - hierarchy_level: { - type: Number, - required: true, - }, - category: { - type: String, - enum: ["cultural", "scitech", "sports", "academic", "independent"], - required: true, - }, - is_active: { - type: Boolean, - default: true, - }, - contact_info: { - email: { - type: String, - required: true, - unique: true, - }, - social_media: [ - { - platform: { - type: String, - required: true, - }, - url: { - type: String, - required: true, - }, - }, - ], - }, - budget_info: { - allocated_budget: { - type: Number, - default: 0, - }, - spent_amount: { - type: Number, - default: 0, - }, - }, - created_at: { - type: Date, - default: Date.now, - }, - updated_at: { - type: Date, - default: Date.now, - }, -}); - -//position - -const positionSchema = new mongoose.Schema({ - position_id: { - type: String, - required: true, - unique: true, - }, - title: { - type: String, - required: true, - }, - unit_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "Organizational_Unit", - required: true, - }, - position_type: { - type: String, - required: true, - }, - responsibilities: [ - { - type: String, - }, - ], - requirements: { - min_cgpa: { - type: Number, - default: 0, - }, - min_year: { - type: Number, - default: 1, - }, - skills_required: [ - { - type: String, - }, - ], - }, - description: { - type: String, - }, - position_count: { - type: Number, - }, - created_at: { - type: Date, - default: Date.now, - }, -}); - -//position holder collection; -const positionHolderSchema = new mongoose.Schema({ - por_id: { - type: String, - required: true, - unique: true, - }, - user_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - position_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "Position", - required: true, - }, - - tenure_year: { - type: String, - required: true, - }, - appointment_details: { - appointed_by: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - appointment_date: { - type: Date, - }, - }, - performance_metrics: { - events_organized: { - type: Number, - default: 0, - }, - budget_utilized: { - type: Number, - default: 0, - }, - feedback: { - type: String, - }, - }, - status: { - type: String, - enum: ["active", "completed", "terminated"], - required: true, - }, - created_at: { - type: Date, - default: Date.now, - }, - updated_at: { - type: Date, - default: Date.now, - }, -}); - -//events collection -const eventSchema = new mongoose.Schema({ - event_id: { - type: String, - required: true, - unique: true, - }, - title: { - type: String, - required: true, - }, - description: String, - category: { - type: String, - enum: ["cultural", "technical", "sports", "academic", "other"], - }, - type: { - type: String, - }, - organizing_unit_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "Organizational_Unit", - required: true, - }, - organizers: [ - { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - ], - schedule: { - start: Date, - end: Date, - venue: String, - mode: { - type: String, - enum: ["online", "offline", "hybrid"], - }, - }, - registration: { - required: Boolean, - start: Date, - end: Date, - fees: Number, - max_participants: Number, - }, - budget: { - allocated: Number, - spent: Number, - sponsors: [ - { - type: String, - }, - ], - }, - status: { - type: String, - enum: ["planned", "ongoing", "completed", "cancelled"], - default: "planned", - }, - participants: [ - { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - ], - winners: [ - { - user: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - position: String, // e.g., "1st", "2nd", "Best Speaker", etc. - }, - ], - feedback_summary: { - type: Object, // You can define structure if fixed - }, - media: { - images: [String], - videos: [String], - documents: [String], - }, - room_requests: [ - { - date: { type: Date, required: true }, - time: { type: String, required: true }, - room: { type: String, required: true }, - description: { type: String }, - status: { - type: String, - enum: ["Pending", "Approved", "Rejected"], - default: "Pending", - }, - requested_at: { - type: Date, - default: Date.now, - }, - reviewed_by: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - }, - ], - created_at: { - type: Date, - default: Date.now, - }, - updated_at: { - type: Date, - default: Date.now, - }, -}); //skill collection - const skillSchema = new mongoose.Schema({ skill_id: { type: String, @@ -473,126 +72,6 @@ const userSkillSchema = new mongoose.Schema({ }, }); -//achievements collection -const achievementSchema = new mongoose.Schema({ - achievement_id: { - type: String, - required: true, - unique: true, - }, - user_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - title: { - type: String, - required: true, - }, - description: String, - category: { - type: String, - required: true, - }, - type: { - type: String, - }, - level: { - type: String, - }, - date_achieved: { - type: Date, - required: true, - }, - position: { - type: String, - }, - event_id: { - type: mongoose.Schema.Types.ObjectId, - ref: "Event", - default: null, // optional - }, - certificate_url: String, - verified: { - type: Boolean, - default: false, - }, - verified_by: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - default: null, - }, - created_at: { - type: Date, - default: Date.now, - }, -}); - -//feedback collection -const feedbackSchema = new mongoose.Schema({ - feedback_id: { - type: String, - required: true, - unique: true, - }, - type: { - type: String, - required: true, - }, - target_id: { - type: mongoose.Schema.Types.ObjectId, - //required: true, - // We'll dynamically interpret this field based on target_type - }, - target_type: { - type: String, - required: true, - }, - feedback_by: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - // category: { - // type: String, - // enum: ['organization', 'communication', 'leadership'], - // required: true - // }, - rating: { - type: Number, - min: 1, - max: 5, - }, - comments: { - type: String, - }, - is_anonymous: { - type: Boolean, - default: false, - }, - is_resolved: { - type: Boolean, - default: false, - }, - actions_taken: { - type: String, - default: "", - }, - created_at: { - type: Date, - default: Date.now, - }, - resolved_at: { - type: Date, - default: null, - }, - resolved_by: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - default: null, - }, -}); - //announcement collection const announcementSchema = new mongoose.Schema({ title: { @@ -621,39 +100,15 @@ const announcementSchema = new mongoose.Schema({ type: Boolean, default: false, }, - createdAt: { - type: Date, - default: Date.now, - }, - updatedAt: { - type: Date, - default: Date.now, - }, -}); -const User = mongoose.model("User", userSchema); -const Feedback = mongoose.model("Feedback", feedbackSchema); -const Achievement = mongoose.model("Achievement", achievementSchema); +}, { timestamps: true}); + const UserSkill = mongoose.model("User_Skill", userSkillSchema); const Skill = mongoose.model("Skill", skillSchema); -const Event = mongoose.model("Event", eventSchema); -const PositionHolder = mongoose.model("Position_Holder", positionHolderSchema); -const Position = mongoose.model("Position", positionSchema); -const OrganizationalUnit = mongoose.model( - "Organizational_Unit", - organizationalUnitSchema, -); const Announcement = mongoose.model("Announcement", announcementSchema); module.exports = { - User, - Feedback, - Achievement, UserSkill, Skill, - Event, - PositionHolder, - Position, - OrganizationalUnit, Announcement, }; diff --git a/backend/models/templateSchema.js b/backend/models/templateSchema.js new file mode 100644 index 00000000..43efc424 --- /dev/null +++ b/backend/models/templateSchema.js @@ -0,0 +1,24 @@ +const mongoose = require("mongoose"); + +const templateSchema = new mongoose.Schema({ + title: { type: String, required: true }, + description: {type: String}, + design: {type: String, default: "Default"}, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + category: { + type: String, + enum: ["CULTURAL", "TECHNICAL", "SPORTS", "ACADEMIC", "OTHER"], + }, + status: { + type: String, + enum: ["Draft", "Active", "Archived"], + default: "Draft" + } +}, {timestamps: true}); + + +module.exports = mongoose.model("Template", templateSchema); \ No newline at end of file diff --git a/backend/models/userSchema.js b/backend/models/userSchema.js new file mode 100644 index 00000000..560e65bf --- /dev/null +++ b/backend/models/userSchema.js @@ -0,0 +1,108 @@ +const mongoose = require("mongoose"); +const bcrypt = require("bcrypt"); +require("dotenv").config(); + +const userSchema = new mongoose.Schema( + { + user_id: { + type: String, + }, + strategy: { + type: String, + enum: ["local", "google"], + required: true, + }, + role: { + type: String, + default: "STUDENT" + }, + username: { + type: String, + required: true, + unique: true, + }, + password: { + type: String, + required: function () { + return this.strategy === "local"; + }, + minLength: 8, + }, + onboardingComplete: { + type: Boolean, + default: false, + }, + personal_info: { + name: { + type: String, + required: true, + }, + email: { + type: String, + unique: true, + required: true, + }, + phone: String, + date_of_birth: Date, + gender: String, + + profilePic: { + type: String, + default: "https://www.gravatar.com/avatar/?d=mp", + }, + + cloudinaryUrl: { + type: String, + default: "", + }, + }, + + academic_info: { + program: { + type: String, + //enum: ["B.Tech", "M.Tech", "PhD", "Msc","other"], + }, + branch: String, + batch_year: String, + current_year: String, + cgpa: Number, + }, + + contact_info: { + hostel: String, + room_number: String, + socialLinks: { + github: { type: String, default: "" }, + linkedin: { type: String, default: "" }, + instagram: { type: String, default: "" }, + other: { type: String, default: "" }, + }, + }, + + status: { + type: String, + enum: ["active", "inactive", "graduated"], + default: "active", + }, + }, + { + timestamps: true, + }, +); + +userSchema.index( + { user_id: 1 }, + { + unique: true, + partialFilterExpression: { user_id: { $exists: true, $type: "string" } }, + name: "user_id_partial_unique", + }, +); + +userSchema.pre("save", async function () { + if (!this.isModified("password")) return; + const SALT_ROUNDS = Number(process.env.SALT) || 12 + this.password = await bcrypt.hash(this.password, SALT_ROUNDS); +}); +const User = mongoose.model("User", userSchema); +module.exports = User; diff --git a/backend/package-lock.json b/backend/package-lock.json index 1d66737d..0f32a4da 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,9 +10,11 @@ "license": "ISC", "dependencies": { "axios": "^1.5.1", - "body-parser": "^1.20.2", + "bcrypt": "^6.0.0", "cloudinary": "^2.6.1", + "connect-mongo": "^5.1.0", "connect-mongodb-session": "^3.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.3.2", "eslint-plugin-react": "^7.33.2", @@ -25,7 +27,6 @@ "moment": "^2.30.1", "mongodb": "^6.1.0", "mongoose": "^7.6.8", - "mongoose-findorcreate": "^4.0.0", "morgan": "^1.10.0", "multer": "^2.0.1", "nodemailer": "^7.0.3", @@ -34,18 +35,16 @@ "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", - "passport-local-mongoose": "^8.0.0", "streamifier": "^0.1.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^4.3.6" }, "devDependencies": { "cookie": "^0.5.0", - "cookie-parser": "^1.4.6", "eslint": "^8.56.0", "eslint-plugin-node": "^11.1.0", "husky": "^8.0.3", "lint-staged": "^15.2.0", - "parser": "^0.1.4", "prettier": "^3.1.1" } }, @@ -1721,6 +1720,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asynciterator.prototype": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", @@ -1803,6 +1814,20 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1811,28 +1836,11 @@ "node": ">=8" } }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" }, "node_modules/bowser": { "version": "2.11.0", @@ -2100,6 +2108,46 @@ "typedarray": "^0.0.6" } }, + "node_modules/connect-mongo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz", + "integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "kruptein": "^3.0.0" + }, + "engines": { + "node": ">=12.9.0" + }, + "peerDependencies": { + "express-session": "^1.17.1", + "mongodb": ">= 5.1.0 < 7" + } + }, + "node_modules/connect-mongo/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/connect-mongo/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/connect-mongodb-session": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/connect-mongodb-session/-/connect-mongodb-session-3.1.1.tgz", @@ -2206,12 +2254,12 @@ } }, "node_modules/cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", - "dev": true, + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", "dependencies": { - "cookie": "0.4.1", + "cookie": "0.7.2", "cookie-signature": "1.0.6" }, "engines": { @@ -2219,10 +2267,10 @@ } }, "node_modules/cookie-parser/node_modules/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", - "dev": true, + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -2325,12 +2373,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/disect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/disect/-/disect-1.1.1.tgz", - "integrity": "sha512-rr2Ym8FSAoqAJ1KfpUiQ/Io01HP0LZPHBuppbFsHozmSNf+YwrvyD5pm5tMTUApJFNwD7HeWJ5DGldSugScukA==", - "dev": true - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3166,11 +3208,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generaterr": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/generaterr/-/generaterr-1.5.0.tgz", - "integrity": "sha512-JgcGRv2yUKeboLvvNrq9Bm90P4iJBu7/vd5wSLYqMG5GJ6SxZT46LAAkMfNhQ+EK3jzC+cRBm7P8aUWYyphgcQ==" - }, "node_modules/get-east-asian-width": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", @@ -3989,6 +4026,18 @@ "json-buffer": "3.0.1" } }, + "node_modules/kruptein": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.2.0.tgz", + "integrity": "sha512-Bcou7bKBn3k2ZEDXyYzR/j7YWWFDIcqv0ZeabHHPWW1aYmfLn0qmJJoWPVeQvh37g6vl2x3nEO9guBSzJsmuMQ==", + "license": "MIT", + "dependencies": { + "asn1.js": "^5.4.1" + }, + "engines": { + "node": ">8" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4357,6 +4406,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4473,11 +4528,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose-findorcreate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mongoose-findorcreate/-/mongoose-findorcreate-4.0.0.tgz", - "integrity": "sha512-wi0vrTmazWBeZn8wHVdb8NEa+ZrAbnmfI8QltnFeIgvC33VlnooapvPSk21W22IEhs0vZ0cBz0MmXcc7eTTSZQ==" - }, "node_modules/mongoose/node_modules/@types/whatwg-url": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", @@ -4682,6 +4732,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemailer": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", @@ -7753,18 +7823,6 @@ "node": ">=6" } }, - "node_modules/parser": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/parser/-/parser-0.1.4.tgz", - "integrity": "sha512-f6EM/mBtPzmIh96MpcbePfhkBOYRmLYWuOukJqMysMlvjp4s2MQSSQnFEekd9GV4JGTnDJ2uFt3Ztcqc9wCMJg==", - "dev": true, - "dependencies": { - "tokenizer": "*" - }, - "engines": { - "node": "0.4-0.9" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7812,19 +7870,6 @@ "node": ">= 0.4.0" } }, - "node_modules/passport-local-mongoose": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/passport-local-mongoose/-/passport-local-mongoose-8.0.0.tgz", - "integrity": "sha512-jgfN/B0j11WT5f96QlL5EBvxbIwmzd+tbwPzG1Vk8hzDOF68jrch5M+NFvrHjWjb3lfAU0DkxKmNRT9BjFZysQ==", - "dependencies": { - "generaterr": "^1.5.0", - "passport-local": "^1.0.0", - "scmp": "^2.1.0" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/passport-oauth2": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", @@ -8037,20 +8082,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8303,11 +8334,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/scmp": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", - "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==" - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8756,18 +8782,6 @@ "node": ">=0.6" } }, - "node_modules/tokenizer": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tokenizer/-/tokenizer-1.1.2.tgz", - "integrity": "sha512-c/EYsBwEW/EX28q44UaSrJ9o5M2aI+N/xdJJ4Zl7dNq76OmWQHhmXH0T8DJQNjVYPc7NclV2CZQfyeUMfnEu/A==", - "dev": true, - "dependencies": { - "disect": "~1.1.0" - }, - "engines": { - "node": "0.10.x" - } - }, "node_modules/touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -9188,6 +9202,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 3814a629..41b9bf11 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,9 +30,11 @@ "license": "ISC", "dependencies": { "axios": "^1.5.1", - "body-parser": "^1.20.2", + "bcrypt": "^6.0.0", "cloudinary": "^2.6.1", + "connect-mongo": "^5.1.0", "connect-mongodb-session": "^3.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.3.2", "eslint-plugin-react": "^7.33.2", @@ -45,7 +47,6 @@ "moment": "^2.30.1", "mongodb": "^6.1.0", "mongoose": "^7.6.8", - "mongoose-findorcreate": "^4.0.0", "morgan": "^1.10.0", "multer": "^2.0.1", "nodemailer": "^7.0.3", @@ -54,18 +55,16 @@ "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", - "passport-local-mongoose": "^8.0.0", "streamifier": "^0.1.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^4.3.6" }, "devDependencies": { "cookie": "^0.5.0", - "cookie-parser": "^1.4.6", "eslint": "^8.56.0", "eslint-plugin-node": "^11.1.0", "husky": "^8.0.3", "lint-staged": "^15.2.0", - "parser": "^0.1.4", "prettier": "^3.1.1" } } diff --git a/backend/routes/achievements.js b/backend/routes/achievements.js index 026bc288..d3b4a29b 100644 --- a/backend/routes/achievements.js +++ b/backend/routes/achievements.js @@ -1,8 +1,8 @@ const express = require("express"); const router = express.Router(); -const { Achievement } = require("../models/schema"); // Update path as needed +const Achievement = require("../models/achievementSchema"); // Update path as needed const { v4: uuidv4 } = require("uuid"); -const isAuthenticated = require("../middlewares/isAuthenticated"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); const authorizeRole = require("../middlewares/authorizeRole"); const { ROLE_GROUPS } = require("../utils/roles"); diff --git a/backend/routes/analytics.js b/backend/routes/analytics.js index bf31fc49..40127816 100644 --- a/backend/routes/analytics.js +++ b/backend/routes/analytics.js @@ -1,20 +1,40 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const controller = require('../controllers/analyticsController'); -const isAuthenticated = require('../middlewares/isAuthenticated'); -const authorizeRole = require('../middlewares/authorizeRole'); -const {ROLE_GROUPS} = require('../utils/roles'); +const controller = require("../controllers/analyticsController"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); +const authorizeRole = require("../middlewares/authorizeRole"); +const { ROLE_GROUPS } = require("../utils/roles"); // Route to get analytics for president -router.get('/president', isAuthenticated, authorizeRole(['PRESIDENT']), controller.getPresidentAnalytics); +router.get( + "/president", + isAuthenticated, + authorizeRole(["PRESIDENT"]), + controller.getPresidentAnalytics, +); // Route to get analytics for gensecs -router.get('/gensec', isAuthenticated,authorizeRole([...ROLE_GROUPS.GENSECS]), controller.getGensecAnalytics); +router.get( + "/gensec", + isAuthenticated, + authorizeRole([...ROLE_GROUPS.GENSECS]), + controller.getGensecAnalytics, +); // Route to get analytics for club coordinators -router.get('/club-coordinator',authorizeRole(['CLUB_COORDINATOR']), isAuthenticated, controller.getClubCoordinatorAnalytics); +router.get( + "/club-coordinator", + isAuthenticated, + authorizeRole(["CLUB_COORDINATOR"]), + controller.getClubCoordinatorAnalytics, +); // Route to get analytics for students -router.get('/student', isAuthenticated,authorizeRole(['STUDENT']), controller.getStudentAnalytics); +router.get( + "/student", + isAuthenticated, + authorizeRole(["STUDENT"]), + controller.getStudentAnalytics, +); module.exports = router; diff --git a/backend/routes/announcements.js b/backend/routes/announcements.js index c4f5ae9f..fee19d6c 100644 --- a/backend/routes/announcements.js +++ b/backend/routes/announcements.js @@ -1,13 +1,12 @@ const express = require("express"); const router = express.Router(); const mongoose = require("mongoose"); -const { - Announcement, - Event, - OrganizationalUnit, - Position, -} = require("../models/schema"); -const isAuthenticated = require("../middlewares/isAuthenticated"); +const {Announcement} = require("../models/schema"); +const Event = require("../models/eventSchema"); +const Position = require("../models/positionSchema"); +const OrganizationalUnit = require("../models/organizationSchema"); + +const { isAuthenticated } = require("../middlewares/isAuthenticated"); const findTargetId = async (type, identifier) => { let target = null; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index c2ee6f7b..48507f23 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,13 +1,16 @@ const express = require("express"); const router = express.Router(); const jwt = require("jsonwebtoken"); -//const secretKey = process.env.JWT_SECRET_TOKEN; -const isIITBhilaiEmail = require("../utils/isIITBhilaiEmail"); -const passport = require("../models/passportConfig"); + +const { registerValidate } = require("../utils/authValidate"); +const passport = require("../config/passportConfig"); const rateLimit = require("express-rate-limit"); var nodemailer = require("nodemailer"); -const { User } = require("../models/schema"); -const isAuthenticated= require("../middlewares/isAuthenticated"); + +const User = require("../models/userSchema"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); + +//const bcrypt = require("bcrypt"); //rate limiter - for password reset try const forgotPasswordLimiter = rateLimit({ @@ -17,67 +20,108 @@ const forgotPasswordLimiter = rateLimit({ }); // Session Status -router.get("/fetchAuth",isAuthenticated, function (req, res) { - if (req.isAuthenticated()) { - res.json(req.user); - } else { - res.json(null); - } +router.get("/fetchAuth", isAuthenticated, function (req, res) { + //console.log(req.user); + const { personal_info, role, onboardingComplete, _id, ...restData } = + req.user; + res.json({ message: { personal_info, role, onboardingComplete, _id } }); }); -// Local Authentication -router.post("/login", passport.authenticate("local"), (req, res) => { - // If authentication is successful, this function will be called - const email = req.user.username; - if (!isIITBhilaiEmail(email)) { - console.log("Access denied. Please use your IIT Bhilai email."); - return res.status(403).json({ - message: "Access denied. Please use your IIT Bhilai email.", - }); +/** + * User POST /auth/login + ↓ + passport.authenticate("local") + ↓ + LocalStrategy (validate credentials) + ↓ + done(null, user) + ↓ + req.login(user) called + ↓ + serializeUser(user) → store ID in session + ↓ + Session saved → session cookie sent + */ +router.post("/login", async (req, res) => { + try { + passport.authenticate("local", (err, user, info) => { + if (err) { + console.error(err); + return res.status(500).json({ message: "Internal server error" }); + } + + if (!user) + return res + .status(401) + .json({ message: info?.message || "Login failed" }); + + // if using a custom callback like this u have to manually call req.login() else not needed + //this will seralize user, store id in session, save session and send cookie + req.login(user, (err) => { + if (err) + return res.status(500).json({ message: "Internal server error" }); + const { personal_info, role, onboardingComplete, _id, ...restData } = + user; + return res.json({ + message: "Login Successful", + success: true, + data: { personal_info, role, onboardingComplete, _id }, + }); + }); + })(req, res); + } catch (err) { + return res.status(500).json({ message: err.message }); } - res.status(200).json({ message: "Login successful", user: req.user }); }); router.post("/register", async (req, res) => { try { - const { name, ID, email, password } = req.body; - if (!isIITBhilaiEmail(email)) { - return res.status(400).json({ - message: "Invalid email address. Please use an IIT Bhilai email.", - }); + const { username, password, name } = req.body; + const role = "STUDENT"; + const result = registerValidate.safeParse({ + username, + password, + name, + role, + }); + + if (!result.success) { + const errors = result.error.issues.map((issue) => issue.message); + return res.status(400).json({ message: errors, success: false }); } - const existingUser = await User.findOne({ username: email }); - if (existingUser) { - return res.status(400).json({ message: "User already exists." }); + + const user = await User.findOne({ username }); + if (user) { + return res.status(409).json({ + message: "Account with username already exists", + success: false, + }); } - const newUser = await User.register( - new User({ - user_id: ID, - role: "STUDENT", - strategy: "local", - username: email, - personal_info: { - name: name, - email: email, - }, - onboardingComplete: false, - }), + /** + * This logic is now embedded in the pre save hook + * const hashedPassword = await bcrypt.hash( password, + Number(process.env.SALT), ); + */ - req.login(newUser, (err) => { - if (err) { - console.error(err); - return res.status(400).json({ message: "Bad request." }); - } - return res - .status(200) - .json({ message: "Registration successful", user: newUser }); + const newUser = await User.create({ + strategy: "local", + username, + password, + personal_info: { + name, + email: username, + }, + role, }); - } catch (error) { - console.error("Registration error:", error); - return res.status(500).json({ message: "Internal server error" }); + //console.log(newUser); + + //return res.json({ message: "Registered Successfully", user: newUser }); + return res.json({ message: "Registered Successfully", success: true }); + } catch (err) { + return res.status(500).json({ message: err.message }); } }); @@ -87,29 +131,77 @@ router.get( passport.authenticate("google", { scope: ["profile", "email"] }), ); -router.get( - "/google/verify", - passport.authenticate("google", { failureRedirect: "/" }), - (req, res) => { - if (req.user.onboardingComplete) { - res.redirect(`${process.env.FRONTEND_URL}/`); - } else { - res.redirect(`${process.env.FRONTEND_URL}/onboarding`); +router.get("/google/verify", function (req, res) { + //console.log("in verify"); + passport.authenticate("google", (err, user, info) => { + if (err) { + console.error(err); + return res.status(500).json({ message: "Internal server error" }); } - }, -); -router.post("/logout", (req, res, next) => { - req.logout(function (err) { + if (!user) + return res + .status(401) + .json({ message: info?.message || "Google Authentication failed" }); + + /** + * if(!user.onboardingComplete){ + return res.redirect(`${process.env.FRONTEND_URL}/onboarding`) + } + */ + //return res.redirect(`${process.env.FRONTEND_URL}`); + + req.login(user, (loginErr) => { + if (loginErr) { + console.error("Login error:", loginErr); + return res.status(500).json({ message: "Error establishing session" }); + } + + /*console.log("User logged in successfully:", user.username); + console.log("OnboardingComplete:", user.onboardingComplete); + */ + if (!user.onboardingComplete) { + //console.log("Redirecting to onboarding"); + return res.redirect(`${process.env.FRONTEND_URL}/onboarding`); + } + + //console.log("Redirecting to home"); + return res.redirect(`${process.env.FRONTEND_URL}`); + }); + })(req, res); +}); + +router.post("/logout", (req, res) => { + req.logout((err) => { if (err) { - return next(err); + console.error("Error during logout:", err); + return res.status(500).json({ message: "Error during logout" }); } - res.send("Logout Successful"); + + // Destroy the session + // req.session.destroy will remove the session from session store and invalidate ids or fields + req.session.destroy((err) => { + if (err) { + console.error("Error destroying session:", err); + return res.status(500).json({ message: "Error destroying session" }); + } + + // Clear the session cookie + res.clearCookie("token", { + path: "/", + secure: process.env.NODE_ENV === "production", // HTTPS only in prod + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", // cross-origin in prod + maxAge: 0, + httpOnly: true, + }); + + res.json({ message: "Logged out successfully" }); + }); }); }); //routes for forgot-password -router.post("/forgot-password", forgotPasswordLimiter, async (req, res) => { +router.post("/", forgotPasswordLimiter, async (req, res) => { try { const { email } = req.body; const user = await User.findOne({ username: email }); @@ -151,7 +243,7 @@ router.post("/forgot-password", forgotPasswordLimiter, async (req, res) => { .json({ message: "Password reset link sent to your email" }); } }); - console.log(link); + //console.log(link); } catch (error) { console.log(error); return res.status(500).json({ message: "Internal server error" }); @@ -160,14 +252,14 @@ router.post("/forgot-password", forgotPasswordLimiter, async (req, res) => { //route for password reset router.get("/reset-password/:id/:token", async (req, res) => { - const { id, token } = req.params; - console.log(req.params); - const user = await User.findOne({ _id: id }); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - const secret = user._id + process.env.JWT_SECRET_TOKEN; try { + const { id, token } = req.params; + console.log(req.params); + const user = await User.findOne({ _id: id }); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + const secret = user._id + process.env.JWT_SECRET_TOKEN; jwt.verify(token, secret); return res.status(200).json({ message: "Token verified successfully" }); } catch (error) { diff --git a/backend/routes/certificate.js b/backend/routes/certificate.js new file mode 100644 index 00000000..85826e60 --- /dev/null +++ b/backend/routes/certificate.js @@ -0,0 +1,7 @@ +const router = require("express").Router(); +const {isAuthenticated} = require("../middlewares/isAuthenticated") +const {getCertificates} = require("../controllers/certificateController"); + +router.get("/", isAuthenticated, getCertificates); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/certificateBatch.js b/backend/routes/certificateBatch.js new file mode 100644 index 00000000..1f8f6baf --- /dev/null +++ b/backend/routes/certificateBatch.js @@ -0,0 +1,89 @@ +const router = require("express").Router(); +const { + createBatch, + editBatch, + getBatchUsers, + duplicateBatch, + deleteBatch, + archiveBatch, + getUserBatches, + approverEditBatch, + approveBatch, + rejectBatch, +} = require("../controllers/certificateBatchController"); + +const { isAuthenticated } = require("../middlewares/isAuthenticated"); +const authorizeRole = require("../middlewares/authorizeRole"); +const { ROLE_GROUPS, ROLES } = require("../utils/roles"); + +router.get( + "/:userId", + isAuthenticated, + authorizeRole(ROLE_GROUPS.ADMIN), + getUserBatches, +); + +router.post( + "/create-batch", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + createBatch, +); + +router.patch( + "/edit-batch", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + editBatch, +); + +router.patch( + "/approver/edit-batch", + isAuthenticated, + authorizeRole([...ROLE_GROUPS.GENSECS, ROLES.PRESIDENT]), + approverEditBatch, +); + +router.post( + "/batch-users", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + getBatchUsers, +); + +router.post( + "/duplicate-batch", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + duplicateBatch, +); + +router.delete( + "/delete-batch", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + deleteBatch, +); + +router.patch( + "/archive-batch", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + archiveBatch, +); + +router.get( + "/:batchId/approve", + isAuthenticated, + authorizeRole(ROLE_GROUPS.ADMIN), + approveBatch, +); + +router.get( + "/:batchId/reject", + isAuthenticated, + authorizeRole(ROLE_GROUPS.ADMIN), + rejectBatch, +); + +module.exports = router; diff --git a/backend/routes/dashboard.js b/backend/routes/dashboard.js index 43846500..2ce8a818 100644 --- a/backend/routes/dashboard.js +++ b/backend/routes/dashboard.js @@ -1,8 +1,8 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const dashboardController = require('../controllers/dashboardController'); -const isAuthenticated = require('../middlewares/isAuthenticated'); +const dashboardController = require("../controllers/dashboardController"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); -router.get('/stats',isAuthenticated, dashboardController.getDashboardStats); +router.get("/stats", isAuthenticated, dashboardController.getDashboardStats); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/routes/events.js b/backend/routes/events.js index 4bf5dd92..2f601244 100644 --- a/backend/routes/events.js +++ b/backend/routes/events.js @@ -1,8 +1,10 @@ const express = require("express"); const router = express.Router(); -const { Event, User, OrganizationalUnit } = require("../models/schema"); +const OrganizationalUnit = require("../models/organizationSchema"); +const Event = require("../models/eventSchema"); +const User = require("../models/userSchema"); const { v4: uuidv4 } = require("uuid"); -const isAuthenticated = require("../middlewares/isAuthenticated"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); const isEventContact = require("../middlewares/isEventContact"); const authorizeRole = require("../middlewares/authorizeRole"); const { ROLE_GROUPS, ROLES } = require("../utils/roles"); @@ -73,7 +75,7 @@ router.post( ); // GET all events (for all users: logged in or not logged in) -router.get("/events", async (req, res) => { +router.get("/", async (req, res) => { try { const events = await Event.find().populate("organizing_unit_id", "name"); res.json(events); @@ -221,7 +223,6 @@ router.post( return res.status(400).json({ message: "Registration has ended." }); } - const maxParticipants = event.registration.max_participants; if (maxParticipants) { const updatedEvent = await Event.findOneAndUpdate( @@ -255,8 +256,8 @@ router.post( event: updatedEvent, }); } catch (error) { - if (error?.name === "CastError") { - return res.status(400).json({ message: "Invalid event ID format." }); + if (error.name === "CastError") { + return res.status(400).json({ message: "Invalid event ID format." }); } console.error("Event registration error:", error); return res diff --git a/backend/routes/feedbackRoutes.js b/backend/routes/feedbackRoutes.js index d3e52386..94ca51ee 100644 --- a/backend/routes/feedbackRoutes.js +++ b/backend/routes/feedbackRoutes.js @@ -1,18 +1,16 @@ const express = require("express"); const router = express.Router(); -const isAuthenticated = require("../middlewares/isAuthenticated"); -const { - User, - Feedback, - Event, - Position, - OrganizationalUnit, -} = require("./../models/schema"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); +const OrganizationalUnit = require("../models/organizationSchema"); +const Event = require("../models/eventSchema"); +const User = require("../models/userSchema"); +const Feedback = require("../models/feedbackSchema"); +const Position = require("../models/positionSchema"); const { v4: uuidv4 } = require("uuid"); const authorizeRole = require("../middlewares/authorizeRole"); const { ROLE_GROUPS } = require("../utils/roles"); -router.post("/add",isAuthenticated, async (req, res) => { +router.post("/add", isAuthenticated, async (req, res) => { try { const { type, @@ -28,19 +26,19 @@ router.post("/add",isAuthenticated, async (req, res) => { return res.status(400).json({ message: "Missing required fields" }); } - const targetModels={ + const targetModels = { User, Event, "Club/Organization": OrganizationalUnit, POR: Position, }; - const TargetModel=targetModels[target_type]; + const TargetModel = targetModels[target_type]; - if(!TargetModel){ - return res.status(400).json({message:"Invalid target type"}); + if (!TargetModel) { + return res.status(400).json({ message: "Invalid target type" }); } - + const feedback = new Feedback({ feedback_id: uuidv4(), type, @@ -63,9 +61,12 @@ router.post("/add",isAuthenticated, async (req, res) => { } }); -router.get("/get-targetid",isAuthenticated, async (req, res) => { +router.get("/get-targetid", isAuthenticated, async (req, res) => { try { - const users = await User.find({role: "STUDENT"}, "_id user_id personal_info.name"); + const users = await User.find( + { role: "STUDENT" }, + "_id user_id personal_info.name", + ); const events = await Event.find({}, "_id title"); const organizational_units = await OrganizationalUnit.find({}, "_id name"); const positions = await Position.find({}) @@ -178,42 +179,47 @@ router.get("/view-feedback", async (req, res) => { }); // requires user middleware that attaches user info to req.user -router.put("/mark-resolved/:id",isAuthenticated,authorizeRole(ROLE_GROUPS.ADMIN), async (req, res) => { - const feedbackId = req.params.id; - const { actions_taken, resolved_by } = req.body; - console.log(req.body); - console.log("User resolving feedback:", resolved_by); - - if (!actions_taken || actions_taken.trim() === "") { - return res.status(400).json({ error: "Resolution comment is required." }); - } - - try { - const feedback = await Feedback.findById(feedbackId); - if (!feedback) { - return res.status(404).json({ error: "Feedback not found" }); +router.put( + "/mark-resolved/:id", + isAuthenticated, + authorizeRole(ROLE_GROUPS.ADMIN), + async (req, res) => { + const feedbackId = req.params.id; + const { actions_taken, resolved_by } = req.body; + console.log(req.body); + console.log("User resolving feedback:", resolved_by); + + if (!actions_taken || actions_taken.trim() === "") { + return res.status(400).json({ error: "Resolution comment is required." }); } - if (feedback.is_resolved) { - return res.status(400).json({ error: "Feedback is already resolved." }); - } + try { + const feedback = await Feedback.findById(feedbackId); + if (!feedback) { + return res.status(404).json({ error: "Feedback not found" }); + } - feedback.is_resolved = true; - feedback.resolved_at = new Date(); - feedback.actions_taken = actions_taken; - feedback.resolved_by = resolved_by; + if (feedback.is_resolved) { + return res.status(400).json({ error: "Feedback is already resolved." }); + } - await feedback.save(); + feedback.is_resolved = true; + feedback.resolved_at = new Date(); + feedback.actions_taken = actions_taken; + feedback.resolved_by = resolved_by; - res.json({ success: true, message: "Feedback marked as resolved." }); - } catch (err) { - console.error("Error updating feedback:", err); - res.status(500).json({ error: "Server error" }); - } -}); + await feedback.save(); + + res.json({ success: true, message: "Feedback marked as resolved." }); + } catch (err) { + console.error("Error updating feedback:", err); + res.status(500).json({ error: "Server error" }); + } + }, +); //get all user given feedbacks -router.get("/:userId",isAuthenticated, async (req, res) => { +router.get("/:userId", isAuthenticated, async (req, res) => { const userId = req.params.userId; try { const userFeedbacks = await Feedback.find({ feedback_by: userId }).populate( diff --git a/backend/routes/onboarding.js b/backend/routes/onboarding.js index dca690d2..14905a90 100644 --- a/backend/routes/onboarding.js +++ b/backend/routes/onboarding.js @@ -1,18 +1,17 @@ const express = require("express"); const router = express.Router(); -const { User } = require("../models/schema"); -const isAuthenticated = require("../middlewares/isAuthenticated"); +const User = require("../models/userSchema"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); // Onboarding route - to be called when user logs in for the first time -router.post("/",isAuthenticated, async (req, res) => { - const { ID_No, add_year, Program, discipline, mobile_no } = req.body; +router.put("/", isAuthenticated, async (req, res) => { + const { add_year, Program, discipline, mobile_no } = req.body; try { - console.log(req.user); + //console.log(req.user); const updatedUser = await User.findByIdAndUpdate( req.user._id, { - user_id: ID_No, onboardingComplete: true, personal_info: Object.assign({}, req.user.personal_info, { phone: mobile_no || "", @@ -28,11 +27,11 @@ router.post("/",isAuthenticated, async (req, res) => { { new: true, runValidators: true }, ); - console.log("Onboarding completed for user:", updatedUser._id); + //console.log("Onboarding completed for user:", updatedUser._id); res.status(200).json({ message: "Onboarding completed successfully" }); } catch (error) { - console.error("Onboarding failed:", error); - res.status(500).json({ message: "Onboarding failed", error }); + console.error("Onboarding failed:", error.message); + res.status(500).json({ message: error.message || "Onboarding failed" }); } }); diff --git a/backend/routes/orgUnit.js b/backend/routes/orgUnit.js index 2c71597b..bc108781 100644 --- a/backend/routes/orgUnit.js +++ b/backend/routes/orgUnit.js @@ -3,16 +3,16 @@ const express = require("express"); const router = express.Router(); const mongoose = require("mongoose"); const { v4: uuidv4 } = require("uuid"); -const { - OrganizationalUnit, - Event, - Position, - PositionHolder, - Achievement, - Feedback, - User, -} = require("../models/schema"); -const isAuthenticated = require("../middlewares/isAuthenticated"); + +const User = require("../models/userSchema"); +const Feedback = require("../models/feedbackSchema"); +const Achievement = require("../models/achievementSchema"); +const Event = require("../models/eventSchema"); +const PositionHolder = require("../models/positionHolderSchema"); +const Position = require("../models/positionSchema"); +const OrganizationalUnit = require("../models/organizationSchema"); + +const { isAuthenticated } = require("../middlewares/isAuthenticated"); const authorizeRole = require("../middlewares/authorizeRole"); const { ROLE_GROUPS } = require("../utils/roles"); diff --git a/backend/routes/positionRoutes.js b/backend/routes/positionRoutes.js index d25e32f4..eb5602a1 100644 --- a/backend/routes/positionRoutes.js +++ b/backend/routes/positionRoutes.js @@ -1,8 +1,9 @@ const express = require("express"); const router = express.Router(); -const { Position, PositionHolder } = require("../models/schema"); +const Position = require("../models/positionSchema"); +const PositionHolder = require("../models/positionHolderSchema"); const { v4: uuidv4 } = require("uuid"); -const isAuthenticated = require("../middlewares/isAuthenticated"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); // POST for adding a new position router.post("/add-position", isAuthenticated, async (req, res) => { diff --git a/backend/routes/profile.js b/backend/routes/profile.js index db94cae5..c2fad728 100644 --- a/backend/routes/profile.js +++ b/backend/routes/profile.js @@ -4,9 +4,9 @@ const router = express.Router(); const upload = require("../middlewares/upload"); const cloudinary = require("cloudinary").v2; //const { Student } = require("../models/student"); -const { User } = require("../models/schema"); +const User = require("../models/userSchema"); const streamifier = require("streamifier"); -const isAuthenticated = require("../middlewares/isAuthenticated"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); // Cloudinary config cloudinary.config({ cloud_name: process.env.CLOUDINARY_CLOUD_NAME, @@ -15,15 +15,20 @@ cloudinary.config({ }); router.post( - "/photo-update",isAuthenticated, + "/photo-update", + isAuthenticated, upload.fields([{ name: "image" }]), async (req, res) => { try { const { ID_No } = req.body; - if (!ID_No) { return res.status(400).json({ error: "ID_No is required" }); } + if (!ID_No) { + return res.status(400).json({ error: "ID_No is required" }); + } const user = await User.findOne({ user_id: ID_No }); - if (!user) { return res.status(404).json({ error: "User not found" });} + if (!user) { + return res.status(404).json({ error: "User not found" }); + } if ( !req.files || @@ -46,8 +51,11 @@ router.post( let stream = cloudinary.uploader.upload_stream( { folder: "profile-photos" }, (error, result) => { - if (result) { resolve(result);} - else { reject(error); } + if (result) { + resolve(result); + } else { + reject(error); + } }, ); streamifier.createReadStream(fileBuffer).pipe(stream); @@ -69,13 +77,17 @@ router.post( ); // Delete profile photo (reset to default) -router.delete("/photo-delete",isAuthenticated, async (req, res) => { +router.delete("/photo-delete", isAuthenticated, async (req, res) => { try { const { ID_No } = req.query; // Get ID_No from frontend for DELETE - if (!ID_No) { return res.status(400).json({ error: "ID_No is required" }); } + if (!ID_No) { + return res.status(400).json({ error: "ID_No is required" }); + } - const user = await user.findOne({ user_id: ID_No }); - if (!user) { return res.status(404).json({ error: "User not found" }); } + const user = await User.findOne({ user_id: ID_No }); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } // Delete from Cloudinary if exists if (user.personal_info.cloudinaryUrl) { @@ -91,8 +103,8 @@ router.delete("/photo-delete",isAuthenticated, async (req, res) => { } }); -// API to Update Student Profile -router.put("/updateStudentProfile",isAuthenticated, async (req, res) => { +// API to Update Student Profile +router.put("/updateStudentProfile", isAuthenticated, async (req, res) => { try { const { userId, updatedDetails } = req.body; console.log("Received userId:", userId); @@ -124,13 +136,27 @@ router.put("/updateStudentProfile",isAuthenticated, async (req, res) => { cloudinaryUrl, } = updatedDetails.personal_info; - if (name) { user.personal_info.name = name; } - if (email) { user.personal_info.email = email; } - if (phone) { user.personal_info.phone = phone; } - if (gender) { user.personal_info.gender = gender; } - if (date_of_birth) { user.personal_info.date_of_birth = date_of_birth; } - if (profilePic) { user.personal_info.profilePic = profilePic; } - if (cloudinaryUrl) { user.personal_info.cloudinaryUrl = cloudinaryUrl; } + if (name) { + user.personal_info.name = name; + } + if (email) { + user.personal_info.email = email; + } + if (phone) { + user.personal_info.phone = phone; + } + if (gender) { + user.personal_info.gender = gender; + } + if (date_of_birth) { + user.personal_info.date_of_birth = date_of_birth; + } + if (profilePic) { + user.personal_info.profilePic = profilePic; + } + if (cloudinaryUrl) { + user.personal_info.cloudinaryUrl = cloudinaryUrl; + } } // ---------- ACADEMIC INFO ---------- @@ -138,19 +164,33 @@ router.put("/updateStudentProfile",isAuthenticated, async (req, res) => { const { program, branch, batch_year, current_year, cgpa } = updatedDetails.academic_info; - if (program) { user.academic_info.program = program; } - if (branch) { user.academic_info.branch = branch; } - if (batch_year) { user.academic_info.batch_year = batch_year; } - if (current_year) { user.academic_info.current_year = current_year; } - if (cgpa !== undefined) { user.academic_info.cgpa = cgpa; } + if (program) { + user.academic_info.program = program; + } + if (branch) { + user.academic_info.branch = branch; + } + if (batch_year) { + user.academic_info.batch_year = batch_year; + } + if (current_year) { + user.academic_info.current_year = current_year; + } + if (cgpa !== undefined) { + user.academic_info.cgpa = cgpa; + } } // ---------- CONTACT INFO ---------- if (updatedDetails.contact_info) { const { hostel, room_number, socialLinks } = updatedDetails.contact_info; - if (hostel) { user.contact_info.hostel = hostel; } - if (room_number) { user.contact_info.room_number = room_number; } + if (hostel) { + user.contact_info.hostel = hostel; + } + if (room_number) { + user.contact_info.room_number = room_number; + } // Social Links if (socialLinks) { diff --git a/backend/routes/skillsRoutes.js b/backend/routes/skillsRoutes.js index 04d1bb11..8a2863ac 100644 --- a/backend/routes/skillsRoutes.js +++ b/backend/routes/skillsRoutes.js @@ -2,7 +2,7 @@ const express = require("express"); const router = express.Router(); const { UserSkill, Skill } = require("../models/schema"); const { v4: uuidv4 } = require("uuid"); -const isAuthenticated = require("../middlewares/isAuthenticated"); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); const authorizeRole = require("../middlewares/authorizeRole"); const { ROLE_GROUPS } = require("../utils/roles"); // GET unendorsed user skills for a particular skill type diff --git a/backend/routes/template.js b/backend/routes/template.js new file mode 100644 index 00000000..c5de1805 --- /dev/null +++ b/backend/routes/template.js @@ -0,0 +1,17 @@ +const router = require("express").Router(); +const { isAuthenticated } = require("../middlewares/isAuthenticated"); +const Template = require("../models/templateSchema"); + +router.get("/", isAuthenticated, async function (req, res) { + try { + const templates = await Template.find(); + if (!templates) { + return res.status(404).json({ message: "No templates found" }); + } + res.json({ message: templates }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +module.exports = router; diff --git a/backend/seed.js b/backend/seed.js index 61a65273..72388bf7 100644 --- a/backend/seed.js +++ b/backend/seed.js @@ -1,592 +1,1403 @@ require("dotenv").config(); const mongoose = require("mongoose"); -const { - User, - Feedback, - Achievement, - UserSkill, - Skill, - Event, - PositionHolder, - Position, - OrganizationalUnit, -} = require("./models/schema"); +const User = require("./models/userSchema"); +const Feedback = require("./models/feedbackSchema"); +const Achievement = require("./models/achievementSchema"); +const { Skill, UserSkill } = require("./models/schema"); +const Event = require("./models/eventSchema"); +const PositionHolder = require("./models/positionHolderSchema"); +const Position = require("./models/positionSchema"); +const OrganizationalUnit = require("./models/organizationSchema"); +const Template = require("./models/templateSchema"); +const { CertificateBatch, Certificate } = require("./models/certificateSchema"); // --- Data for Seeding --- // Original club/committee data. const initialUnitsData = [ - { unit_id: "CLUB_OPENLAKE", name: "OpenLake", type: "Club", description: "Open Source Club of IIT Bhilai", hierarchy_level: 2, category: "scitech", contact_info: { email: "openlake@iitbhilai.ac.in", social_media: [] } }, - { unit_id: "CLUB_RENAISSANCE", name: "Renaissance", type: "Club", description: "Fine Arts Club under Cultural Council.", hierarchy_level: 2, category: "cultural", contact_info: { email: "renaissance@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/renaissance_iitbh?igsh=dzRqNmV5bncxZWp1" }, { platform: "LinkedIn", url: "https://www.linkedin.com/in/renaissance-club-a76430331" } ] } }, - { unit_id: "CLUB_GOALS", name: "GOALS", type: "Club", description: "General Oratory and Literary Society handling Literature and Oration.", hierarchy_level: 2, category: "independent", contact_info: { email: "goals@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/goals_iitbhilai?igsh=ejF6NzVmM3lxMmky" }, { platform: "LinkedIn", url: "https://www.linkedin.com/company/general-oratory-and-literary-society-goals/" } ] } }, - { unit_id: "CLUB_BEATHACKERS", name: "Beathackers", type: "Club", description: "The Dance Club of IIT Bhilai.", hierarchy_level: 2, category: "cultural", contact_info: { email: "beathackers@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/beathackers_iitbhilai?igsh=YnVmbGozZ2V3dWE=" }, { platform: "YouTube", url: "https://youtube.com/@beathackersiitbhilai8247" } ] } }, - { unit_id: "CLUB_EPSILON", name: "The Epsilon Club", type: "Club", description: "Robotics Club of IIT Bhilai", hierarchy_level: 2, category: "scitech", contact_info: { email: "epsilon@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/roboticsclub_iitbhilai" }, { platform: "LinkedIn", url: "https://www.linkedin.com/company/the-epsilon-club-iit-bhilai-robotics-club/" } ] } }, - { unit_id: "CLUB_INGENUITY", name: "Ingenuity", type: "Club", description: "Competitive programming club fostering problem-solving.", hierarchy_level: 2, category: "scitech", contact_info: { email: "ingenuity@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/ingenuity_iit_bh/" }, { platform: "LinkedIn", url: "https://www.linkedin.com/company/74349589/admin/dashboard/" } ] } }, - { unit_id: "CLUB_DESIGNX", name: "DesignX", type: "Club", description: "Digital Arts club of IIT Bhilai.", hierarchy_level: 2, category: "cultural", contact_info: { email: "designx@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/designx_iitbhilai?igsh=NTc4MTIwNjQ2YQ==" }, { platform: "LinkedIn", url: "https://www.linkedin.com/in/designx-iit-bhilai-612a7a371" } ] } }, - { unit_id: "CLUB_SPECTRE", name: "Spectre", type: "Club", description: "Cybersecurity Club of IIT Bhilai.", hierarchy_level: 2, category: "scitech", contact_info: { email: "spectre@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/spectre_iitbhilai?igsh=ZDlyaDlqeXllYjNk" }, { platform: "LinkedIn", url: "https://www.linkedin.com/company/spectre-iit-bhilai/" } ] } }, - { unit_id: "COMMITTEE_EXTERNAL", name: "External Affairs", type: "Committee", description: "Handles sponsorship and PR opportunities of IIT Bhilai.", hierarchy_level: 1, category: "independent", contact_info: { email: "Outreach_cosa@iitbhilai.ac.in", social_media: [ { platform: "LinkedIn", url: "https://www.linkedin.com/in/external-affairs-iit-bhilai-8246a737b" } ] } }, - { unit_id: "CLUB_YOGA", name: "Yoga Club", type: "Club", description: "Promotes physical and mental well-being through yoga.", hierarchy_level: 2, category: "sports", contact_info: { email: "sports_yoga@iitbhilai.ac.in", social_media: [] } }, - { unit_id: "CLUB_MOTORSPORTS", name: "Motorsports", type: "Club", description: "Promotes automotive culture in the institute.", hierarchy_level: 2, category: "scitech", contact_info: { email: "baja@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/iitbhilaimotorsports" }, { platform: "LinkedIn", url: "https://www.linkedin.com/company/iit-bhilai-motorsports/" } ] } }, - { unit_id: "CLUB_FPS", name: "Film Production Society", type: "Club", description: "Film-making society of IIT Bhilai.", hierarchy_level: 2, category: "cultural", contact_info: { email: "fps@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/fps_iitbh" }, { platform: "YouTube", url: "http://youtube.com/@fps-iitbhilai9282" } ] } }, - { unit_id: "CLUB_SWARA", name: "Swara", type: "Club", description: "Music Club of IIT Bhilai.", hierarchy_level: 2, category: "cultural", contact_info: { email: "swara@iitbhilai.ac.in", social_media: [ { platform: "Instagram", url: "https://www.instagram.com/swara_iitbh" }, { platform: "YouTube", url: "https://youtube.com/@swaraiitbhilai" } ] } }, + { + unit_id: "CLUB_OPENLAKE", + name: "OpenLake", + type: "Club", + description: "Open Source Club of IIT Bhilai", + hierarchy_level: 2, + category: "scitech", + contact_info: { email: "openlake@iitbhilai.ac.in", social_media: [] }, + }, + { + unit_id: "CLUB_RENAISSANCE", + name: "Renaissance", + type: "Club", + description: "Fine Arts Club under Cultural Council.", + hierarchy_level: 2, + category: "cultural", + contact_info: { + email: "renaissance@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/renaissance_iitbh?igsh=dzRqNmV5bncxZWp1", + }, + { + platform: "LinkedIn", + url: "https://www.linkedin.com/in/renaissance-club-a76430331", + }, + ], + }, + }, + { + unit_id: "CLUB_GOALS", + name: "GOALS", + type: "Club", + description: + "General Oratory and Literary Society handling Literature and Oration.", + hierarchy_level: 2, + category: "independent", + contact_info: { + email: "goals@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/goals_iitbhilai?igsh=ejF6NzVmM3lxMmky", + }, + { + platform: "LinkedIn", + url: "https://www.linkedin.com/company/general-oratory-and-literary-society-goals/", + }, + ], + }, + }, + { + unit_id: "CLUB_BEATHACKERS", + name: "Beathackers", + type: "Club", + description: "The Dance Club of IIT Bhilai.", + hierarchy_level: 2, + category: "cultural", + contact_info: { + email: "beathackers@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/beathackers_iitbhilai?igsh=YnVmbGozZ2V3dWE=", + }, + { + platform: "YouTube", + url: "https://youtube.com/@beathackersiitbhilai8247", + }, + ], + }, + }, + { + unit_id: "CLUB_EPSILON", + name: "The Epsilon Club", + type: "Club", + description: "Robotics Club of IIT Bhilai", + hierarchy_level: 2, + category: "scitech", + contact_info: { + email: "epsilon@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/roboticsclub_iitbhilai", + }, + { + platform: "LinkedIn", + url: "https://www.linkedin.com/company/the-epsilon-club-iit-bhilai-robotics-club/", + }, + ], + }, + }, + { + unit_id: "CLUB_INGENUITY", + name: "Ingenuity", + type: "Club", + description: "Competitive programming club fostering problem-solving.", + hierarchy_level: 2, + category: "scitech", + contact_info: { + email: "ingenuity@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/ingenuity_iit_bh/", + }, + { + platform: "LinkedIn", + url: "https://www.linkedin.com/company/74349589/admin/dashboard/", + }, + ], + }, + }, + { + unit_id: "CLUB_DESIGNX", + name: "DesignX", + type: "Club", + description: "Digital Arts club of IIT Bhilai.", + hierarchy_level: 2, + category: "cultural", + contact_info: { + email: "designx@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/designx_iitbhilai?igsh=NTc4MTIwNjQ2YQ==", + }, + { + platform: "LinkedIn", + url: "https://www.linkedin.com/in/designx-iit-bhilai-612a7a371", + }, + ], + }, + }, + { + unit_id: "CLUB_SPECTRE", + name: "Spectre", + type: "Club", + description: "Cybersecurity Club of IIT Bhilai.", + hierarchy_level: 2, + category: "scitech", + contact_info: { + email: "spectre@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/spectre_iitbhilai?igsh=ZDlyaDlqeXllYjNk", + }, + { + platform: "LinkedIn", + url: "https://www.linkedin.com/company/spectre-iit-bhilai/", + }, + ], + }, + }, + { + unit_id: "COMMITTEE_EXTERNAL", + name: "External Affairs", + type: "Committee", + description: "Handles sponsorship and PR opportunities of IIT Bhilai.", + hierarchy_level: 1, + category: "independent", + contact_info: { + email: "Outreach_cosa@iitbhilai.ac.in", + social_media: [ + { + platform: "LinkedIn", + url: "https://www.linkedin.com/in/external-affairs-iit-bhilai-8246a737b", + }, + ], + }, + }, + { + unit_id: "CLUB_YOGA", + name: "Yoga Club", + type: "Club", + description: "Promotes physical and mental well-being through yoga.", + hierarchy_level: 2, + category: "sports", + contact_info: { email: "sports_yoga@iitbhilai.ac.in", social_media: [] }, + }, + { + unit_id: "CLUB_MOTORSPORTS", + name: "Motorsports", + type: "Club", + description: "Promotes automotive culture in the institute.", + hierarchy_level: 2, + category: "scitech", + contact_info: { + email: "baja@iitbhilai.ac.in", + social_media: [ + { + platform: "Instagram", + url: "https://www.instagram.com/iitbhilaimotorsports", + }, + { + platform: "LinkedIn", + url: "https://www.linkedin.com/company/iit-bhilai-motorsports/", + }, + ], + }, + }, + { + unit_id: "CLUB_FPS", + name: "Film Production Society", + type: "Club", + description: "Film-making society of IIT Bhilai.", + hierarchy_level: 2, + category: "cultural", + contact_info: { + email: "fps@iitbhilai.ac.in", + social_media: [ + { platform: "Instagram", url: "https://www.instagram.com/fps_iitbh" }, + { platform: "YouTube", url: "http://youtube.com/@fps-iitbhilai9282" }, + ], + }, + }, + { + unit_id: "CLUB_SWARA", + name: "Swara", + type: "Club", + description: "Music Club of IIT Bhilai.", + hierarchy_level: 2, + category: "cultural", + contact_info: { + email: "swara@iitbhilai.ac.in", + social_media: [ + { platform: "Instagram", url: "https://www.instagram.com/swara_iitbh" }, + { platform: "YouTube", url: "https://youtube.com/@swaraiitbhilai" }, + ], + }, + }, ]; - /** * Clears all data from the relevant collections. */ const clearData = async () => { - console.log("Clearing existing data..."); - await OrganizationalUnit.deleteMany({}); - await Position.deleteMany({}); - await User.deleteMany({}); - await PositionHolder.deleteMany({}); - await Event.deleteMany({}); - await Skill.deleteMany({}); - await UserSkill.deleteMany({}); - await Achievement.deleteMany({}); - await Feedback.deleteMany({}); - console.log("All collections cleared successfully!"); + console.log("Clearing existing data..."); + await OrganizationalUnit.deleteMany({}); + await Position.deleteMany({}); + await User.deleteMany({}); + await PositionHolder.deleteMany({}); + await Event.deleteMany({}); + await Skill.deleteMany({}); + await UserSkill.deleteMany({}); + await Achievement.deleteMany({}); + await Feedback.deleteMany({}); + await Template.deleteMany({}); + await CertificateBatch.deleteMany({}); + await Certificate.deleteMany({}); + // Drop the stale legacy certificateId_1 unique index if it exists from a + // previous schema version — it conflicts with Pending certs (null values). + try { + await Certificate.collection.dropIndex("certificateId_1"); + console.log("Dropped stale certificateId_1 index."); + } catch (_) { + // Index doesn't exist — nothing to do. + } + console.log("All collections cleared successfully!"); }; - /** * Seeds the Organizational Units with a proper hierarchy. */ const seedOrganizationalUnits = async () => { - console.log("Seeding Organizational Units..."); - - // 1. Create the top-level President and Test President units - const presidentUnit = new OrganizationalUnit({ - unit_id: "PRESIDENT_GYMKHANA", - name: "President, Student Gymkhana", - type: "independent_position", - description: "The highest student representative body in the Student Gymkhana.", - parent_unit_id: null, - hierarchy_level: 0, - category: "independent", - contact_info: { email: "president_gymkhana@iitbhilai.ac.in", social_media: [] }, - }); - await presidentUnit.save(); - console.log("Created President Unit."); - - const testPresidentUnit = new OrganizationalUnit({ - unit_id: "PRESIDENT_GYMKHANA_TEST", - name: "Test President, Student Gymkhana", - type: "independent_position", - description: "The test president for the Student Gymkhana.", - parent_unit_id: null, - hierarchy_level: 0, - category: "independent", - contact_info: { email: "test_president_gymkhana@iitbhilai.ac.in", social_media: [] }, - }); - await testPresidentUnit.save(); - console.log("Created Test President Unit."); - - // 2. Create the main councils (Gensecs) and link them to the President - const mainCouncilsData = [ - { unit_id: "COUNCIL_CULTURAL", name: "Cultural Council", type: "Council", description: "Council for all cultural activities.", hierarchy_level: 1, category: "cultural", contact_info: { email: "gensec_cultural_gymkhana@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - { unit_id: "COUNCIL_SCITECH", name: "Science and Technology Council", type: "Council", description: "Council for all science and technology activities.", hierarchy_level: 1, category: "scitech", contact_info: { email: "gensec_scitech_gymkhana@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - { unit_id: "COUNCIL_SPORTS", name: "Sports Council", type: "Council", description: "Council for all sports activities.", hierarchy_level: 1, category: "sports", contact_info: { email: "gensec_sports_gymkhana@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - { unit_id: "COUNCIL_ACADEMIC", name: "Academic Affairs Council", type: "Council", description: "Council for all academic affairs.", hierarchy_level: 1, category: "academic", contact_info: { email: "gensec_academic_gymkhana@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - ]; - await OrganizationalUnit.insertMany(mainCouncilsData); - console.log("Created Main Councils (Gensecs)."); - - // 3. Link initial clubs and committees to their respective parent councils - const councils = await OrganizationalUnit.find({ type: 'Council', unit_id: { $not: /_TEST/ } }); - const councilMap = councils.reduce((map, council) => { - map[council.category] = council._id; - return map; - }, {}); - - const linkedUnitsData = initialUnitsData.map(unit => { - return Object.assign({}, unit, { - parent_unit_id: councilMap[unit.category] || presidentUnit._id, - }); + console.log("Seeding Organizational Units..."); + + // 1. Create the top-level President unit + const presidentUnit = await OrganizationalUnit.create({ + unit_id: "PRESIDENT_GYMKHANA", + name: "President, Student Gymkhana", + type: "independent_position", + description: + "The highest student representative body in the Student Gymkhana.", + parent_unit_id: null, + hierarchy_level: 0, + category: "independent", + contact_info: { + email: "president_gymkhana@iitbhilai.ac.in", + social_media: [], + }, + }); + console.log("Created President Unit."); + + // 2. Create the main councils (Gensecs) and link them to the President + const mainCouncilsData = [ + { + unit_id: "COUNCIL_CULTURAL", + name: "Cultural Council", + type: "Council", + description: "Council for all cultural activities.", + hierarchy_level: 1, + category: "cultural", + contact_info: { + email: "gensec_cultural_gymkhana@iitbhilai.ac.in", + social_media: [], + }, + parent_unit_id: presidentUnit._id, + }, + { + unit_id: "COUNCIL_SCITECH", + name: "Science and Technology Council", + type: "Council", + description: "Council for all science and technology activities.", + hierarchy_level: 1, + category: "scitech", + contact_info: { + email: "gensec_scitech_gymkhana@iitbhilai.ac.in", + social_media: [], + }, + parent_unit_id: presidentUnit._id, + }, + { + unit_id: "COUNCIL_SPORTS", + name: "Sports Council", + type: "Council", + description: "Council for all sports activities.", + hierarchy_level: 1, + category: "sports", + contact_info: { + email: "gensec_sports_gymkhana@iitbhilai.ac.in", + social_media: [], + }, + parent_unit_id: presidentUnit._id, + }, + { + unit_id: "COUNCIL_ACADEMIC", + name: "Academic Affairs Council", + type: "Council", + description: "Council for all academic affairs.", + hierarchy_level: 1, + category: "academic", + contact_info: { + email: "gensec_academic_gymkhana@iitbhilai.ac.in", + social_media: [], + }, + parent_unit_id: presidentUnit._id, + }, + ]; + await OrganizationalUnit.insertMany(mainCouncilsData); + console.log("Created Main Councils (Gensecs)."); + + // 3. Link initial clubs and committees to their respective parent councils + const councils = await OrganizationalUnit.find({ type: "Council" }); + const councilMap = councils.reduce((map, council) => { + map[council.category] = council._id; + return map; + }, {}); + + const linkedUnitsData = initialUnitsData.map((unit) => { + return Object.assign({}, unit, { + parent_unit_id: councilMap[unit.category] || presidentUnit._id, }); - await OrganizationalUnit.insertMany(linkedUnitsData); - console.log("Seeded and linked initial clubs and committees."); - - // 4. Create and link the test councils and clubs - const testCouncilsData = [ - { unit_id: "COUNCIL_CULTURAL_TEST", name: "Test Cultural Council", type: "Council", description: "Test council for cultural activities.", hierarchy_level: 1, category: "cultural", contact_info: { email: "test_gensec_cult@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - { unit_id: "COUNCIL_SCITECH_TEST", name: "Test SciTech Council", type: "Council", description: "Test council for scitech activities.", hierarchy_level: 1, category: "scitech", contact_info: { email: "test_gensec_scitech@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - { unit_id: "COUNCIL_SPORTS_TEST", name: "Test Sports Council", type: "Council", description: "Test council for sports activities.", hierarchy_level: 1, category: "sports", contact_info: { email: "test_gensec_sports@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - { unit_id: "COUNCIL_ACAD_TEST", name: "Test Academic Council", type: "Council", description: "Test council for academic activities.", hierarchy_level: 1, category: "academic", contact_info: { email: "test_gensec_acad@iitbhilai.ac.in", social_media: [] }, parent_unit_id: presidentUnit._id }, - ]; - await OrganizationalUnit.insertMany(testCouncilsData); - console.log("Created Test Councils."); - - const testCouncils = await OrganizationalUnit.find({ name: /Test/ }); - const testCouncilMap = testCouncils.reduce((map, council) => { - map[council.category] = council._id; - return map; - }, {}); - - const testClubsData = [ - { unit_id: "CLUB_CULTURAL_TEST", name: "Test Cultural Club", type: "Club", description: "A test club for cultural events.", hierarchy_level: 2, category: "cultural", contact_info: { email: "test_cultural_club@iitbhilai.ac.in", social_media: [] }, parent_unit_id: testCouncilMap.cultural }, - { unit_id: "CLUB_SCITECH_TEST", name: "Test SciTech Club", type: "Club", description: "A test club for scitech events.", hierarchy_level: 2, category: "scitech", contact_info: { email: "test_scitech_club@iitbhilai.ac.in", social_media: [] }, parent_unit_id: testCouncilMap.scitech }, - { unit_id: "CLUB_SPORTS_TEST", name: "Test Sports Club", type: "Club", description: "A test club for sports events.", hierarchy_level: 2, category: "sports", contact_info: { email: "test_sports_club@iitbhilai.ac.in", social_media: [] }, parent_unit_id: testCouncilMap.sports }, - { unit_id: "CLUB_ACAD_TEST", name: "Test Academic Club", type: "Club", description: "A test club for academic events.", hierarchy_level: 2, category: "academic", contact_info: { email: "test_acad_club@iitbhilai.ac.in", social_media: [] }, parent_unit_id: testCouncilMap.academic }, - ]; - await OrganizationalUnit.insertMany(testClubsData); - console.log("Seeded and linked Test Clubs."); - - console.log("Organizational Units seeded successfully!"); + }); + await OrganizationalUnit.insertMany(linkedUnitsData); + console.log("Seeded and linked initial clubs and committees."); + console.log("Organizational Units seeded successfully!"); }; /** - * Seeds the User collection based on Organizational Units and adds test students. + * Seeds the User collection. + * - One local-auth position-holder user per org unit (username = unit contact email). + * - 10 google-auth student users (student1@iitbhilai.ac.in … student10@iitbhilai.ac.in). */ const seedUsers = async () => { - console.log("Seeding Users..."); - const units = await OrganizationalUnit.find({}); - const localAuthUsers = []; - const googleAuthUsers = []; - const password = "password"; - - for (const unit of units) { - let role; - - if (unit.unit_id.includes("PRESIDENT_GYMKHANA")) { - role = "PRESIDENT"; - } else if (unit.unit_id.includes("COUNCIL_CULTURAL")) { - role = "GENSEC_CULTURAL"; - } else if (unit.unit_id.includes("COUNCIL_SCITECH")) { - role = "GENSEC_SCITECH"; - } else if (unit.unit_id.includes("COUNCIL_SPORTS")) { - role = "GENSEC_SPORTS"; - } else if (unit.unit_id.includes("COUNCIL_ACADEMIC") || unit.unit_id.includes("COUNCIL_ACAD")) { - role = "GENSEC_ACADEMIC"; - } else if (unit.type === 'Club' || unit.type === 'Committee') { - role = "CLUB_COORDINATOR"; - } else { - role = "STUDENT"; - } - - const userData = { - username: unit.contact_info.email, - role: role, - onboardingComplete: true, - personal_info: { - name: unit.name, - email: unit.contact_info.email, - }, - }; - - if (unit.unit_id.includes("_TEST") || unit.name.includes("Test")) { - userData.strategy = "local"; - localAuthUsers.push(userData); - } else { - userData.strategy = "google"; - googleAuthUsers.push(userData); - } - } - - // Add 10 dummy student users with local auth and correct email domain - for (let i = 1; i <= 10; i++) { - const userEmail = `student${i}@iitbhilai.ac.in`; - - // --- Add dummy academic info --- - const branches = ["CSE", "EE"]; - const batchYears = ["2026", "2027"]; - - const batch_year = batchYears[Math.floor(Math.random() * batchYears.length)]; - // Assuming Sep 2025 as current time: 2026 grad year -> 4th year, 2027 grad year -> 3rd year - const current_year = batch_year === "2026" ? "4" : "3"; - - const academic_info = { - program: "B.Tech", - branch: branches[Math.floor(Math.random() * branches.length)], - batch_year: batch_year, - current_year: current_year, - cgpa: parseFloat((Math.random() * (10.0 - 6.0) + 6.0).toFixed(2)) - }; - - localAuthUsers.push({ - username: userEmail, - role: "STUDENT", - strategy: "local", - onboardingComplete: true, - personal_info: { name: `Demo Student ${i}`, email: userEmail }, - academic_info: academic_info, // Add academic info to user data - }); + console.log("Seeding Users..."); + const units = await OrganizationalUnit.find({}); + let localUserCount = 0; + const googleAuthUsers = []; + const password = "password123"; + const branches = ["CSE", "EE"]; + const batchYears = ["2026", "2027"]; + + for (const unit of units) { + let role; + + if (unit.unit_id.includes("PRESIDENT_GYMKHANA")) { + role = "PRESIDENT"; + } else if (unit.unit_id.includes("COUNCIL_CULTURAL")) { + role = "GENSEC_CULTURAL"; + } else if (unit.unit_id.includes("COUNCIL_SCITECH")) { + role = "GENSEC_SCITECH"; + } else if (unit.unit_id.includes("COUNCIL_SPORTS")) { + role = "GENSEC_SPORTS"; + } else if ( + unit.unit_id.includes("COUNCIL_ACADEMIC") || + unit.unit_id.includes("COUNCIL_ACAD") + ) { + role = "GENSEC_ACADEMIC"; + } else if (unit.type === "Club" || unit.type === "Committee") { + role = "CLUB_COORDINATOR"; + } else { + role = "STUDENT"; } - // Create Google auth users (no password needed) - if (googleAuthUsers.length > 0) { - await User.insertMany(googleAuthUsers); - console.log(`Created ${googleAuthUsers.length} Google auth users.`); - } + const batch_year = + batchYears[Math.floor(Math.random() * batchYears.length)]; + // Assuming Sep 2025 as current time: 2026 grad year -> 4th year, 2027 grad year -> 3rd year + const current_year = batch_year === "2026" ? "4" : "3"; + + const academic_info = { + program: "B.Tech", + branch: branches[Math.floor(Math.random() * branches.length)], + batch_year: batch_year, + current_year: current_year, + cgpa: parseFloat((Math.random() * (10.0 - 6.0) + 6.0).toFixed(2)), + }; + + await User.create({ + username: unit.contact_info.email, + password: password, + role: role, + strategy: "local", + onboardingComplete: true, + personal_info: { + name: `Student${localUserCount + 1}`, + email: unit.contact_info.email, + }, + academic_info: academic_info, + }); - // Create Local auth users (requires password hashing) - for (const userData of localAuthUsers) { - const user = new User(userData); - await User.register(user, password); - } - console.log(`Created and registered ${localAuthUsers.length} local auth users.`); + localUserCount++; + } + console.log(`Seeded ${localUserCount} local auth users.`); + + // Add 10 dummy student users with google auth + for (let i = 1; i <= 10; i++) { + const userEmail = `student${i}@iitbhilai.ac.in`; + + const batch_year = + batchYears[Math.floor(Math.random() * batchYears.length)]; + const current_year = batch_year === "2026" ? "4" : "3"; + const academic_info = { + program: "B.Tech", + branch: branches[Math.floor(Math.random() * branches.length)], + batch_year: batch_year, + current_year: current_year, + cgpa: parseFloat((Math.random() * (10.0 - 6.0) + 6.0).toFixed(2)), + }; + + googleAuthUsers.push({ + username: userEmail, + role: "STUDENT", + strategy: "google", + onboardingComplete: true, + personal_info: { name: `Student${i}`, email: userEmail }, + academic_info: academic_info, + }); + } - console.log("Users seeded successfully!"); + if (googleAuthUsers.length > 0) { + await User.insertMany(googleAuthUsers); + console.log(`Created ${googleAuthUsers.length} Google auth users.`); + } + console.log("Users seeded successfully!"); }; /** - * Seeds the Position collection for all test units. + * Seeds the Position collection. + * Creates exactly ONE valid position per unit, with a title that matches + * the Position schema enum and corresponds to the role of that unit's user: + * + * PRESIDENT_GYMKHANA unit → "PRESIDENT" + * Council units → "GENSEC_" (only valid enum values) + * Club / Committee units → "CLUB_COORDINATOR" + * + * Any unit type that does not map to a valid enum value is skipped. */ const seedPositions = async () => { - console.log("Seeding Positions for test units..."); - const testUnits = await OrganizationalUnit.find({ - $or: [{ unit_id: /_TEST/ }, { name: /Test/ }], - }); - - const positionsToCreate = []; - - for (const unit of testUnits) { - const positions = [ - { title: "Coordinator", count: 1, type: "Leadership" }, - { title: "Core Member", count: 5, type: "CoreTeam" }, - { title: "Member", count: 10, type: "General" }, - ]; - - for (const pos of positions) { - const positionData = { - position_id: `${unit.unit_id}_${pos.title.toUpperCase().replace(' ', '_')}`, - title: pos.title, - unit_id: unit._id, - position_type: pos.type, - description: `The ${pos.title} position for ${unit.name}.`, - position_count: pos.count, - responsibilities: [`Fulfill the duties of a ${pos.title}.`], - requirements: { - min_cgpa: 6.0, - min_year: 1, - skills_required: ["Teamwork", "Communication"], - }, - }; - positionsToCreate.push(positionData); - } - } - - if (positionsToCreate.length > 0) { - await Position.insertMany(positionsToCreate); - console.log(`Created ${positionsToCreate.length} positions for test units.`); + console.log("Seeding Positions..."); + const units = await OrganizationalUnit.find({}); + + // Valid Gensec titles as defined by the Position schema enum + const validGensecTitles = new Set([ + "GENSEC_CULTURAL", + "GENSEC_SCITECH", + "GENSEC_SPORTS", + "GENSEC_ACADEMIC", + ]); + + const positionsToCreate = []; + + for (const unit of units) { + let title; + + if (unit.unit_id === "PRESIDENT_GYMKHANA") { + title = "PRESIDENT"; + } else if (unit.type === "Council") { + const mapped = `GENSEC_${unit.category.toUpperCase()}`; + if (!validGensecTitles.has(mapped)) continue; + title = mapped; + } else if (unit.type === "Club" || unit.type === "Committee") { + title = "CLUB_COORDINATOR"; } else { - console.log("No test units found to create positions for."); + // e.g. "independent_position" type — no matching enum value, skip + continue; } - console.log("Positions seeded successfully!"); + positionsToCreate.push({ + position_id: `POS_${unit.unit_id}`, + title, + unit_id: unit._id, + position_type: "Leadership", + description: `${title} position for ${unit.name}.`, + position_count: 1, + responsibilities: [`Fulfill the duties of ${title} for ${unit.name}.`], + requirements: { + min_cgpa: 6.0, + min_year: 1, + skills_required: [], + }, + }); + } + + if (positionsToCreate.length > 0) { + await Position.insertMany(positionsToCreate); + console.log(`Created ${positionsToCreate.length} positions.`); + } else { + console.log("No valid positions to create."); + } + + console.log("Positions seeded successfully!"); }; /** - * Seeds the PositionHolder collection by assigning test students to test positions. + * Seeds the PositionHolder collection for auto-generated role users. + * For every local-auth user that holds a named role (PRESIDENT, GENSEC_*, CLUB_COORDINATOR), + * finds the org unit whose contact email matches the user's username, + * then looks up the position for that unit and creates a PositionHolder record. */ const seedPositionHolders = async () => { - console.log("Seeding Position Holders for test units..."); + console.log("Seeding Position Holders..."); + + const namedRoles = [ + "PRESIDENT", + "GENSEC_CULTURAL", + "GENSEC_SCITECH", + "GENSEC_SPORTS", + "GENSEC_ACADEMIC", + "CLUB_COORDINATOR", + ]; + + // Only auto-generated users (their username = their unit's contact email) + const roleUsers = await User.find({ + strategy: "local", + role: { $in: namedRoles }, + }); + + if (roleUsers.length === 0) { + console.log("No role users found. Skipping position holders."); + return; + } + + const positionHoldersToCreate = []; + + for (const user of roleUsers) { + // Match the unit by its contact email + const unit = await OrganizationalUnit.findOne({ + "contact_info.email": user.username, + }); + if (!unit) continue; + + // Find the position for this unit with the matching role title + const position = await Position.findOne({ + title: user.role, + unit_id: unit._id, + }); + if (!position) continue; + + positionHoldersToCreate.push({ + por_id: `POR_${user._id}_${position._id}`, + user_id: user._id, + position_id: position._id, + tenure_year: "2024-2025", + status: "active", + }); + } - const students = await User.find({ role: 'STUDENT', strategy: 'local' }); - const testClubs = await OrganizationalUnit.find({ type: 'Club', name: /Test/ }); - const testPositions = await Position.find({}).populate('unit_id'); + if (positionHoldersToCreate.length > 0) { + await PositionHolder.insertMany(positionHoldersToCreate); + console.log(`Created ${positionHoldersToCreate.length} position holders.`); + } else { + console.log("Could not create any position holders."); + } - if (students.length === 0) { - console.log("No student users found to assign positions to."); - return; + console.log("Position Holders seeded successfully!"); +}; + +/** + * Seeds named dev/test users with fixed credentials. + * Each position-holding user is also wired to the correct Position and + * PositionHolder documents so the full certificate-batch service flow works. + * + * STUDENT rahul.verma@iitbhilai.ac.in / password123 + * CLUB_COORDINATOR kaushik@iitbhilai.ac.in / kaushik123 -> OpenLake + * PRESIDENT kaushikks@iitbhilai.ac.in / kaushik123 -> President Gymkhana + * GENSEC_CULTURAL gensec_cult@iitbhilai.ac.in / kaushik123 -> Cultural Council + */ +const seedNamedUsers = async () => { + console.log("Seeding named dev users..."); + + // -- 1. STUDENT - Rahul Verma -------------------------------------------------- + await User.create({ + username: "rahul.verma@iitbhilai.ac.in", + password: "password123", + role: "STUDENT", + strategy: "local", + onboardingComplete: true, + personal_info: { + name: "Rahul Verma", + email: "rahul.verma@iitbhilai.ac.in", + }, + academic_info: { + program: "B.Tech", + branch: "CSE", + batch_year: "2026", + current_year: "4", + cgpa: 8.5, + }, + }); + console.log(" Created student: Rahul Verma"); + + // -- 2. CLUB_COORDINATOR - Kaushik -> OpenLake --------------------------------- + const openLake = await OrganizationalUnit.findOne({ + unit_id: "CLUB_OPENLAKE", + }); + if (!openLake) { + console.log(" OpenLake unit not found - skipping named coordinator."); + } else { + const coordinatorPosition = await Position.findOne({ + title: "CLUB_COORDINATOR", + unit_id: openLake._id, + }); + if (!coordinatorPosition) { + console.log( + " CLUB_COORDINATOR position for OpenLake not found - skipping.", + ); + } else { + const kaushikCoord = await User.create({ + username: "kaushik@iitbhilai.ac.in", + password: "kaushik123", + role: "CLUB_COORDINATOR", + strategy: "local", + onboardingComplete: true, + personal_info: { + name: "Kaushik", + email: "kaushik@iitbhilai.ac.in", + }, + academic_info: { + program: "B.Tech", + branch: "CSE", + batch_year: "2026", + current_year: "4", + cgpa: 9.0, + }, + }); + await PositionHolder.create({ + por_id: "POR_NAMED_KAUSHIK_COORD_" + kaushikCoord._id, + user_id: kaushikCoord._id, + position_id: coordinatorPosition._id, + tenure_year: "2024-2025", + status: "active", + }); + console.log(" Created coordinator: Kaushik -> OpenLake"); } + } - const positionHoldersToCreate = []; - let studentIndex = 0; - - for (const club of testClubs) { - if (studentIndex >= students.length) { break;} - - const clubPositions = testPositions.filter(p => p.unit_id._id.equals(club._id)); - const coordinatorPos = clubPositions.find(p => p.title === 'Coordinator'); - const coreMemberPos = clubPositions.find(p => p.title === 'Core Member'); - - if (coordinatorPos && studentIndex < students.length) { - const student = students[studentIndex++]; - positionHoldersToCreate.push({ - por_id: `POR_${student._id}_${coordinatorPos._id}`, - user_id: student._id, - position_id: coordinatorPos._id, - tenure_year: "2024-2025", - status: "active", - }); - } - - if (coreMemberPos && studentIndex < students.length) { - const coreMemberCount = Math.floor(Math.random() * 2) + 1; - for (let i = 0; i < coreMemberCount && studentIndex < students.length; i++) { - const student = students[studentIndex++]; - positionHoldersToCreate.push({ - por_id: `POR_${student._id}_${coreMemberPos._id}_${i}`, - user_id: student._id, - position_id: coreMemberPos._id, - tenure_year: "2024-2025", - status: "active", - }); - } - } + // -- 3. PRESIDENT - Kaushik KS ------------------------------------------------- + const presidentUnit = await OrganizationalUnit.findOne({ + unit_id: "PRESIDENT_GYMKHANA", + }); + if (!presidentUnit) { + console.log(" President unit not found - skipping named president."); + } else { + const presidentPosition = await Position.findOne({ + title: "PRESIDENT", + unit_id: presidentUnit._id, + }); + if (!presidentPosition) { + console.log(" PRESIDENT position not found - skipping."); + } else { + const kaushikPres = await User.create({ + username: "kaushikks@iitbhilai.ac.in", + password: "kaushik123", + role: "PRESIDENT", + strategy: "local", + onboardingComplete: true, + personal_info: { + name: "Kaushik", + email: "kaushikks@iitbhilai.ac.in", + }, + academic_info: { + program: "B.Tech", + branch: "CSE", + batch_year: "2026", + current_year: "4", + cgpa: 9.2, + }, + }); + await PositionHolder.create({ + por_id: "POR_NAMED_KAUSHIK_PRES_" + kaushikPres._id, + user_id: kaushikPres._id, + + position_id: presidentPosition._id, + tenure_year: "2024-2025", + status: "active", + }); + console.log(" Created president: Kaushik KS -> President Gymkhana"); } + } - if (positionHoldersToCreate.length > 0) { - await PositionHolder.insertMany(positionHoldersToCreate); - console.log(`Created ${positionHoldersToCreate.length} position holders.`); + // -- 4. GENSEC_CULTURAL - Test Cultural Council -------------------------------- + const culturalCouncil = await OrganizationalUnit.findOne({ + unit_id: "COUNCIL_CULTURAL", + }); + if (!culturalCouncil) { + console.log(" Cultural council not found - skipping named gensec."); + } else { + const gensecPosition = await Position.findOne({ + title: "GENSEC_CULTURAL", + unit_id: culturalCouncil._id, + }); + if (!gensecPosition) { + console.log(" GENSEC_CULTURAL position not found - skipping."); } else { - console.log("Could not create any position holders."); + const gensecCultural = await User.create({ + username: "gensec_cult@iitbhilai.ac.in", + password: "kaushik123", + role: "GENSEC_CULTURAL", + strategy: "local", + onboardingComplete: true, + personal_info: { + name: "Test Cultural Council", + email: "gensec_cult@iitbhilai.ac.in", + }, + academic_info: { + program: "B.Tech", + branch: "CSE", + batch_year: "2026", + current_year: "4", + cgpa: 8.8, + }, + }); + await PositionHolder.create({ + por_id: "POR_NAMED_GENSEC_CULT_" + gensecCultural._id, + user_id: gensecCultural._id, + position_id: gensecPosition._id, + tenure_year: "2024-2025", + status: "active", + }); + console.log( + " Created gensec cultural: Test Cultural Council -> Cultural Council", + ); } + } - console.log("Position Holders seeded successfully!"); + console.log("Named dev users seeded successfully!"); }; /** * Seeds the Skill collection with a predefined list of skills. */ const seedSkills = async () => { - console.log("Seeding Skills..."); - const skillsData = [ - { skill_id: "SKL_JS", name: "JavaScript", category: "Programming", type: "technical" }, - { skill_id: "SKL_PY", name: "Python", category: "Programming", type: "technical" }, - { skill_id: "SKL_REACT", name: "React", category: "Web Development", type: "technical" }, - { skill_id: "SKL_NODE", name: "Node.js", category: "Web Development", type: "technical" }, - { skill_id: "SKL_MONGO", name: "MongoDB", category: "Database", type: "technical" }, - { skill_id: "SKL_CYBER", name: "Cybersecurity", category: "Security", type: "technical" }, - { skill_id: "SKL_ROBO", name: "Robotics", category: "Hardware", type: "technical" }, - { skill_id: "SKL_DANCE", name: "Dancing", category: "Performing Arts", type: "cultural" }, - { skill_id: "SKL_SING", name: "Singing", category: "Performing Arts", type: "cultural" }, - { skill_id: "SKL_PAINT", name: "Painting", category: "Fine Arts", type: "cultural" }, - { skill_id: "SKL_DART", name: "Digital Art", category: "Fine Arts", type: "cultural" }, - { skill_id: "SKL_SPEAK", name: "Public Speaking", category: "Literary", type: "cultural" }, - { skill_id: "SKL_FILM", name: "Film Making", category: "Media", type: "cultural" }, - { skill_id: "SKL_CRIC", name: "Cricket", category: "Team Sport", type: "sports" }, - { skill_id: "SKL_FOOT", name: "Football", category: "Team Sport", type: "sports" }, - { skill_id: "SKL_YOGA", name: "Yoga", category: "Fitness", type: "sports" }, - { skill_id: "SKL_BASK", name: "Basketball", category: "Team Sport", type: "sports" }, - ]; - await Skill.insertMany(skillsData); - console.log(`Created ${skillsData.length} skills.`); + console.log("Seeding Skills..."); + const skillsData = [ + { + skill_id: "SKL_JS", + name: "JavaScript", + category: "Programming", + type: "technical", + }, + { + skill_id: "SKL_PY", + name: "Python", + category: "Programming", + type: "technical", + }, + { + skill_id: "SKL_REACT", + name: "React", + category: "Web Development", + type: "technical", + }, + { + skill_id: "SKL_NODE", + name: "Node.js", + category: "Web Development", + type: "technical", + }, + { + skill_id: "SKL_MONGO", + name: "MongoDB", + category: "Database", + type: "technical", + }, + { + skill_id: "SKL_CYBER", + name: "Cybersecurity", + category: "Security", + type: "technical", + }, + { + skill_id: "SKL_ROBO", + name: "Robotics", + category: "Hardware", + type: "technical", + }, + { + skill_id: "SKL_DANCE", + name: "Dancing", + category: "Performing Arts", + type: "cultural", + }, + { + skill_id: "SKL_SING", + name: "Singing", + category: "Performing Arts", + type: "cultural", + }, + { + skill_id: "SKL_PAINT", + name: "Painting", + category: "Fine Arts", + type: "cultural", + }, + { + skill_id: "SKL_DART", + name: "Digital Art", + category: "Fine Arts", + type: "cultural", + }, + { + skill_id: "SKL_SPEAK", + name: "Public Speaking", + category: "Literary", + type: "cultural", + }, + { + skill_id: "SKL_FILM", + name: "Film Making", + category: "Media", + type: "cultural", + }, + { + skill_id: "SKL_CRIC", + name: "Cricket", + category: "Team Sport", + type: "sports", + }, + { + skill_id: "SKL_FOOT", + name: "Football", + category: "Team Sport", + type: "sports", + }, + { skill_id: "SKL_YOGA", name: "Yoga", category: "Fitness", type: "sports" }, + { + skill_id: "SKL_BASK", + name: "Basketball", + category: "Team Sport", + type: "sports", + }, + ]; + await Skill.insertMany(skillsData); + console.log("Created " + skillsData.length + " skills."); + console.log("Skills seeded successfully!"); }; /** - * Assigns a random set of skills to each dummy student. + * Assigns a random set of skills to each student user. */ const seedUserSkills = async () => { - console.log("Assigning skills to users..."); - const skills = await Skill.find({}); - const students = await User.find({ role: 'STUDENT', strategy: 'local' }); + console.log("Assigning skills to users..."); + const skills = await Skill.find({}); + const students = await User.find({ role: "STUDENT" }); - if (skills.length === 0 || students.length === 0) { - console.log("No skills or students found to create user-skill links."); - return; - } + if (skills.length === 0 || students.length === 0) { + console.log("No skills or students found. Skipping user skills."); + return; + } - const userSkillsToCreate = []; - const proficiencyLevels = ["beginner", "intermediate", "advanced", "expert"]; - - for (const student of students) { - const skillsToAssignCount = Math.floor(Math.random() * 3) + 2; - const shuffledSkills = [...skills].sort(() => 0.5 - Math.random()); - const selectedSkills = shuffledSkills.slice(0, skillsToAssignCount); - - for (const skill of selectedSkills) { - userSkillsToCreate.push({ - user_id: student._id, - skill_id: skill._id, - proficiency_level: proficiencyLevels[Math.floor(Math.random() * proficiencyLevels.length)], - is_endorsed: false, - }); - } + const userSkillsToCreate = []; + const proficiencyLevels = ["beginner", "intermediate", "advanced", "expert"]; + + for (const student of students) { + const count = Math.floor(Math.random() * 3) + 2; + const shuffled = [...skills].sort(() => 0.5 - Math.random()); + const selectedSkills = shuffled.slice(0, count); + + for (const skill of selectedSkills) { + userSkillsToCreate.push({ + user_id: student._id, + skill_id: skill._id, + proficiency_level: + proficiencyLevels[ + Math.floor(Math.random() * proficiencyLevels.length) + ], + is_endorsed: false, + }); } - - if (userSkillsToCreate.length > 0) { - await UserSkill.insertMany(userSkillsToCreate); - console.log(`Assigned ${userSkillsToCreate.length} skills across ${students.length} students.`); - } - console.log("User skills seeded successfully!"); + } + + if (userSkillsToCreate.length > 0) { + await UserSkill.insertMany(userSkillsToCreate); + console.log( + "Assigned " + + userSkillsToCreate.length + + " skills across " + + students.length + + " students.", + ); + } + console.log("User skills seeded successfully!"); }; /** - * Seeds the Event collection with dummy events for test clubs. + * Seeds the Event collection - one completed and one planned event per club. */ const seedEvents = async () => { - console.log("Seeding Events for test clubs..."); + console.log("Seeding Events for clubs..."); - const testClubs = await OrganizationalUnit.find({ type: 'Club', name: /Test/ }); - const students = await User.find({ role: 'STUDENT', strategy: 'local' }); + const clubs = await OrganizationalUnit.find({ type: "Club" }); + const students = await User.find({ role: "STUDENT" }); - if (testClubs.length === 0 || students.length === 0) { - console.log("No test clubs or students found to create events for."); - return; - } + if (clubs.length === 0 || students.length === 0) { + console.log("No clubs or students found. Skipping events."); + return; + } - const eventsToCreate = []; - const now = new Date(); - - for (const club of testClubs) { - const eventCategory = club.category === 'scitech' ? 'technical' : club.category; - - // --- Completed Event --- - const completedEventParticipants = [...students].sort(() => 0.5 - Math.random()).slice(0, 5); - if (completedEventParticipants.length === 0) { continue;} - const completedEvent = { - event_id: `EVENT_${club.unit_id}_COMPLETED`, - title: `Annual ${club.category} Gala`, - description: `A look back at the amazing ${club.category} events of the past year.`, - category: eventCategory, - type: "Gala", - organizing_unit_id: club._id, - schedule: { - start: new Date(now.getFullYear(), now.getMonth() - 1, 15), - end: new Date(now.getFullYear(), now.getMonth() - 1, 15), - venue: "Main Auditorium", - mode: "offline", - }, - status: "completed", - participants: completedEventParticipants.map(p => p._id), - winners: [{ user: completedEventParticipants[0]._id, position: "1st Place" }], - }; - eventsToCreate.push(completedEvent); - - // --- Planned Event --- - const plannedEvent = { - event_id: `EVENT_${club.unit_id}_PLANNED`, - title: `Introductory Workshop on ${club.category}`, - description: `Join us for a fun and interactive workshop!`, - category: eventCategory, - type: "Workshop", - organizing_unit_id: club._id, - schedule: { - start: new Date(now.getFullYear(), now.getMonth() + 1, 10), - end: new Date(now.getFullYear(), now.getMonth() + 1, 10), - venue: "Room C101", - mode: "offline", - }, - registration: { - required: true, - start: new Date(now.getFullYear(), now.getMonth(), 1), - end: new Date(now.getFullYear(), now.getMonth() + 1, 5), - fees: 0, - max_participants: 50, - }, - status: "planned", - }; - eventsToCreate.push(plannedEvent); - } - - if (eventsToCreate.length > 0) { - await Event.insertMany(eventsToCreate); - console.log(`Created ${eventsToCreate.length} dummy events.`); - } + const eventsToCreate = []; + const now = new Date(); + + for (const club of clubs) { + const clubCategoryMap = { scitech: "technical", independent: "other" }; + const eventCategory = clubCategoryMap[club.category] || club.category; + + // Completed event + const participants = [...students] + .sort(() => 0.5 - Math.random()) + .slice(0, 5); + if (participants.length === 0) continue; + + eventsToCreate.push({ + event_id: "EVENT_" + club.unit_id + "_COMPLETED", + title: "Annual " + club.category + " Gala", + description: + "A look back at the amazing " + + club.category + + " events of the past year.", + category: eventCategory, + type: "Gala", + organizing_unit_id: club._id, + schedule: { + start: new Date(now.getFullYear(), now.getMonth() - 1, 15), + end: new Date(now.getFullYear(), now.getMonth() - 1, 15), + venue: "Main Auditorium", + mode: "offline", + }, + status: "completed", + participants: participants.map((p) => p._id), + winners: [{ user: participants[0]._id, position: "1st Place" }], + }); - console.log("Events seeded successfully!"); + // Planned event + eventsToCreate.push({ + event_id: "EVENT_" + club.unit_id + "_PLANNED", + title: "Introductory Workshop on " + club.category, + description: "Join us for a fun and interactive workshop!", + category: eventCategory, + type: "Workshop", + organizing_unit_id: club._id, + schedule: { + start: new Date(now.getFullYear(), now.getMonth() + 1, 10), + end: new Date(now.getFullYear(), now.getMonth() + 1, 10), + venue: "Room C101", + mode: "offline", + }, + registration: { + required: true, + start: new Date(now.getFullYear(), now.getMonth(), 1), + end: new Date(now.getFullYear(), now.getMonth() + 1, 5), + fees: 0, + max_participants: 50, + }, + status: "planned", + }); + } + + if (eventsToCreate.length > 0) { + await Event.insertMany(eventsToCreate); + console.log("Created " + eventsToCreate.length + " events."); + } + console.log("Events seeded successfully!"); }; /** - * Seeds the Achievement collection based on winners of completed events. + * Seeds the Achievement collection from event winners. */ const seedAchievements = async () => { - console.log("Seeding Achievements from event winners..."); - - const completedEventsWithWinners = await Event.find({ - status: 'completed', - winners: { $exists: true, $not: { $size: 0 } } - }).populate('organizing_unit_id'); - - if (completedEventsWithWinners.length === 0) { - console.log("No completed events with winners found to create achievements from."); - return; - } + console.log("Seeding Achievements from event winners..."); + + const completedEvents = await Event.find({ + status: "completed", + winners: { $exists: true, $not: { $size: 0 } }, + }).populate("organizing_unit_id"); + + if (completedEvents.length === 0) { + console.log( + "No completed events with winners found. Skipping achievements.", + ); + return; + } - const achievementsToCreate = []; - for (const event of completedEventsWithWinners) { - for (const winner of event.winners) { - const achievementCategory = event.category === 'technical' ? 'scitech' : event.category; - const achievementData = { - achievement_id: `ACH_${event.event_id}_${winner.user}`, - user_id: winner.user, - title: `Winner of ${event.title}`, - description: `Achieved ${winner.position} in the ${event.title} event organized by ${event.organizing_unit_id.name}.`, - category: achievementCategory, - type: 'Competition', - level: 'Institute', - date_achieved: event.schedule.end, - position: winner.position, - event_id: event._id, - verified: true, - }; - achievementsToCreate.push(achievementData); - } + const achievementsToCreate = []; + + for (const event of completedEvents) { + for (const winner of event.winners) { + const achievementCategory = + event.category === "technical" ? "scitech" : event.category; + achievementsToCreate.push({ + achievement_id: "ACH_" + event.event_id + "_" + winner.user, + user_id: winner.user, + title: "Winner of " + event.title, + description: + "Achieved " + + winner.position + + " in the " + + event.title + + " event organized by " + + event.organizing_unit_id.name + + ".", + category: achievementCategory, + type: "Competition", + level: "Institute", + date_achieved: event.schedule.end, + position: winner.position, + event_id: event._id, + verified: true, + }); } + } - if (achievementsToCreate.length > 0) { - await Achievement.insertMany(achievementsToCreate); - console.log(`Created ${achievementsToCreate.length} achievements for event winners.`); - } + if (achievementsToCreate.length > 0) { + await Achievement.insertMany(achievementsToCreate); + console.log("Created " + achievementsToCreate.length + " achievements."); + } + console.log("Achievements seeded successfully!"); }; /** - * Seeds the Feedback collection for events and organizational units. + * Seeds the Feedback collection for events and clubs. */ const seedFeedbacks = async () => { - console.log("Seeding Feedback..."); + console.log("Seeding Feedback..."); + + const students = await User.find({ role: "STUDENT" }); + const completedEvents = await Event.find({ status: "completed" }); + const clubs = await OrganizationalUnit.find({ type: "Club" }); + + if ( + students.length < 2 || + completedEvents.length === 0 || + clubs.length === 0 + ) { + console.log("Not enough data for feedback. Skipping."); + return; + } - const students = await User.find({ role: 'STUDENT', strategy: 'local' }); - const completedEvents = await Event.find({ status: 'completed' }); - const testClubs = await OrganizationalUnit.find({ type: 'Club', name: /Test/ }); + const feedbacksToCreate = []; + const eventToReview = completedEvents[0]; + const reviewer1 = students[0]; + const reviewer2 = students[1]; + + feedbacksToCreate.push({ + feedback_id: "FDB_EVT_" + eventToReview._id + "_" + reviewer1._id, + type: "Event Feedback", + target_id: eventToReview._id, + target_type: "Event", + feedback_by: reviewer1._id, + rating: 5, + comments: "This was an amazing event! Well organized and very engaging.", + is_anonymous: false, + }); + + feedbacksToCreate.push({ + feedback_id: "FDB_EVT_" + eventToReview._id + "_" + reviewer2._id, + type: "Event Feedback", + target_id: eventToReview._id, + target_type: "Event", + feedback_by: reviewer2._id, + rating: 4, + comments: "Good event, but the timings could have been better.", + is_anonymous: true, + }); + + const clubToReview = clubs[0]; + feedbacksToCreate.push({ + feedback_id: "FDB_OU_" + clubToReview._id + "_" + reviewer1._id, + type: "Unit Feedback", + target_id: clubToReview._id, + target_type: "Organizational_Unit", + feedback_by: reviewer1._id, + rating: 4, + comments: + "The " + clubToReview.name + " is doing a great job this semester.", + is_anonymous: false, + }); + + if (feedbacksToCreate.length > 0) { + await Feedback.insertMany(feedbacksToCreate); + console.log("Created " + feedbacksToCreate.length + " feedback entries."); + } + console.log("Feedback seeded successfully!"); +}; - if (students.length < 2 || completedEvents.length === 0 || testClubs.length === 0) { - console.log("Not enough data to create meaningful feedback."); - return; - } - - const feedbacksToCreate = []; - - // 1. Create feedback for an event - const eventToReview = completedEvents[0]; - const studentReviewer1 = students[0]; - const studentReviewer2 = students[1]; - - feedbacksToCreate.push({ - feedback_id: `FDB_EVT_${eventToReview._id}_${studentReviewer1._id}`, - type: 'Event Feedback', - target_id: eventToReview._id, - target_type: 'Event', - feedback_by: studentReviewer1._id, - rating: 5, - comments: 'This was an amazing event! Well organized and very engaging.', - is_anonymous: false, - }); +/** + * Seeds the Template collection — one active template per certificate category. + * All templates are authored by the President user. + */ +const seedTemplates = async () => { + console.log("Seeding Templates..."); + + const president = await User.findOne({ role: "PRESIDENT" }); + if (!president) { + console.log("No president user found. Skipping templates."); + return; + } - feedbacksToCreate.push({ - feedback_id: `FDB_EVT_${eventToReview._id}_${studentReviewer2._id}`, - type: 'Event Feedback', - target_id: eventToReview._id, - target_type: 'Event', - feedback_by: studentReviewer2._id, - rating: 4, - comments: 'Good event, but the timings could have been better.', - is_anonymous: true, + const templatesToCreate = [ + { + title: "Cultural Events Participation Certificate", + description: + "Awarded to students who participated in cultural events organised by the Cultural Council.", + design: "Default", + createdBy: president._id, + category: "CULTURAL", + status: "Active", + }, + { + title: "Science & Technology Achievement Certificate", + description: + "Awarded to students who participated in science and technology events organised by the SciTech Council.", + design: "Default", + createdBy: president._id, + category: "TECHNICAL", + status: "Active", + }, + { + title: "Sports Excellence Certificate", + description: + "Awarded to students who demonstrated excellence in sports events organised by the Sports Council.", + design: "Default", + createdBy: president._id, + category: "SPORTS", + status: "Active", + }, + { + title: "Academic Achievement Certificate", + description: + "Awarded to students for outstanding academic contributions and participation.", + design: "Default", + createdBy: president._id, + category: "ACADEMIC", + status: "Active", + }, + { + title: "General Participation Certificate", + description: + "General-purpose certificate for participation in institute events and activities.", + design: "Default", + createdBy: president._id, + category: "OTHER", + status: "Active", + }, + ]; + + await Template.insertMany(templatesToCreate); + console.log("Created " + templatesToCreate.length + " templates."); + console.log("Templates seeded successfully!"); +}; + +/** + * Seeds the CertificateBatch collection. + * Creates one batch per completed event, cycling through all four lifecycle + * states (Draft, Submitted, Active, Archived) in round-robin for variety. + * + * initiatedBy - CLUB_COORDINATOR whose username matches the club email + * approverIds - [matching Gensec, President] + * users - event participants (omitted for Draft) + * signatoryDetails - coordinator + gensec + president (omitted for Draft) + */ +const seedCertificateBatches = async () => { + console.log("Seeding Certificate Batches..."); + + const completedEvents = await Event.find({ status: "completed" }).populate( + "organizing_unit_id", + ); + const templates = await Template.find({ status: "Active" }); + const president = await User.findOne({ role: "PRESIDENT" }); + + if (completedEvents.length === 0) { + console.log("No completed events. Skipping batches."); + return; + } + if (templates.length === 0) { + console.log("No active templates. Skipping batches."); + return; + } + if (!president) { + console.log("No president found. Skipping batches."); + return; + } + + // Template lookup: lowercase category key -> template doc + const templateMap = templates.reduce((map, tpl) => { + map[tpl.category.toLowerCase()] = tpl; + return map; + }, {}); + + // Club category -> Template category key + const categoryToTemplateKey = { + cultural: "cultural", + scitech: "technical", + sports: "sports", + academic: "academic", + }; + + // Lifecycle states cycled in round-robin across events + const lifecycleStates = [ + { lifecycleStatus: "Draft", approvalStatus: null, currentApprovalLevel: 0 }, + { + lifecycleStatus: "Submitted", + approvalStatus: "Pending", + currentApprovalLevel: 0, + }, + { + lifecycleStatus: "Active", + approvalStatus: "Approved", + currentApprovalLevel: 2, + }, + { + lifecycleStatus: "Archived", + approvalStatus: "Approved", + currentApprovalLevel: 2, + }, + ]; + + const batchesToCreate = []; + + for (let i = 0; i < completedEvents.length; i++) { + const event = completedEvents[i]; + const club = event.organizing_unit_id; + if (!club) continue; + + const coordinator = await User.findOne({ + username: club.contact_info.email, + role: "CLUB_COORDINATOR", }); + if (!coordinator) continue; - // 2. Create feedback for an organizational unit - const clubToReview = testClubs[0]; - feedbacksToCreate.push({ - feedback_id: `FDB_OU_${clubToReview._id}_${studentReviewer1._id}`, - type: 'Unit Feedback', - target_id: clubToReview._id, - target_type: 'Organizational_Unit', - feedback_by: studentReviewer1._id, - rating: 4, - comments: `The ${clubToReview.name} is doing a great job this semester.`, - is_anonymous: false, + const gensec = await User.findOne({ + role: "GENSEC_" + club.category.toUpperCase(), }); + if (!gensec) continue; + + const templateKey = categoryToTemplateKey[club.category] || "other"; + const template = templateMap[templateKey]; + if (!template) continue; + + const { lifecycleStatus, approvalStatus, currentApprovalLevel } = + lifecycleStates[i % lifecycleStates.length]; + const isDraft = lifecycleStatus === "Draft"; + const eventParticipants = event.participants || []; + + const signatoryDetails = [ + { + name: coordinator.personal_info.name, + signature: + "https://signatures.iitbhilai.ac.in/" + coordinator._id + ".png", + role: "Club Coordinator", + }, + { + name: gensec.personal_info.name, + signature: "https://signatures.iitbhilai.ac.in/" + gensec._id + ".png", + role: "General Secretary, " + club.name, + }, + { + name: president.personal_info.name, + signature: + "https://signatures.iitbhilai.ac.in/" + president._id + ".png", + role: "President, Student Gymkhana", + }, + ]; - if (feedbacksToCreate.length > 0) { - await Feedback.insertMany(feedbacksToCreate); - console.log(`Created ${feedbacksToCreate.length} feedback entries.`); - } + const batchDoc = Object.assign( + { + title: "Annual " + club.category + " Gala - Participation Certificate", + eventId: event._id, + templateId: template._id, + initiatedBy: coordinator._id, + approverIds: [gensec._id, president._id], + lifecycleStatus, + currentApprovalLevel, + }, + approvalStatus ? { approvalStatus } : {}, + { users: eventParticipants, signatoryDetails }, + ); + + batchesToCreate.push(batchDoc); + } + + if (batchesToCreate.length > 0) { + await CertificateBatch.insertMany(batchesToCreate); + console.log("Created " + batchesToCreate.length + " certificate batches."); + } else { + console.log("No certificate batches could be created."); + } + console.log("Certificate Batches seeded successfully!"); }; +/** + * Seeds the Certificate collection from existing batches. + * Submitted batches -> Pending certificates (no URL / ID yet) + * Active / Archived -> Approved certificates (URL + zero-padded ID) + * Draft batches are skipped entirely. + */ +const seedCertificates = async () => { + console.log("Seeding Certificates..."); + + const actionableBatches = await CertificateBatch.find({ + lifecycleStatus: { $in: ["Submitted", "Active", "Archived"] }, + }); + + if (actionableBatches.length === 0) { + console.log("No actionable batches found. Skipping certificates."); + return; + } + + const certificatesToCreate = []; + let certCounter = 1; + + for (const batch of actionableBatches) { + if (!batch.users || batch.users.length === 0) continue; + + const isApproved = + batch.lifecycleStatus === "Active" || + batch.lifecycleStatus === "Archived"; + + for (const userId of batch.users) { + const certDoc = Object.assign( + { + userId, + batchId: batch._id, + status: isApproved ? "Approved" : "Pending", + }, + isApproved + ? { + certificateUrl: + "https://certificates.iitbhilai.ac.in/" + + batch._id + + "/" + + userId + + ".pdf", + certificateId: "CERT-" + String(certCounter++).padStart(5, "0"), + } + : {}, + ); + certificatesToCreate.push(certDoc); + } + } + + if (certificatesToCreate.length > 0) { + await Certificate.insertMany(certificatesToCreate); + console.log("Created " + certificatesToCreate.length + " certificates."); + } else { + console.log("No certificates to create."); + } + console.log("Certificates seeded successfully!"); +}; /** * Main function to run the entire seeding process. @@ -605,24 +1416,26 @@ async function seedDB() { await seedUsers(); await seedPositions(); await seedPositionHolders(); + await seedNamedUsers(); await seedSkills(); await seedUserSkills(); await seedEvents(); await seedAchievements(); await seedFeedbacks(); + await seedTemplates(); + await seedCertificateBatches(); + await seedCertificates(); - console.log("\n✅ Seeding completed successfully!"); - + console.log("\n Seeding completed successfully!"); } catch (error) { - console.error("\n❌ An error occurred during the seeding process:", error); + console.error("\n An error occurred during the seeding process:", error); } finally { if (mongoose.connection.readyState === 1) { - await mongoose.connection.close(); - console.log("Database connection closed."); + await mongoose.connection.close(); + console.log("Database connection closed."); } } } // Run the seeding function seedDB(); - diff --git a/backend/services/event.service.js b/backend/services/event.service.js new file mode 100644 index 00000000..85ed47d7 --- /dev/null +++ b/backend/services/event.service.js @@ -0,0 +1,12 @@ +const Event = require("../models/eventSchema"); +const { HttpError } = require("../utils/httpError"); + +async function findEvent(id) { + const event = await Event.findById(id); + if (!event) throw new HttpError(400, "Selected event doesn't exist"); + return event; +} + +module.exports = { + findEvent +} \ No newline at end of file diff --git a/backend/services/organization.service.js b/backend/services/organization.service.js new file mode 100644 index 00000000..c2c46773 --- /dev/null +++ b/backend/services/organization.service.js @@ -0,0 +1,45 @@ +const OrganizationalUnit = require("../models/organizationSchema"); +const { HttpError } = require("../utils/httpError"); + +async function getOrganization(id) { + const org = await OrganizationalUnit.findById(id); + if (!org) throw new HttpError(403, "Organization doesn't exist"); + return org; +} + +async function getPresidentOrganization(club) { + + if(club.type?.toLowerCase() !== "club"){ + throw new HttpError(403, "Organization is not a club"); + } + const presidentOrg = await OrganizationalUnit.findOne({ + hierarchy_level: 0, + parent_unit_id: null, + }); + if (!presidentOrg) throw new HttpError(500, "President organization not found"); + + if (!club.parent_unit_id) { + throw new HttpError(403, "Organization(Club) does not belong to a council"); + } + + const councilObj = await getOrganization(club.parent_unit_id); + if (!councilObj.parent_unit_id) { + throw new HttpError(403, "Organization(Council) does not belong to a president organization"); + } + + if (councilObj.type?.toLowerCase() !== "council") { + throw new HttpError(403, "Organization does not belong to a council"); + } + + const presidentObj = await getOrganization(councilObj.parent_unit_id); + if (!presidentOrg._id.equals(presidentObj._id)) { + throw new HttpError(500, "Invalid Organization"); + } + + return presidentObj; +} + +module.exports = { + getOrganization, + getPresidentOrganization +} \ No newline at end of file diff --git a/backend/services/template.service.js b/backend/services/template.service.js new file mode 100644 index 00000000..85e815af --- /dev/null +++ b/backend/services/template.service.js @@ -0,0 +1,18 @@ +const Template = require("../models/templateSchema"); +const { HttpError } = require("../utils/httpError"); +const mongoose = require("mongoose"); + +async function findTemplate(id) { + + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new HttpError(400, "Invalid template ID format"); + } + + const template = await Template.findById(id); + if (!template) throw new HttpError(404, "Selected template doesn't exist"); + return template; +} + +module.exports = { + findTemplate +} \ No newline at end of file diff --git a/backend/services/user.service.js b/backend/services/user.service.js new file mode 100644 index 00000000..998d28a2 --- /dev/null +++ b/backend/services/user.service.js @@ -0,0 +1,44 @@ +const PositionHolder = require("../models/positionHolderSchema"); +const Position = require("../models/positionSchema"); +const User = require("../models/userSchema"); +const { HttpError } = require("../utils/httpError"); + +async function getUserPosition(userId) { + const positionHolder = await PositionHolder.findOne({ user_id: userId }); + if (!positionHolder) { + throw new HttpError(403, "You do not hold a valid position in any organization"); + } + + const position = await Position.findById(positionHolder.position_id); + if (!position) throw new HttpError(403, "Invalid Position"); + + return position; +} + +async function getApprovers(category) { + const normalizedCategory = String(category || "").toUpperCase(); + const gensecObj = await User.findOne({ role: `GENSEC_${normalizedCategory}` }); + if (!gensecObj) { + throw new HttpError(403, "General secretary doesn't exist for the category"); + } + + const gensecPosition = await getUserPosition(gensecObj._id); + if (gensecPosition.title !== gensecObj.role.toUpperCase()) { + throw new HttpError(500, "Data inconsistent - General Secretary could not be resolved"); + } + + const presidentObj = await User.findOne({ role: "PRESIDENT" }); + if (!presidentObj) throw new HttpError(403, "President role doesn't exist"); + + const presidentPosition = await getUserPosition(presidentObj._id); + if (presidentPosition.title !== presidentObj.role.toUpperCase()) { + throw new HttpError(500, "Data inconsistent - President could not be resolved"); + } + + return { gensecObj, presidentObj }; +} + +module.exports = { + getUserPosition, + getApprovers +} diff --git a/backend/utils/authValidate.js b/backend/utils/authValidate.js new file mode 100644 index 00000000..6bfbbd6e --- /dev/null +++ b/backend/utils/authValidate.js @@ -0,0 +1,22 @@ +const zod = require("zod"); + +const zodUsername = zod.string().regex(/^[a-zA-Z0-9._%+-]+@iitbhilai\.ac\.in$/i); + +const loginValidate = zod.object({ + username: zodUsername, + password: zod.string().min(8), +}); + +const registerValidate = zod.object({ + username: zodUsername, + password: zod.string().min(8), + //user_id: zod.string().min(2), + name: zod.string().min(5), + role: zod.string().min(5), +}); + +module.exports = { + loginValidate, + registerValidate, + zodUsername +}; diff --git a/backend/utils/batchValidate.js b/backend/utils/batchValidate.js new file mode 100644 index 00000000..2d45d149 --- /dev/null +++ b/backend/utils/batchValidate.js @@ -0,0 +1,29 @@ +const zod = require("zod"); + +const zodObjectId = zod.string().regex(/^[0-9a-zA-Z]{24}$/, "Invalid ObjectId"); + +const validateBatchSchema = zod.object({ + title: zod.string().min(5, "Title is required"), + eventId: zodObjectId, + templateId: zodObjectId, + signatoryDetails: zod + .array( + zod.object({ + name: zod.string().min(3, "Name must be atleast 5 characters"), + signature: zod.string().optional(), + role: zod.string().min(1, "Invalid position"), + }), + ) + .nonempty("At least one signatory is required"), + users: zod.array(zodObjectId).min(1, "Atleast 1 user must be associated."), +}); + +const validateBatchUsersIds = zod + .array(zodObjectId) + .nonempty("At least 1 participant is required"); + +module.exports = { + validateBatchSchema, + zodObjectId, + validateBatchUsersIds, +}; diff --git a/backend/utils/httpError.js b/backend/utils/httpError.js new file mode 100644 index 00000000..8e889872 --- /dev/null +++ b/backend/utils/httpError.js @@ -0,0 +1,16 @@ +class HttpError extends Error { + /** + * @param {number} statusCode + * @param {string} message + * @param {any} [details] + */ + constructor(statusCode, message, details) { + super(message); + this.name = "HttpError"; + this.statusCode = statusCode; + this.details = details; + } +} + +module.exports = { HttpError }; + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26916cac..b6af1f89 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7206,9 +7206,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001772", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz", - "integrity": "sha512-mIwLZICj+ntVTw4BT2zfp+yu/AqV6GMKfJVJMx3MwPxs+uk/uj2GLl2dH8LQbjiLDX66amCga5nKFyDgRR43kg==", + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "funding": [ { "type": "opencollective", diff --git a/frontend/src/App.js b/frontend/src/App.jsx similarity index 98% rename from frontend/src/App.js rename to frontend/src/App.jsx index 5f7688ed..c1d09521 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import { ToastContainer } from "react-toastify"; function App() { const authData = useAuth(); const { isUserLoggedIn, isOnboardingComplete, isLoading } = authData; + //console.log("User data is: ",authData); // const role = isUserLoggedIn?.role || "STUDENT"; // const navItems = NavbarConfig[role] || []; diff --git a/frontend/src/Components/Auth/Login.jsx b/frontend/src/Components/Auth/Login.jsx index 57a22f35..232fec2e 100644 --- a/frontend/src/Components/Auth/Login.jsx +++ b/frontend/src/Components/Auth/Login.jsx @@ -1,14 +1,14 @@ import React, { useState, useContext } from "react"; -import { AdminContext } from "../../context/AdminContext"; +import { useAdminContext } from "../../context/AdminContext"; import { loginUser } from "../../services/auth"; import { useNavigate } from "react-router-dom"; import GoogleIcon from "@mui/icons-material/Google"; import cosa from "../../assets/COSA.png"; import backgroundImage from "../../assets/iitbh.jpg"; import { toast } from "react-toastify"; - +import { Link } from "react-router-dom"; export default function Login() { - const { handleLogin } = useContext(AdminContext); + const { handleLogin } = useAdminContext(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -19,10 +19,12 @@ export default function Login() { setLoading(true); try { - const userObject = await loginUser(email, password); - if (userObject) { - handleLogin(userObject); - toast.success("Login successful! 🎉"); + const response = await loginUser(email, password); + //console.log(response); + if (response?.success) { + handleLogin(response.data); + toast.success("Login successful "); + //console.log("Onboarding now is:", isOnboardingComplete); navigate("/", { replace: true }); } else { toast.error("Login failed. Please check your credentials."); @@ -41,7 +43,7 @@ export default function Login() { style={{ backgroundImage: `url(${backgroundImage})`, backgroundSize: "cover", - backgroundPosition: "center" + backgroundPosition: "center", }} > {/* Blur Overlay */} @@ -61,7 +63,6 @@ export default function Login() { className="flex flex-wrap flex-col-reverse lg:flex-row items-center justify-center gap-12 lg:gap-16 w-full max-w-7xl relative" style={{ zIndex: 2 }} > -
{/* Google Login */} - { + window.location.href = `${process.env.REACT_APP_BACKEND_URL}/auth/google`; + }} > - - + Sign up with Google + +

Don’t have an account?{" "} - Sign Up - +

diff --git a/frontend/src/Components/Auth/Logout.jsx b/frontend/src/Components/Auth/Logout.jsx new file mode 100644 index 00000000..5a87fd75 --- /dev/null +++ b/frontend/src/Components/Auth/Logout.jsx @@ -0,0 +1,37 @@ +// src/pages/Logout.jsx +import React, { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { logoutUser } from "../../services/auth"; +import { useAdminContext } from "../../context/AdminContext"; +import {toast} from "react-toastify"; + +const Logout = () => { + const navigate = useNavigate(); + const { setIsUserLoggedIn, setUserRole, isLoading, setIsLoading} = useAdminContext(); + useEffect(() => { + const performLogout = async () => { + try { + const loggedOut = await logoutUser(); // server logout + if(!loggedOut){ + toast.error("Server log out failed"); + return ; + } + + setIsUserLoggedIn(null); // clear frontend session + setUserRole("STUDENT"); // reset role + toast.success("Logged out successfully"); + setTimeout(() => navigate("/"),1500); // redirect + } catch (error) { + console.error("Logout failed:", error); + }finally{ + setIsLoading(false); + } + }; + + performLogout(); + }, [setIsUserLoggedIn, setUserRole]); + + if(isLoading) return

Logging out...

; +}; + +export default Logout; diff --git a/frontend/src/Components/Auth/Register.jsx b/frontend/src/Components/Auth/Register.jsx index 8456697a..9c45a8df 100644 --- a/frontend/src/Components/Auth/Register.jsx +++ b/frontend/src/Components/Auth/Register.jsx @@ -1,8 +1,74 @@ import GoogleIcon from "@mui/icons-material/Google"; import cosa from "../../assets/COSA.png"; import backgroundImage from "../../assets/iitbh.jpg"; +import { Link, useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { registerUser } from "../../services/auth"; +import { toast } from "react-toastify"; export default function Register() { + const [form, setForm] = useState({ + username: "", + password: "", + name: "", + }); + const [loading, setLoading] = useState(false); + const navigate = useNavigate("/"); + function handleChange(e) { + const { name, value } = e.target; + setForm((prev) => ({ + ...prev, + [name]: value, + })); + } + + async function handleSubmit(e) { + e.preventDefault(); + setLoading(true); + try { + const response = await registerUser( + form.username, + form.password, + form.name, + ); + // success response is the full axios response + if ( + response && + response.status === 200 && + response.data && + response.data.success + ) { + toast.success(response.data.message || "Registration successful"); + setTimeout(() => { + navigate("/", { replace: true }); + }, 1500); + return; + } + + // handle errors returned from server + let errorMessage = ""; + const respData = response && response.data; + if (respData) { + const msg = respData.message; + if (Array.isArray(msg)) { + errorMessage = msg.join(". "); + } else if (typeof msg === "string") { + errorMessage = msg; + } else if (msg && msg.message) { + errorMessage = msg.message; + } + } else if (response && response.status) { + errorMessage = response.statusText; + } + toast.error(errorMessage); + } catch (error) { + console.error("Registration failed:", error); + toast.error("Registration failed. Please try again."); + } finally { + setLoading(false); + } + } + return (
-
-
+

Sign Up

+
+ + {/* Username */} + + + + {/* Password */} + + + + {/* Name */} + + + + {/* Register Button */} + - {/* Google Register */} - +
+ OR +
+
+ + {/* Google Register */} + {/* + + - + Sign up with Google + + + + */} + -

- Already have an account?{" "} - - Login - -

-
-
+

+ Already have an account?{" "} + + Login + +

+ {/* CoSA Logo */}
diff --git a/frontend/src/Components/Auth/RoleRedirect.jsx b/frontend/src/Components/Auth/RoleRedirect.jsx index e6970c82..facf3a83 100644 --- a/frontend/src/Components/Auth/RoleRedirect.jsx +++ b/frontend/src/Components/Auth/RoleRedirect.jsx @@ -1,14 +1,12 @@ import React, { useContext } from "react"; import { Navigate } from "react-router-dom"; -import { AdminContext } from "../../context/AdminContext"; +import { useAdminContext } from "../../context/AdminContext"; const RoleRedirect = () => { - const { userRole, isUserLoggedIn, isOnboardingComplete, isLoading } = - useContext(AdminContext); - + const { userRole, isUserLoggedIn, isOnboardingComplete, isLoading } = useAdminContext(); if (isLoading) return
Loading...
; - if (!isUserLoggedIn) { + if (!isUserLoggedIn || Object.keys(isUserLoggedIn).length === 0) { return ; } @@ -19,7 +17,7 @@ const RoleRedirect = () => { if (!userRole) { return
Loading user role...
; // Or just return null for a blank screen } - return ; + return ; }; export default RoleRedirect; diff --git a/frontend/src/Components/Auth/UserOnboarding.jsx b/frontend/src/Components/Auth/UserOnboarding.jsx index cb4a2e01..afb57ede 100644 --- a/frontend/src/Components/Auth/UserOnboarding.jsx +++ b/frontend/src/Components/Auth/UserOnboarding.jsx @@ -1,13 +1,12 @@ import { useState, useEffect, useContext } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Navigate } from "react-router-dom"; import { fetchCredentials, completeOnboarding } from "../../services/auth"; -import { AdminContext } from "../../context/AdminContext"; +import { useAdminContext } from "../../context/AdminContext"; import logo from "../../assets/image.png"; export default function OnboardingForm() { const navigate = useNavigate(); - const { setIsOnboardingComplete } = useContext(AdminContext); - + const { setIsOnboardingComplete, isOnboardingComplete } = useAdminContext(); const [userData, setUserData] = useState({ name: "", email: "", @@ -23,11 +22,13 @@ export default function OnboardingForm() { useEffect(() => { const fetchUser = async () => { try { - const user = await fetchCredentials(); + const response = await fetchCredentials(); + const user = response.message; + if (!user) return; setUserData((prev) => ({ ...prev, - name: user.personal_info.name, - email: user.personal_info.email, + name: user.personal_info?.name, + email: user.personal_info?.email, })); } catch (error) { console.error("Error fetching user data:", error); @@ -41,7 +42,11 @@ export default function OnboardingForm() { if (!userData.ID_No) newErrors.ID_No = "ID Number is required"; if (!/^\d{10}$/.test(userData.mobile_no)) newErrors.mobile_no = "Mobile number must be 10 digits"; - if (!userData.add_year || userData.add_year < 2016) + if ( + !userData.add_year || + userData.add_year < 2016 || + userData.add_year > new Date().getFullYear() + ) newErrors.add_year = "Invalid admission year"; if (!userData.Program) newErrors.Program = "Program is required"; if (!userData.discipline) newErrors.discipline = "Discipline is required"; @@ -55,7 +60,9 @@ export default function OnboardingForm() { const handleSubmit = async (e) => { e.preventDefault(); const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + //console.log("Validation errors: ", validationErrors); setErrors(validationErrors); return; } @@ -68,6 +75,9 @@ export default function OnboardingForm() { } }; + if (isOnboardingComplete) { + return ; + } return (
@@ -80,8 +90,8 @@ export default function OnboardingForm() { className="w-32 h-32 object-contain rounded-full" />
-

- Welcome to Our College +

+ Welcome to IIT Bhilai

Complete your profile to access all campus services and tools. @@ -123,7 +133,7 @@ export default function OnboardingForm() { Student ID Number + {label} + + ); +} + +function ActionBtnList({ icon: Icon, danger, onClick }) { + return ( + + ); +} + +export function BatchCard({ + batch, + onView, + onEdit, + onDelete, + onDuplicate, + onArchive, +}) { + + const batchKey = String(batch._id || batch.id || batch.batchId || ""); + const colorIndex = [...batchKey].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % BATCH_COLORS.length; + const c = BATCH_COLORS[colorIndex % BATCH_COLORS.length]; + + return ( +

+ {/* Thumbnail */} +
+ +
+ + {/* Body */} +
+ {/* Title + status */} +
+
+

+ {batch.eventId.title} +

+

+ {batch.eventId.organizing_unit_id.name || ""} +

+
+ +
+ + {/* Inner sub-panel */} +
+ {/* Row 1 */} +
+ + {batch.eventId.title} + + + + + {new Date(batch.createdAt).toLocaleDateString()} + +
+ + {/* Row 2 */} +
+ {/* Left side */} +
+ + Students: {batch.users.length} +
+ + {/* Right side */} +
+ + {batch.eventId.schedule?.venue} +
+
+ + {/* +
+ Event Schedule: + + {new Date(batch.eventId.schedule.start).toLocaleDateString()} -{" "} + {new Date(batch.eventId.schedule.end).toLocaleDateString()} + +
+ + +
+ Approval: {batch.currentApprovalLevel} / 2 +
+ */} + + {/* Row 5 */} +
+
+ + Last Updated: {new Date(batch.updatedAt).toLocaleDateString()} + + + + -{batch?.initiatedBy?.personal_info?.name || "User"} + +
+
+
+ + {/* Actions */} +
+ {batch.lifecycleStatus === "Draft" && ( + <> + onView(batch)} /> + onEdit(batch)} /> + onDuplicate(batch)} /> + onDelete(batch)} + /> + + )} + {batch.lifecycleStatus === "Submitted" && ( + <> + onView(batch)} /> + onDuplicate(batch)} /> + onArchive(batch._id)} /> + + )} + {batch.lifecycleStatus === "Archived" && ( + <> + onView(batch)} /> + onDuplicate(batch)} /> + onDelete(batch)} + /> + + )} + {batch.lifecycleStatus === "Active" && ( + <> + onView(batch)} /> + onDuplicate(batch)} /> + onArchive(batch._id)} /> + + )} +
+
+
+ ); +} + +export function BatchList({ + filtered, + onView, + onEdit, + onDelete, + onDuplicate, + onArchive, +}) { + //console.log('filtered prop', filtered); + return ( +
+ + {/* Header */} + + + {[ + "Batch", + "Organization", + "Template", + "Students", + "Status", + "Created By", + "Last Modified", + "Actions", + ].map((h, i, arr) => ( + + ))} + + + + {/* Body */} + + {filtered?.map((b, i) => { + const c = BATCH_COLORS[b.color % BATCH_COLORS.length]; + + return ( + + {/* Batch Name */} + + + {/* Organization */} + + + {/* Template */} + + + {/* Students */} + + + {/* Status */} + + + {/* Created By */} + + + {/* Modified */} + + + {/* Actions */} + + + ); + })} + +
+ {h} +
+
+

+ {b.name} +

+
+
+
+

+ {b.org} +

+
+
+
+

+ {b.template} +

+
+
+
+

+ {b.students} +

+
+
+ + +
+

+ {b.createdBy} +

+
+
+
+

+ {b.modified} +

+
+
+
+ {b.status === "Draft" && ( + <> + onEdit(b)} + /> + onDelete(b.id)} + /> + + )} + + {b.status === "Active" && ( + <> + onView(b)} + /> + onEdit(b)} + /> + onDuplicate(b)} + /> + onDelete(b.id)} + /> + + )} + {b.status === "Submitted" && ( + <> + onView(b)} + /> + onArchive(b.id)} + /> + + )} + {b.status === "Archived" && ( + <> + onView(b)} + /> + onDuplicate(b)} + /> + onDelete(b.id)} + /> + + )} +
+
+
+ ); +} diff --git a/frontend/src/Components/Batches/batches.jsx b/frontend/src/Components/Batches/batches.jsx new file mode 100644 index 00000000..a1718990 --- /dev/null +++ b/frontend/src/Components/Batches/batches.jsx @@ -0,0 +1,549 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + LayoutGrid, + List, + Search, + X, + Info, + SquareUser, + Plus, + Building2, + FolderOpen, + CircleFadingArrowUp, +} from "lucide-react"; +import ModalDialog from "./modalDialog"; +import { BatchCard, BatchList } from "./batchCard"; +import { Select } from "./select"; +import { + fetchBatches, + createBatch, + editBatch, + duplicateBatch, + deleteBatch, + archiveBatchApi, +} from "../../services/batch"; +import { fetchEvents } from "../../services/events"; +import { fetchTemplates } from "../../services/templates"; +import { useAdminContext } from "../../context/AdminContext"; + +export default function BatchesPage() { + const { isUserLoggedIn } = useAdminContext(); + const [batches, setBatches] = useState([]); + const [events, setEvents] = useState([]); + const [templates, setTemplates] = useState([]); + + const [search, setSearch] = useState(""); + const [org, setOrg] = useState("ALL"); + const [status, setStatus] = useState("ALL"); + const [approvalStatus, setApprovalStatus] = useState("ALL"); + const [creator, setCreator] = useState("ALL"); + const [view, setView] = useState("grid"); + + const [viewingBatch, setViewingBatch] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const [selectedEvent, setSelectedEvent] = useState(null); + const [toast, setToast] = useState(null); + const [form, setForm] = useState({ + title: "", // batch.title + eventId: "", // batch.eventId._id (if exists) + eventName: "", // batch.eventId.title + org: "", // batch.eventId.organizing_unit_id.name + startDate: "", // batch.eventId.schedule.start + endDate: "", // batch.eventId.schedule.end + venue: "", // batch.eventId.schedule.venue + description: "", + templateId: "", // batch.templateId._id + signatoryDetails: [], //batch.signatoryDetails + students: [], // batch.users + }); + + const fire = (msg) => { + setToast(msg); + setTimeout(() => setToast(null), 2200); + }; + + useEffect(() => { + const fetchData = async () => { + try { + const [batches, events, templates] = await Promise.all([ + fetchBatches(isUserLoggedIn?._id), + fetchEvents(), + fetchTemplates(), + ]); + + if (!batches || !events || !templates) { + fire("Could not load data"); + return; + } + + //console.log("Batches is:", batches[0]); + + Array.isArray(batches) && setBatches(batches); + Array.isArray(events) && setEvents(events); + Array.isArray(templates) && setTemplates(templates); + } catch (err) { + fire("Failed to load data"); + } + }; + + fetchData(); + }, []); + + const filter = useMemo(() => { + const list = batches || []; + return { + statuses: [...new Set(list.map((b) => b.lifecycleStatus))], + approvalStatuses: [...new Set(list.map((b) => b.approvalStatus))], + creators: [...new Set(list.map((b) => b.initiatedBy.personal_info.name))], + organizations: [ + ...new Set(list.map((b) => b.eventId.organizing_unit_id.name)), + ], + }; + }, [batches]); + + const filtered = (batches || []).filter((b) => { + const batchOrg = b.eventId.organizing_unit_id.name; + const matchSearch = + !search || b.title.toLowerCase().includes(search?.toLowerCase()); + const matchOrg = org === "ALL" || batchOrg === org; + const matchStatus = status === "ALL" || b.lifecycleStatus === status; + const matchApprovalStatus = + approvalStatus === "ALL" || b.approvalStatus === approvalStatus; + const matchCreator = + creator === "ALL" || b.initiatedBy.personal_info.name === creator; + return ( + matchSearch && + matchOrg && + matchStatus && + matchApprovalStatus && + matchCreator + ); + }); + + const hasFilters = + org !== "ALL" || + status !== "ALL" || + approvalStatus !== "ALL" || + creator !== "ALL" || + search; + const clearAll = () => { + setOrg("ALL"); + setStatus("ALL"); + setCreator("ALL"); + setApprovalStatus("ALL"); + setSearch(""); + }; + + const openCreate = () => { + setEditing(null); + setViewingBatch(null); + setForm({ + title: "", // batch.title + eventId: "", // batch.eventId._id (if exists) + eventName: "", // batch.eventId.title + org: "", // batch.eventId.organizing_unit_id.name + startDate: "", // batch.eventId.schedule.start + endDate: "", // batch.eventId.schedule.end + venue: "", // batch.eventId.schedule.venue + description: "", + templateId: "", // batch.templateId._id + templateName: "", // batch.templateId.title + signatoryDetails: [], //batch.signatoryDetails + students: [], // batch.users + }); + setModalOpen(true); + }; + + const openEdit = useCallback((b) => { + if (b.lifecycleStatus !== "Draft") return; + if (!b.eventId || !b.templateId) { + fire("Batch data is incomplete"); + return; + } + + setEditing(b); + setViewingBatch(null); + setForm({ + title: b.title, + eventId: b.eventId, + eventName: b.eventId.title || "", + org: b.eventId.organizing_unit_id?.name || "", + startDate: b.eventId?.schedule?.start + ? new Date(b.eventId.schedule.start).toLocaleDateString("en-GB") + : "", + + endDate: b.eventId?.schedule?.end + ? new Date(b.eventId.schedule.end).toLocaleDateString("en-GB") + : "", + + description: b.eventId.description || "", + templateId: b.templateId._id || "", + signatoryDetails: b.signatoryDetails || [], + students: b.users || [], + }); + setModalOpen(true); + }, []); + + const openView = useCallback((batch) => { + setViewingBatch(batch); + setEditing(null); + setForm({ + title: batch.title, + eventId: batch.eventId._id, + eventName: batch.eventId.title, + org: batch.eventId.organizing_unit_id.name, + startDate: new Date(batch.eventId.schedule.start).toLocaleDateString( + "en-GB", + ), + endDate: new Date(batch.eventId.schedule.end).toLocaleDateString("en-GB"), + venue: batch.eventId.schedule.venue, + description: batch.eventId.description, + templateId: batch.templateId._id, + signatoryDetails: batch.signatoryDetails, + students: batch.users, + }); + setModalOpen(true); + }, []); + + const closeModal = useCallback(() => { + setModalOpen(false); + setViewingBatch(null); + setEditing(null); + }, []); + + // When user selects an event in the dropdown, populate Event Details from that event + const handleEventChange = useCallback( + (eventId) => { + const selected = events.find((ev) => ev._id === eventId); + setForm((f) => ({ + ...f, + eventId, + eventName: selected ? selected.title : "", + org: + selected && + selected.organizing_unit_id && + (selected.organizing_unit_id.name || ""), + startDate: + selected && selected.schedule && selected.schedule.start + ? new Date(selected.schedule.start).toLocaleDateString("en-GB") + : "", + endDate: + selected && selected.schedule && selected.schedule.end + ? new Date(selected.schedule.end).toLocaleDateString("en-GB") + : "", + description: selected && (selected.description || ""), + })); + setSelectedEvent(selected); + }, + [events], + ); + + const handleTemplateChange = useCallback( + (templateId) => { + const selected = templates.find((t) => t._id === templateId); + setForm((f) => ({ + ...f, + templateId: selected && (selected._id || "Unknown"), + })); + }, + [templates], + ); + + const getDifference = (editing, form) => { + const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); + for (let key in form) { + if (!isEqual(form[key], editing[key])) return true; + } + return false; + }; + + const saveBatch = useCallback( + async (action) => { + if ( + !form.title || + !form.eventId || + !form.templateId || + form.signatoryDetails.length === 0 || + form.students.length === 0 + ) { + fire("Please fill in all fields"); + return; + } + closeModal(); + try { + let response; + if (editing) { + if (action === "Draft") { + if (!getDifference(editing, form)) return; + response = await editBatch({ + ...form, + action: "Draft", + batchId: editing?._id, + }); + } else if (action === "Submitted") { + response = await editBatch({ + ...form, + action: "Submitted", + batchId: editing?._id, + }); + } + } else { + if (action === "Draft") { + response = await createBatch({ + ...form, + action: "Draft", + }); + } else if (action === "Submitted") { + response = await createBatch({ + ...form, + action: "Submitted", + }); + } + } + + response && fire(response); + const updated = await fetchBatches(isUserLoggedIn?._id); + if (updated) setBatches(updated); + } catch (err) { + console.error(err); + fire("Failed to " + action); + } + }, + [form], + ); + + const delBatch = useCallback(async (batch) => { + const response = await deleteBatch(batch._id); + response && fire(response); + const updated = await fetchBatches(isUserLoggedIn?._id); + if (updated && updated.length !== 0) setBatches(updated); + }, []); + + const archiveBatch = useCallback(async (id) => { + const response = await archiveBatchApi(id); + response && fire(response); + const updated = await fetchBatches(isUserLoggedIn?._id); + if (updated && updated.length !== 0) setBatches(updated); + }, []); + + const dupBatch = useCallback(async (b) => { + const response = await duplicateBatch(b?._id); + response && fire(response); + const updated = await fetchBatches(isUserLoggedIn?._id); + + if (updated && updated.length !== 0) setBatches(updated); + }, []); + + return ( +
+ + + {/* Toast */} + {toast && ( +
+ + {toast} +
+ )} + + {/*Header */} +
+ {/* view toggle */} +
+ + +
+ + {/* search */} +
+ + setSearch(e.target.value)} + placeholder="Search batches..." + className="w-full pl-8 pr-4 py-2 text-xs font-medium rounded-xl border border-yellow-100 bg-white text-gray-700 placeholder:text-gray-400 outline-none focus:border-yellow-300 focus:ring-2 focus:ring-yellow-100 transition-all" + /> + {search && ( + + )} +
+ + + + + + {hasFilters && ( + + )} + +
+ +
+
+ + {/* empty state */} + {filtered?.length === 0 && ( +
+ +

No batches found

+ {search || hasFilters ? ( +

+ Try adjusting your filters or search +

+ ) : ( +

+ No batches exist. Create one today +

+ )} +
+ )} + + {/* View grid */} + {view === "grid" && filtered?.length > 0 && ( +
+ {filtered.map((b) => ( + + ))} +
+ )} + + {/**List grid */} + {view === "list" && filtered?.length > 0 && ( + + )} + + saveBatch("Draft")} + submitBatch={() => saveBatch("Submitted")} + events={events} + templates={templates} + handleEventChange={handleEventChange} + handleTemplateChange={handleTemplateChange} + selectedEvent={selectedEvent} + /> +
+ ); +} diff --git a/frontend/src/Components/Batches/modalDialog.jsx b/frontend/src/Components/Batches/modalDialog.jsx new file mode 100644 index 00000000..43902887 --- /dev/null +++ b/frontend/src/Components/Batches/modalDialog.jsx @@ -0,0 +1,379 @@ +import { useState, useEffect } from "react"; +import { Modal, Field, Divider } from "./ui"; +import { fetchBatchUsers } from "../../services/batch"; +import { Users, ChevronDown, ChevronUp, Search, X, Plus } from "lucide-react"; +import StudentsPanel from "./studentPanel"; + +/* ─── tiny helpers ─────────────────────────────────────────── */ +const toId = (s) => + s && typeof s === "object" ? (s._id || s).toString() : String(s); + +const initials = (name = "") => + name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0]) + .join("") + .toUpperCase() || "?"; + +const DEFAULT_PIC = "https://www.gravatar.com/avatar/?d=mp"; + +/* ─── Avatar ────────────────────────────────────────────────── */ +export function Avatar({ student, size = 34 }) { + const name = student.personal_info?.name || ""; + const pic = student.personal_info?.profilePic; + const hasRealPic = pic && pic !== DEFAULT_PIC; + + return ( +
+ {hasRealPic ? ( + {name} + ) : ( + initials(name) + )} +
+ ); +} + +/* ─── Checkbox ──────────────────────────────────────────────── */ +export function Checkbox({ checked, disabled }) { + return ( +
+ {checked && ( + + + + )} +
+ ); +} + +/* ─── Main ModalDialog ──────────────────────────────────────── */ +export default function ModalDialog({ + modalOpen, + closeModal, + viewingBatch, + editing, + form, + setForm, + saveDraft, + submitBatch, + events = [], + templates = [], + handleEventChange, + handleTemplateChange, + selectedEvent, +}) { + const hc = (e) => + setForm((f) => ({ + ...f, + [e.target.name]: e.target.value, + })); + + const handleSignatoryChange = (index = -1, field, value) => { + setForm((prev) => { + if (index === -1) { + return { ...prev, signatoryDetails: [{ [field]: value }] }; + } + const updated = [...prev.signatoryDetails]; + updated[index] = { + ...updated[index], + [field]: value, + }; + + return { ...prev, signatoryDetails: updated }; + }); + }; + + const isViewOnly = viewingBatch && Object.keys(viewingBatch).length > 0; + + useEffect(() => { + if (form.signatoryDetails.length === 0) { + setForm((prev) => ({ + ...prev, + signatoryDetails: [{ name: "", role: "" }], + })); + } + }, []); + + const selectStyle = { + width: "100%", + boxSizing: "border-box", + border: "1.5px solid #e7e5e0", + borderRadius: 12, + padding: "9px 12px", + fontSize: 13, + color: "#1c1917", + background: "#fafaf5", + outline: "none", + fontFamily: "inherit", + }; + + const labelStyle = { + display: "block", + fontSize: 10, + fontWeight: 800, + textTransform: "uppercase", + letterSpacing: "0.1em", + color: "#1a3d15", + marginBottom: 5, + }; + + return ( + + + + + +
+ + +
+ + + +
+ + + +
+ + + + + +
+ + +
+ + {/* Signatory Details */} +
+ {form.signatoryDetails.map((sig, index) => { + return ( +
+ + handleSignatoryChange(index, "name", e.target.value) + } + placeholder="John Smith" + disabled={isViewOnly} + /> + + handleSignatoryChange(index, "role", e.target.value) + } + placeholder="Dean" + disabled={isViewOnly} + /> +
+ ); + })} + + {!isViewOnly && form.signatoryDetails.length < 3 && ( + + )} +
+ + + + + + + {/* ── modal footer buttons ── */} +
+ {!isViewOnly ? ( + <> + + +
+ + + + + + ) : ( + + )} +
+ + ); +} diff --git a/frontend/src/Components/Batches/select.jsx b/frontend/src/Components/Batches/select.jsx new file mode 100644 index 00000000..4653f1e3 --- /dev/null +++ b/frontend/src/Components/Batches/select.jsx @@ -0,0 +1,15 @@ +import {ChevronDown} from "lucide-react" +/* ── select dropdown ─────────────────────────────────────── */ +export const Select = ({ value, onChange, options = [], icon: Icon, placeholder }) => ( +
+ {Icon && } + + +
+ ); \ No newline at end of file diff --git a/frontend/src/Components/Batches/studentPanel.jsx b/frontend/src/Components/Batches/studentPanel.jsx new file mode 100644 index 00000000..e1001db0 --- /dev/null +++ b/frontend/src/Components/Batches/studentPanel.jsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from "react"; +import { fetchBatchUsers } from "../../services/batch"; +import { Users, ChevronDown, ChevronUp, Search, X } from "lucide-react"; +import { Avatar, Checkbox } from "../Batches/modalDialog"; +import { Modal } from "./ui"; +/* ─── tiny helpers ─────────────────────────────────────────── */ +const toId = (s) => s?._id; + +/* ─── StudentsPanel ─────────────────────────────────────────── */ +export default function StudentsPanel({ + form, + setForm, + isViewOnly, + selectedEvent, +}) { + const [open, setOpen] = useState(false); + const [details, setDetails] = useState([]); + const [loading, setLoading] = useState(false); + const [localSelected, setLocalSelected] = useState(new Set()); + const [search, setSearch] = useState(""); + let studentIds = form.students || []; + let count = studentIds?.length || 0; + /* reset when panel closes */ + const closePanel = () => { + setOpen(false); + setSearch(""); + }; + + /* fetch when opening */ + useEffect(() => { + if (!open) return; + if (count === 0) { + count = selectedEvent.participants?.length || 0; + if (count === 0) { + setDetails([]); + setLocalSelected(new Set()); + return; + } else studentIds = selectedEvent.participants || []; + } + + let cancelled = false; + setLoading(true); + + fetchBatchUsers(studentIds).then((data) => { + if (cancelled) return; + setDetails(Array.isArray(data) ? data : []); + setLocalSelected(new Set(studentIds)); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, selectedEvent]); + + const toggle = (id) => + setLocalSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const selectAll = () => setLocalSelected(new Set(details.map((s) => s?._id))); + + const deselectAll = () => setLocalSelected(new Set()); + + const saveSelection = () => { + setForm((f) => ({ ...f, students: Array.from(localSelected) })); + closePanel(); + }; + + const cancelSelection = () => { + setLocalSelected(new Set(studentIds)); + closePanel(); + }; + + const filtered = details.filter((s) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + s.personal_info?.name?.toLowerCase().includes(q) || + s._id?.toString().toLowerCase().includes(q) || + s.academic_info?.branch?.toLowerCase().includes(q) || + s.academic_info?.program?.toLowerCase().includes(q) + ); + }); + + const allSelected = + details.length > 0 && details.every((s) => localSelected.has(s?._id)); + + const pendingChanges = + open && + !isViewOnly && + (localSelected.size !== count || + !studentIds.every((id) => localSelected.has(id))); + return ( + <> + {/* ── trigger button ── */} + + + +
+
+ {/* search + bulk controls */} +
+ {/* search input */} +
+ + + setSearch(e.target.value)} + placeholder="Search by name, ID, branch, program" + className="w-full box-border border-[1.5px] border-[#e7e5e0] rounded-lg pl-6 pr-7 py-[5px] text-[11px] text-[#1c1917] bg-[#fafaf5] outline-none font-inherit" + /> + + {search && ( + + )} +
+ + {/* Select All / Deselect All */} + {!isViewOnly && details.length > 0 && ( + + )} +
+ + {/* student list */} +
+ {loading ? ( +
+ Loading participants… +
+ ) : filtered.length === 0 ? ( +
+ {count === 0 + ? "No participants added to this batch yet." + : "No results match your search."} +
+ ) : ( + filtered.map((student) => ( + + )) + )} +
+ + {/* panel footer */} +
+ {/* left: count summary */} + + {isViewOnly + ? `${count} participant${count !== 1 ? "s" : ""} in batch` + : `${localSelected.size} of ${details.length} selected`} + + + {isViewOnly ? ( + + ) : ( + <> + + + + + )} +
+
+
+
+ + ); +} + +/* ─── StudentCard ───────────────────────────────────────────── */ +function StudentCard({ student, selected, onToggle, disabled }) { + return ( +
onToggle(student?._id)} + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "8px 10px", + borderRadius: 12, + border: `1.5px solid ${selected ? "#bbf7d0" : "#e7e5e0"}`, + background: selected ? "#f0fdf4" : "#fafaf5", + cursor: disabled ? "default" : "pointer", + transition: "border-color 0.15s, background 0.15s", + userSelect: "none", + }} + > + + +
+
+ {student.personal_info?.name || "Unknown Student"} -{" "} + {student.username} +
+ + {(student.academic_info?.program || + student.academic_info?.branch || + student.academic_info.batch_year) && ( +
+ {[ + student.academic_info.program, + student.academic_info.branch, + student.academic_info.batch_year, + ] + .filter(Boolean) + .join(" · ")} +
+ )} +
+ + +
+ ); +} diff --git a/frontend/src/Components/Batches/ui.jsx b/frontend/src/Components/Batches/ui.jsx new file mode 100644 index 00000000..1d8bee25 --- /dev/null +++ b/frontend/src/Components/Batches/ui.jsx @@ -0,0 +1,298 @@ +const BATCH_COLORS = [ + { bg: "#f0fdf4", border: "#bbf7d0", pill: "#166534", pillBg: "#dcfce7" }, + { bg: "#fefce8", border: "#fde68a", pill: "#92400e", pillBg: "#fef3c7" }, + { bg: "#f0f9ff", border: "#bae6fd", pill: "#0c4a6e", pillBg: "#e0f2fe" }, + { bg: "#fdf4ff", border: "#e9d5ff", pill: "#581c87", pillBg: "#f3e8ff" }, + { bg: "#fff1f2", border: "#fecdd3", pill: "#881337", pillBg: "#ffe4e6" }, + { bg: "#f0fdfa", border: "#99f6e4", pill: "#134e4a", pillBg: "#ccfbf1" }, +]; + +const STATUS_MAP = { + Draft: { + label: "Draft", + dot: "#a8a29e", + bg: "#f5f5f4", + color: "#78716c", + border: "#e7e5e0", + }, + Active: { + label: "Active", + dot: "#f59e0b", + bg: "#fffbeb", + color: "#92400e", + border: "#fde68a", + }, + Submitted: { + label: "Submitted", + dot: "#22c55e", + bg: "#f0fdf4", + color: "#166534", + border: "#bbf7d0", + }, + Archived: { + label: "Archived", + dot: "#274582", + bg: "#86aaceac", + color: "#374151", + border: "#6f8ebe", + }, +}; + +function Brackets({ color }) { + const s = { position: "absolute", width: 12, height: 12 }; + return ( + <> + + + + ); +} + +export function StatusBadge({ status }) { + const s = STATUS_MAP[status] || STATUS_MAP.Draft; + return ( + + + {s.label} + + ); +} + +export function CertThumb({ batch }) { + const colorIndex = Math.floor(Math.random() * BATCH_COLORS.length); + const c = BATCH_COLORS[colorIndex % BATCH_COLORS.length]; + return ( +
+ + + {batch.title?.split("-").join("\n")} + +
+
+
+
+ ); +} + +export function Modal({ open, onClose, title, children }) { + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal container */} +
+ {/* Header */} +
+

{title}

+ + +
+ + {/* Content */} +
{children}
+
+
+ ); +} + +export function Field({ + label, + name, + value, + onChange, + type = "text", + placeholder = "", + disabled = false, +}) { + return ( +
+ + {type === "textarea" ? ( +