diff --git a/.husky/pre-commit b/.husky/pre-commit index 6c3b9637..64778a1e 100755 --- 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..6390b2bd --- /dev/null +++ b/backend/controllers/certificateBatchController.js @@ -0,0 +1,601 @@ +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"); +const {newBatchSendEmail, batchStatusSendEmail} = require("../services/email.service"); +const generateCertificates = require("../services/certificates.service"); + +async function createBatch(req, res) { + //console.log(req.user); + try { + let emailBatchObj = {}; + 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 approvers = [gensecObj, presidentObj]; + + const {approverBatchDetails, ccEmails} = approvers.reduce((acc, a)=>{ + const name = a.personal_info.name; + const email = a.personal_info.email; + acc.approverBatchDetails.push({name, email}); + acc.ccEmails.push(email); + return acc; + + }, { approverBatchDetails: [], ccEmails: []}); + + + const approverIds = [gensecObj._id, presidentObj._id]; + + // Validate user ids and existence (bulk query + duplicate detection) + const uniqueUsers = [...new Set(users.map((id) => id.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"); + const existingSet = new Set(existing.map((u) => u._id.toString())); + + const missing = uniqueUsers.filter((u) => !existingSet.has(u)); + + if(missing.length > 0){ + missing.map((uid) => ({ uid, ok: false, reason: "User not found" })); + 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, + }); + + + emailBatchObj = { + title: newBatch.title, + event: {name: event.title, description: event.description}, + createdBy: req.user.personal_info.name, + createdAt: new Date(newBatch.createdAt).toLocaleDateString("en-GB").replaceAll("/","-"), + approverList: approverBatchDetails + } + + const link = process.env.FRONTEND_URL; + await newBatchSendEmail(req.user.personal_info.email, ccEmails, link, emailBatchObj); + + res.json({ message: "New Batch created successfully" }); + + } catch (err) { + console.error(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); + + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + + Object.assign(batch, validation.data); + + 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; + 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("username personal_info academic_info "); + 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) + .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", + }); + } + + const array = batch.title.split("(Copy)"); + const count = array.length -1; + const title = `${array[0]} Copy(${count})`; + const newBatch = await CertificateBatch.create({ + ...batch, + title: title, + 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._id; + 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, + }).populate([ + { + path: "eventId", + select: "title description" + }, + { + path: "initiatedBy", + select: "personal_info" + }, + { + path: "approverIds", + select: "personal_info" + } + ]); + + 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) { + await generateCertificates(batch); + batch.currentApprovalLevel = 2; + batch.lifecycleStatus = "Active"; + batch.approvalStatus = "Approved"; + } + await batch.save(); + + const currentApprover = batch.approverIds.find((a) => a._id.toString() === id.toString())?.personal_info; + const {pendingApprovers, ccEmails} = batch.approverIds.reduce( + (acc, a) => { + if(a._id.toString() !== id.toString()){ + acc.pendingApprovers.push(a.personal_info); + acc.ccEmails.push(a.personal_info.email); + } + return acc; + }, + { pendingApprovers: [], ccEmails: []} + ); + const toEmail = currentApprover.email; + + const batchObj = { + title: batch.title, + event: {name: batch.eventId.title, description: batch.eventId.description}, + createdBy: batch.initiatedBy.personal_info.name, + createdAt: new Date(batch.createdAt).toLocaleDateString("en-GB"), + currentApprover: currentApprover, + approvalLevel: batch.currentApprovalLevel, + pendingApprovers: pendingApprovers, + } + + await batchStatusSendEmail(toEmail, ccEmails, process.env.FRONTEND_URL, batchObj, "approve"); + + 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(); + const currentApprover = batch.approverIds.find((a) => a._id.toString() === id.toString())?.personal_info; + const {ccEmails} = batch.approverIds.reduce( + (acc, a) => { + if(a._id.toString() !== id.toString()){ + acc.ccEmails.push(a.personal_info.email); + } + return acc; + }, + {ccEmails: []} + ); + const toEmail = currentApprover.email; + + const batchObj = { + title: batch.title, + event: {name: batch.eventId.title, description: batch.eventId.description}, + createdBy: batch.initiatedBy.personal_info.name, + createdAt: new Date(batch.createdAt).toLocaleDateString("en-GB"), + currentApprover: currentApprover, + approvalLevel: batch.currentApprovalLevel, + } + + await batchStatusSendEmail(toEmail, ccEmails, process.env.FRONTEND_URL, batchObj, "reject"); + 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 b1cc49c1..654cbcc4 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 roomBookingRoutes = require("./routes/roomBooking.js"); const app = express(); @@ -30,23 +34,31 @@ 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, }, + 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()); @@ -67,12 +79,24 @@ 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/rooms", roomBookingRoutes); 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..7bd00f1f --- /dev/null +++ b/backend/models/certificateSchema.js @@ -0,0 +1,168 @@ +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: ["Approved", "Rejected"], + }, + 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 05ebd00b..34562771 100644 --- a/backend/models/schema.js +++ b/backend/models/schema.js @@ -1,408 +1,6 @@ const mongoose = require("mongoose"); -const passportLocalMongoose = require("passport-local-mongoose"); -var findOrCreate = require("mongoose-findorcreate"); -const { v4: uuidv4 } = require("uuid"); -//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, @@ -474,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: { @@ -622,168 +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); -const roomSchema = new mongoose.Schema({ - room_id: { - type: String, - required: true, - unique: true, - default: () => `ROOM_${uuidv4()}`, - }, - name: { - type: String, - required: true, - unique: true, - }, - capacity: { - type: Number, - required: true, - }, - location: { - type: String, - required: true, - }, - amenities: [ - { - type: String, - }, - ], - allowed_roles: { - type: [ - { - type: String, - enum: [ - "PRESIDENT", - "GENSEC_SCITECH", - "GENSEC_ACADEMIC", - "GENSEC_CULTURAL", - "GENSEC_SPORTS", - "CLUB_COORDINATOR", - "STUDENT", - ], - }, - ], - default: [ - "PRESIDENT", - "GENSEC_SCITECH", - "GENSEC_ACADEMIC", - "GENSEC_CULTURAL", - "GENSEC_SPORTS", - "CLUB_COORDINATOR", - ], - }, - is_active: { - type: Boolean, - default: true, - }, - created_at: { - type: Date, - default: Date.now, - }, - updated_at: { - type: Date, - default: Date.now, - }, -}); - -const Room = mongoose.model("Room", roomSchema); - -const roomBookingSchema = new mongoose.Schema({ - room: { - type: mongoose.Schema.Types.ObjectId, - ref: "Room", - required: true, - }, - event: { - type: mongoose.Schema.Types.ObjectId, - ref: "Event", - }, - date: { - type: Date, - required: true, - }, - startTime: { - type: Date, - required: true, - }, - endTime: { - type: Date, - required: true, - validate: { - validator: function (value) { - if (!this.startTime || !value) { - return false; - } - return value > this.startTime; - }, - message: "endTime must be after startTime", - }, - }, - purpose: { - type: String, - }, - bookedBy: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - }, - status: { - type: String, - enum: ["Pending", "Approved", "Rejected", "Cancelled"], - default: "Pending", - }, - reviewedBy: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }, - created_at: { - type: Date, - default: Date.now, - }, - updated_at: { - type: Date, - default: Date.now, - }, -}); - -roomBookingSchema.index({ room: 1, date: 1, startTime: 1, endTime: 1 }); - -const RoomBooking = mongoose.model("RoomBooking", roomBookingSchema); - module.exports = { - User, - Feedback, - Achievement, UserSkill, Skill, - Event, - PositionHolder, - Position, - OrganizationalUnit, Announcement, - Room, - RoomBooking, }; 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 dec1d123..207ca82a 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", @@ -20,32 +22,31 @@ "express-rate-limit": "^7.5.1", "express-rate-limiter": "^1.3.1", "express-session": "^1.17.3", + "handlebars": "^4.7.9", "jsonwebtoken": "^9.0.2", "jwt-decode": "^3.1.2", "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", + "nodemailer": "^7.0.13", "nodemon": "^3.1.10", "npm": "^10.2.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", - "passport-local-mongoose": "^8.0.0", + "puppeteer": "^24.40.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" } }, @@ -785,6 +786,29 @@ "node": ">=18.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -969,6 +993,62 @@ "node": ">= 8" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/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/@puppeteer/browsers/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/@puppeteer/browsers/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", @@ -1590,6 +1670,18 @@ "node": ">=18.0.0" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -1614,6 +1706,16 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -1654,6 +1756,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1885,6 +1996,31 @@ "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/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/asynciterator.prototype": { "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -1926,12 +2062,117 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz", + "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.11.0.tgz", + "integrity": "sha512-Y/+iQ49fL3rIn6w/AVxI/2+BRrpmzJvdWt5Jv8Za6Ngqc6V227c+pYjYYgLdpR3MwQ9ObVXD0ZrqoBztakM0rw==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1979,6 +2220,29 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "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.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1991,6 +2255,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -2077,6 +2346,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2217,6 +2495,28 @@ "node": ">= 6" } }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2250,6 +2550,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/cloudinary": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.9.0.tgz", @@ -2330,6 +2690,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", @@ -2446,12 +2846,17 @@ } }, "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", "version": "1.4.7", "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "dev": true, "license": "MIT", "dependencies": { + "cookie": "0.7.2", "cookie": "0.7.2", "cookie-signature": "1.0.6" }, @@ -2460,6 +2865,10 @@ } }, "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", @@ -2492,9 +2901,35 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { @@ -2506,6 +2941,15 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2606,6 +3050,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2634,6 +3092,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1581282", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", + "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", + "license": "BSD-3-Clause" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2703,6 +3167,33 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2886,6 +3377,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2904,6 +3404,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -3162,6 +3683,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -3220,6 +3754,15 @@ "dev": true, "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -3357,12 +3900,76 @@ "node": ">= 0.6" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/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/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip/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/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3403,6 +4010,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3608,6 +4224,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } "node_modules/generaterr": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/generaterr/-/generaterr-1.5.0.tgz", @@ -3704,6 +4328,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/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/get-uri/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/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3786,6 +4447,27 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3893,6 +4575,78 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/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/http-proxy-agent/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/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/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/https-proxy-agent/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/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -4022,6 +4776,14 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -4057,6 +4819,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -4507,6 +5275,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4619,13 +5393,25 @@ "json-buffer": "3.0.1" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "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": { - "prelude-ls": "^1.2.1", + "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", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", "type-check": "~0.4.0" }, "engines": { @@ -4645,6 +5431,12 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/lint-staged": { "version": "15.5.2", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", @@ -5014,6 +5806,19 @@ "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", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -5027,6 +5832,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5354,6 +6170,41 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "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/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -5373,6 +6224,9 @@ } }, "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "version": "7.0.13", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", @@ -8081,6 +8935,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/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/pac-proxy-agent/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/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8093,16 +9002,22 @@ "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, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", "dependencies": { - "tokenizer": "*" + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" }, "engines": { - "node": "0.4-0.9" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parseurl": { @@ -8241,6 +9156,18 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -8300,6 +9227,15 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8324,6 +9260,57 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/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/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/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/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8336,6 +9323,16 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8345,6 +9342,79 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.40.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz", + "integrity": "sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1581282", + "puppeteer-core": "24.40.0", + "typed-query-selector": "^2.12.1" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.40.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.40.0.tgz", + "integrity": "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1581282", + "typed-query-selector": "^2.12.1", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/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/puppeteer-core/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/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -8500,6 +9570,15 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8987,19 +10066,71 @@ } }, "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { + "ip-address": "^10.0.1", "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { + "node": ">= 10.0.0", "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/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/socks-proxy-agent/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/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -9048,6 +10179,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9281,6 +10423,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9339,6 +10525,9 @@ } }, "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", @@ -9456,12 +10645,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -9565,6 +10773,12 @@ "node": ">= 0.8" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -9687,6 +10901,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9759,6 +10979,51 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, @@ -9778,6 +11043,72 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9789,6 +11120,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..4e1a611b 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", @@ -40,32 +42,31 @@ "express-rate-limit": "^7.5.1", "express-rate-limiter": "^1.3.1", "express-session": "^1.17.3", + "handlebars": "^4.7.9", "jsonwebtoken": "^9.0.2", "jwt-decode": "^3.1.2", "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", + "nodemailer": "^7.0.13", "nodemon": "^3.1.10", "npm": "^10.2.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", - "passport-local-mongoose": "^8.0.0", + "puppeteer": "^24.40.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..22c1ef26 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 {forgotPasswordSendEmail} = require("../services/email.service"); + +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 }); @@ -127,31 +219,8 @@ router.post("/forgot-password", forgotPasswordLimiter, async (req, res) => { expiresIn: "10m", }); const link = `${process.env.FRONTEND_URL}/reset-password/${user._id}/${token}`; - var transporter = nodemailer.createTransport({ - service: "gmail", - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS, - }, - }); - var mailOptions = { - from: process.env.EMAIL_USER, - to: email, - subject: "Password-Reset Request", - text: `To reset your password, click here: ${link}`, - }; - transporter.sendMail(mailOptions, function (error, info) { - if (error) { - console.log(error); - return res.status(500).json({ message: "Error sending email" }); - } else { - console.log("Email sent:", info.response); - return res - .status(200) - .json({ message: "Password reset link sent to your email" }); - } - }); - console.log(link); + await forgotPasswordSendEmail(email, link); + } catch (error) { console.log(error); return res.status(500).json({ message: "Internal server error" }); @@ -160,14 +229,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..fcc08e0c --- /dev/null +++ b/backend/routes/template.js @@ -0,0 +1,20 @@ +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().populate({ + path: "createdBy", + select: "personal_info " + }); + 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 2faff674..5832064a 100644 --- a/backend/seed.js +++ b/backend/seed.js @@ -1,55 +1,255 @@ require("dotenv").config(); const mongoose = require("mongoose"); -const { - User, - Feedback, - Achievement, - UserSkill, - Skill, - Event, - RoomBooking, - PositionHolder, - Position, - OrganizationalUnit, - Room, -} = require("./models/schema"); - -// Sample Rooms for Seeding -const sampleRooms = [ +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 = [ { - name: "LH-101", - capacity: 60, - location: "Academic Block 1, Ground Floor", - amenities: ["Projector", "AC", "Whiteboard"], + 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: [] }, }, { - name: "LH-102", - capacity: 60, - location: "Academic Block 1, Ground Floor", - amenities: ["Projector", "AC"], + 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", + }, + ], + }, }, { - name: "Seminar Hall", - capacity: 120, - location: "Admin Block, 1st Floor", - amenities: ["Projector", "Sound System", "AC"], + 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" }, + ], + }, }, -]; - -// Seeds sample rooms for testing room booking features. - -const seedRooms = async () => { - console.log("Seeding sample rooms..."); - await Room.deleteMany({}); - await Room.insertMany(sampleRooms); - console.log("Sample rooms seeded!"); -}; - -// --- Data for Seeding --- - -// Original club/committee data. -const initialUnitsData = [ { unit_id: "CLUB_OPENLAKE", name: "OpenLake", @@ -291,8 +491,6 @@ const initialUnitsData = [ */ const clearData = async () => { console.log("Clearing existing data..."); - await RoomBooking.deleteMany({}); - await Room.deleteMany({}); await OrganizationalUnit.deleteMany({}); await Position.deleteMany({}); await User.deleteMany({}); @@ -302,6 +500,17 @@ const clearData = async () => { 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!"); }; @@ -310,9 +519,10 @@ const clearData = async () => { */ const seedOrganizationalUnits = async () => { console.log("Seeding Organizational Units..."); + console.log("Seeding Organizational Units..."); - // 1. Create the top-level President and Test President units - const presidentUnit = new OrganizationalUnit({ + // 1. Create the top-level President unit + const presidentUnit = await OrganizationalUnit.create({ unit_id: "PRESIDENT_GYMKHANA", name: "President, Student Gymkhana", type: "independent_position", @@ -326,25 +536,8 @@ const seedOrganizationalUnits = async () => { 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 = [ { @@ -402,159 +595,97 @@ const seedOrganizationalUnits = async () => { ]; 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, - }); - }); - await OrganizationalUnit.insertMany(linkedUnitsData); - console.log("Seeded and linked initial clubs and committees."); - - // 4. Create and link the test councils and clubs - const testCouncilsData = [ + // 2. Create the main councils (Gensecs) and link them to the President + const mainCouncilsData = [ { - unit_id: "COUNCIL_CULTURAL_TEST", - name: "Test Cultural Council", + unit_id: "COUNCIL_CULTURAL", + name: "Cultural Council", type: "Council", - description: "Test council for cultural activities.", + description: "Council for all cultural activities.", hierarchy_level: 1, category: "cultural", contact_info: { - email: "test_gensec_cult@iitbhilai.ac.in", + email: "gensec_cultural_gymkhana@iitbhilai.ac.in", social_media: [], }, parent_unit_id: presidentUnit._id, }, { - unit_id: "COUNCIL_SCITECH_TEST", - name: "Test SciTech Council", + unit_id: "COUNCIL_SCITECH", + name: "Science and Technology Council", type: "Council", - description: "Test council for scitech activities.", + description: "Council for all science and technology 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", + email: "gensec_scitech_gymkhana@iitbhilai.ac.in", social_media: [], }, parent_unit_id: presidentUnit._id, }, { - unit_id: "COUNCIL_ACAD_TEST", - name: "Test Academic Council", + unit_id: "COUNCIL_SPORTS", + name: "Sports 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, + description: "Council for all sports activities.", + hierarchy_level: 1, category: "sports", contact_info: { - email: "test_sports_club@iitbhilai.ac.in", + email: "gensec_sports_gymkhana@iitbhilai.ac.in", social_media: [], }, - parent_unit_id: testCouncilMap.sports, + parent_unit_id: presidentUnit._id, }, { - unit_id: "CLUB_ACAD_TEST", - name: "Test Academic Club", - type: "Club", - description: "A test club for academic events.", - hierarchy_level: 2, + unit_id: "COUNCIL_ACADEMIC", + name: "Academic Affairs Council", + type: "Council", + description: "Council for all academic affairs.", + hierarchy_level: 1, category: "academic", contact_info: { - email: "test_acad_club@iitbhilai.ac.in", + email: "gensec_academic_gymkhana@iitbhilai.ac.in", social_media: [], }, - parent_unit_id: testCouncilMap.academic, + parent_unit_id: presidentUnit._id, }, ]; - await OrganizationalUnit.insertMany(testClubsData); - console.log("Seeded and linked Test Clubs."); + 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."); 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 = []; + let localUserCount = 0; const googleAuthUsers = []; - const password = "password"; + const password = "password123"; + const branches = ["CSE", "EE"]; + const batchYears = ["2026", "2027"]; + for (const unit of units) { + let role; for (const unit of units) { let role; @@ -576,39 +707,62 @@ const seedUsers = async () => { } else { role = "STUDENT"; } + 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 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)), + }; - const userData = { + await User.create({ username: unit.contact_info.email, + password: password, role: role, + strategy: "local", onboardingComplete: true, personal_info: { - name: unit.name, + name: `Student${localUserCount + 1}`, email: unit.contact_info.email, }, - }; + academic_info: academic_info, + }); - if (unit.unit_id.includes("_TEST") || unit.name.includes("Test")) { - userData.strategy = "local"; - localAuthUsers.push(userData); - } else { - userData.strategy = "google"; - googleAuthUsers.push(userData); - } + localUserCount++; } + console.log(`Seeded ${localUserCount} local auth users.`); - // Add 10 dummy student users with local auth and correct email domain + // Add 10 dummy student users with google auth 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)], @@ -617,153 +771,325 @@ const seedUsers = async () => { cgpa: parseFloat((Math.random() * (10.0 - 6.0) + 6.0).toFixed(2)), }; - localAuthUsers.push({ + googleAuthUsers.push({ username: userEmail, role: "STUDENT", - strategy: "local", + strategy: "google", onboardingComplete: true, - personal_info: { name: `Demo Student ${i}`, email: userEmail }, - academic_info: academic_info, // Add academic info to user data + personal_info: { name: `Student${i}`, email: userEmail }, + academic_info: academic_info, }); } - // Create Google auth users (no password needed) if (googleAuthUsers.length > 0) { await User.insertMany(googleAuthUsers); console.log(`Created ${googleAuthUsers.length} Google auth users.`); } - - // 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.`, - ); - 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/ }], - }); + console.log("Seeding Positions..."); + const units = await OrganizationalUnit.find({}); - const positionsToCreate = []; + // Valid Gensec titles as defined by the Position schema enum + const validGensecTitles = new Set([ + "GENSEC_CULTURAL", + "GENSEC_SCITECH", + "GENSEC_SPORTS", + "GENSEC_ACADEMIC", + ]); - 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" }, - ]; + const positionsToCreate = []; + const positionsToCreate = []; - 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); + 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 { + // e.g. "independent_position" type — no matching enum value, skip + continue; } + + 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 for test units.`, - ); + console.log(`Created ${positionsToCreate.length} positions.`); } else { - console.log("No test units found to create positions for."); + console.log("No valid positions to create."); } console.log("Positions seeded successfully!"); + 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", + ]; - const students = await User.find({ role: "STUDENT", strategy: "local" }); - const testClubs = await OrganizationalUnit.find({ - type: "Club", - name: /Test/, + // Only auto-generated users (their username = their unit's contact email) + const roleUsers = await User.find({ + strategy: "local", + role: { $in: namedRoles }, }); - const testPositions = await Position.find({}).populate("unit_id"); - if (students.length === 0) { - console.log("No student users found to assign positions to."); + if (roleUsers.length === 0) { + console.log("No role users found. Skipping position holders."); return; } const positionHoldersToCreate = []; - let studentIndex = 0; - for (const club of testClubs) { - if (studentIndex >= students.length) { - break; - } + 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; - 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"); + // 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", + }); + } - 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, + if (positionHoldersToCreate.length > 0) { + await PositionHolder.insertMany(positionHoldersToCreate); + console.log(`Created ${positionHoldersToCreate.length} position holders.`); + } else { + console.log("Could not create any position holders."); + } + + 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"); } + } + + // -- 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, - 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", - }); - } + 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 { - console.log("Could not create any position holders."); + const gensecPosition = await Position.findOne({ + title: "GENSEC_CULTURAL", + unit_id: culturalCouncil._id, + }); + if (!gensecPosition) { + console.log(" GENSEC_CULTURAL position not found - skipping."); + } else { + 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!"); }; /** @@ -871,29 +1197,32 @@ const seedSkills = async () => { }, ]; await Skill.insertMany(skillsData); - console.log(`Created ${skillsData.length} skills.`); + 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" }); + 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."); + console.log("No skills or students found. Skipping user skills."); return; } + const userSkillsToCreate = []; + const proficiencyLevels = ["beginner", "intermediate", "advanced", "expert"]; 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); + 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({ @@ -911,47 +1240,52 @@ const seedUserSkills = async () => { if (userSkillsToCreate.length > 0) { await UserSkill.insertMany(userSkillsToCreate); console.log( - `Assigned ${userSkillsToCreate.length} skills across ${students.length} students.`, + "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."); + if (clubs.length === 0 || students.length === 0) { + console.log("No clubs or students found. Skipping events."); return; } + const eventsToCreate = []; + const now = new Date(); const eventsToCreate = []; const now = new Date(); - for (const club of testClubs) { - const eventCategory = - club.category === "scitech" ? "technical" : club.category; + for (const club of clubs) { + const clubCategoryMap = { scitech: "technical", independent: "other" }; + const eventCategory = clubCategoryMap[club.category] || club.category; - // --- Completed Event --- - const completedEventParticipants = [...students] + // Completed event + const participants = [...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.`, + 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, @@ -962,18 +1296,15 @@ const seedEvents = async () => { mode: "offline", }, status: "completed", - participants: completedEventParticipants.map((p) => p._id), - winners: [ - { user: completedEventParticipants[0]._id, position: "1st Place" }, - ], - }; - eventsToCreate.push(completedEvent); + participants: participants.map((p) => p._id), + winners: [{ user: participants[0]._id, position: "1st Place" }], + }); - // --- 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!`, + // 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, @@ -991,46 +1322,52 @@ const seedEvents = async () => { max_participants: 50, }, status: "planned", - }; - eventsToCreate.push(plannedEvent); + }); } if (eventsToCreate.length > 0) { await Event.insertMany(eventsToCreate); - console.log(`Created ${eventsToCreate.length} dummy events.`); + 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({ + const completedEvents = await Event.find({ status: "completed", winners: { $exists: true, $not: { $size: 0 } }, }).populate("organizing_unit_id"); - if (completedEventsWithWinners.length === 0) { + if (completedEvents.length === 0) { console.log( - "No completed events with winners found to create achievements from.", + "No completed events with winners found. Skipping achievements.", ); return; } const achievementsToCreate = []; - for (const event of completedEventsWithWinners) { + + for (const event of completedEvents) { for (const winner of event.winners) { const achievementCategory = event.category === "technical" ? "scitech" : event.category; - const achievementData = { - achievement_id: `ACH_${event.event_id}_${winner.user}`, + 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}.`, + 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", @@ -1038,87 +1375,347 @@ const seedAchievements = async () => { position: winner.position, event_id: event._id, verified: true, - }; - achievementsToCreate.push(achievementData); + }); } } if (achievementsToCreate.length > 0) { await Achievement.insertMany(achievementsToCreate); - console.log( - `Created ${achievementsToCreate.length} achievements for event winners.`, - ); + 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", strategy: "local" }); + const students = await User.find({ role: "STUDENT" }); const completedEvents = await Event.find({ status: "completed" }); - const testClubs = await OrganizationalUnit.find({ - type: "Club", - name: /Test/, - }); + const clubs = await OrganizationalUnit.find({ type: "Club" }); if ( students.length < 2 || completedEvents.length === 0 || - testClubs.length === 0 + clubs.length === 0 ) { - console.log("Not enough data to create meaningful feedback."); + console.log("Not enough data for feedback. Skipping."); return; } const feedbacksToCreate = []; - - // 1. Create feedback for an event const eventToReview = completedEvents[0]; - const studentReviewer1 = students[0]; - const studentReviewer2 = students[1]; + const reviewer1 = students[0]; + const reviewer2 = students[1]; feedbacksToCreate.push({ - feedback_id: `FDB_EVT_${eventToReview._id}_${studentReviewer1._id}`, + feedback_id: "FDB_EVT_" + eventToReview._id + "_" + reviewer1._id, type: "Event Feedback", target_id: eventToReview._id, target_type: "Event", - feedback_by: studentReviewer1._id, + 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}_${studentReviewer2._id}`, + feedback_id: "FDB_EVT_" + eventToReview._id + "_" + reviewer2._id, type: "Event Feedback", target_id: eventToReview._id, target_type: "Event", - feedback_by: studentReviewer2._id, + feedback_by: reviewer2._id, rating: 4, comments: "Good event, but the timings could have been better.", is_anonymous: true, }); - // 2. Create feedback for an organizational unit - const clubToReview = testClubs[0]; + const clubToReview = clubs[0]; feedbacksToCreate.push({ - feedback_id: `FDB_OU_${clubToReview._id}_${studentReviewer1._id}`, + feedback_id: "FDB_OU_" + clubToReview._id + "_" + reviewer1._id, type: "Unit Feedback", target_id: clubToReview._id, target_type: "Organizational_Unit", - feedback_by: studentReviewer1._id, + feedback_by: reviewer1._id, rating: 4, - comments: `The ${clubToReview.name} is doing a great job this semester.`, + 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("Created " + feedbacksToCreate.length + " feedback entries."); + } + console.log("Feedback seeded successfully!"); +}; + +/** + * 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; + } + + 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; + + 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", + }, + ]; + + 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!"); }; /** @@ -1139,19 +1736,25 @@ 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."); } } } diff --git a/backend/services/certificates.service.js b/backend/services/certificates.service.js new file mode 100644 index 00000000..8d6a0725 --- /dev/null +++ b/backend/services/certificates.service.js @@ -0,0 +1,45 @@ +const User = require("../models/userSchema"); +const renderToPdf = require("../utils/renderPdf"); +const uploadTocloudinary = require("../utils/cloudinary"); +const {Certificate} = require("../models/certificateSchema"); + +async function generateCertificates(batch){ + try{ + /** + * 1. Render PDF + * 2. use cloudinary to host the files + * 3. create certifcate docs + */ + + const users = await User.find({_id: {$in: batch.users}}).select("personal_info"); + + for(let user of users){ + const data = { + certificateType: 'Certificate of Participation', + certificateTitle: 'Certificate of Achievement', + recipientName: user.personal_info.name, + description: "in recognition of outstanding participation in the Innovation Club, demonstrating exceptional commitment, collaboration, and leadership", + certificateId: user._id, + issueDate: Date.now().toLocaleString("en-GB").replaceAll("/", "-"), + signatories: batch.signatoryDetails + } + + const pdfId = await renderToPdf(data); + const url = await uploadTocloudinary(pdfId); + + await Certificate.create({ + batchId: batch._id, + userId: user._id, + certificateUrl: url, + certificateId: pdfId, + status: "Approved" + }); + + } + }catch(err){ + console.log(err); + throw err; + } +} + +module.exports = generateCertificates \ No newline at end of file diff --git a/backend/services/email.service.js b/backend/services/email.service.js new file mode 100644 index 00000000..3d37750f --- /dev/null +++ b/backend/services/email.service.js @@ -0,0 +1,273 @@ +const nodemailer = require("nodemailer"); + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + +const transport = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASSWORD + } +}) + +async function forgotPasswordSendEmail(email, link){ + const options = { + from: `COSA Support Team <${process.env.EMAIL_USER}>`, + to: email, + subject: "Password Reset Request – Action Required", + html: ` +
+

Password Reset Request

+

Hello,

+

We received a request to reset your password.

+

+ Click the button below to set a new password. This link will expire in + 10 minutes. +

+

+ + Reset Password + +

+

If you did not request this, you can safely ignore this email.

+
+

+ This is an automated message. Please do not reply. +

+
+ ` + } + + try { + await transport.sendMail(options); + } catch(err) { + throw new Error(`Error sending email: ${err.message}`); + } +} + + +async function newBatchSendEmail(toEmail, ccEmails=[], batchLink, batchObj){ + const approverList = (batchObj.approverList || []).map((a, index) => ` +
+ Approver ${index + 1}:
+ Name: ${a.name}
+ Email: ${a.email} +
+
+ `).join(""); + + const options = { + from: `COSA Support Team <${process.env.EMAIL_USER}>`, + to: toEmail, + cc: ccEmails.join(","), + subject: "Batch Created Successfully – Action Required from Approvers", + html: ` +
+ +

New Batch Created

+ +

Hello,

+ +

+ A new batch has been created by the club coordinator. Please find the details below: +

+ +

+ Batch Name: ${batchObj.title}
+ Event Name: ${batchObj.event.name}
+ Description: ${batchObj.event.description || "N/A"} + Created By: ${batchObj.createdBy}
+ Created At: ${batchObj.createdAt}
+

+ +

+ Approvers Assigned:
+

+ ${approverList} + + +

+ + View Batch Details + +

+ +

+ Note for Approvers: Please review and take the necessary action on this batch. +

+ +

+ If you have any questions, please contact the coordinator. +

+ +
+ +

+ This is an automated notification. Please do not reply. +

+ +
+ ` + } + + try{ + await transport.sendMail(options); + }catch(err){ + throw new Error(`Error sending email: ${err.message}`); + } +} + +async function batchStatusSendEmail(toEmail, ccEmails, batchLink, batchObj, action){ + + const approverList = batchObj.pendingApprovers.map((a, index) => ` +
+ Approver ${index + 1}:
+ Name: ${a.name}
+ Email: ${a.email} +
+
+ `).join(""); + + const Emailformat = { + "approve": { + html: `
+ +

Batch Approved

+ +

Hello,

+ +

+ ${batchObj.currentApprover.name} has approved the batch + ${batchObj.title} at Level ${batchObj.approvalLevel}. +

+ +

+ Batch Details:
+ Batch Name: ${batchObj.title}
+ Event Name: ${batchObj.event.name} + Description: ${batchObj.event.description} + Created By: ${batchObj.createdBy}
+ CreatedAt: ${batchObj.createdAt}
+ +

+ +

+ + View Batch Details + +

+ +

+ Pending Approval from:
+ ${approverList} +

+ +
+ +

+ This is an automated notification. Please do not reply. +

+ +
`, + subject: "Batch Approved" + }, + "reject": { + subject: "Batch Rejected – Notification", + html: `
+ +

Batch Rejected

+ +

Hello,

+ +

+ The batch ${batchObj.title} has been rejected by + ${batchObj.currentApprover.name} at Level ${batchObj.approvalLevel}. +

+ +

+ Batch Details:
+ Batch Name: ${batchObj.title}
+ Event Name: ${batchObj.event.name} + Description: ${batchObj.event.description} + Created By: ${batchObj.createdBy}
+ CreatedAt: ${batchObj.createdAt}
+ +

+ +

+ + View Batch Details + +

+ +

+ No further approvals will be processed for this batch. Please contact the approver if you need more information. +

+ +
+ +

+ This is an automated notification. Please do not reply. +

+ +
` + } + } + + const options = { + from: `COSA Support Team <${process.env.EMAIL_USER}>`, + to: toEmail, + cc: ccEmails.join(","), + subject: Emailformat[action].subject, + html: Emailformat[action].html, + } + + try{ + if(!["approve", "reject"].includes(action)){ + throw new Error("Invalid action"); + } + await transport.sendMail(options); + }catch(err){ + throw new Error(err); + } + +} + +module.exports = { + forgotPasswordSendEmail, + newBatchSendEmail, + batchStatusSendEmail +}; + 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/template-designs/certificate.hbs b/backend/template-designs/certificate.hbs new file mode 100644 index 00000000..d296e546 --- /dev/null +++ b/backend/template-designs/certificate.hbs @@ -0,0 +1,401 @@ + + + + + + {{certificateTitle}} – {{recipientName}} + + + +
+ + +
+ + +
+
+ + +
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+ + +
+
+ + +
+
+ + +
+
Certificate No. {{certificateId}}
+
{{issueDate}}
+
+ + +
+
+ COSA +
+
Council of Student Affairs, IIT Bhilai
+
+ + +
+
{{certificateType}}
+
{{certificateTitle}}
+ +
+
+
+
+
+ +
This certificate is proudly presented to
+
{{recipientName}}
+
+
{{description}}
+
+ + + + +
+ + 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..7db98472 --- /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 should be atleast 5 characters"), + 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/cloudinary.js b/backend/utils/cloudinary.js new file mode 100644 index 00000000..cb9832a6 --- /dev/null +++ b/backend/utils/cloudinary.js @@ -0,0 +1,34 @@ +const path = require("path"); +require("dotenv").config({path: path.resolve(__dirname, "../.env")}); +const cloudinary = require("cloudinary").v2; +const fs = require("fs"); + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET +}) + +async function uploadTocloudinary(pdfId){ + try{ + const pdfPath = path.join(__dirname, "../uploads", `${pdfId}.pdf`); + const result = await cloudinary.uploader.upload(pdfPath, { + resource_type: "raw", + //this will store certs in folder certificates on cloudinary + folder: "certificates" + }); + + fs.unlink(pdfPath, (err)=>{ + if(err) throw new Error(err); + console.log("File unlinked successfully"); + return ; + }) + + return result.secure_url; + }catch(err){ + console.log(err); + throw err; + } +} + +module.exports = uploadTocloudinary; \ No newline at end of file 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/backend/utils/renderPdf.js b/backend/utils/renderPdf.js new file mode 100644 index 00000000..204f6970 --- /dev/null +++ b/backend/utils/renderPdf.js @@ -0,0 +1,39 @@ +const puppeteer = require("puppeteer"); +const handlebars = require("handlebars"); +const fs = require("fs"); +const path = require("path"); + +async function renderToPdf(data){ + try{ + const browser = await puppeteer.launch({headless: true}); + const page = await browser.newPage(); + + const filePath = path.join(__dirname, "../template-designs/certificate.hbs"); + const template = fs.readFileSync(filePath,"utf-8", (err, data)=>{ + if(err){ + throw new Error("Error reading files", err.message); + } + return data; + }) + const html = handlebars.compile(template)(data); + await page.setContent(html, {waitUntil: "networkidle0"}); + const userId = `${data.recipientName}_${data.certificateId}`; + + const outputPath = path.join(__dirname, "../uploads", `${userId}.pdf`); + await page.pdf({ + path: outputPath, + width: "1122px", + height: "794px", + printBackground: true, + margin: { top: 0, right: 0, bottom: 0, left: 0 } + }) + + await browser.close(); + console.log("Certificate successfully generated at", outputPath); + return userId; + }catch(err){ + throw new Error(err); + } +} + +module.exports = renderToPdf; \ No newline at end of file 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", + "Students", + "Batch Status", + "Approval 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 */} + + + {/* Students */} + + + {/* Batch Status */} + + + {/* Approval Status */} + + + {/* Created By */} + + + {/* Modified */} + + + {/* Actions */} + + + ); + })} + +
+ {h} +
+
+

+ {b.title} +

+
+
+
+

+ {b.eventId.organizing_unit_id.name} +

+
+
+
+

+ {b.users.length} +

+
+
+
+ +
+
+
+ +
+
+
+

+ {b.initiatedBy.personal_info.name} +

+
+
+
+

+ {new Date(b.updatedAt).toLocaleDateString("en-GB").replaceAll("/", "-")} +

+
+
+
+ {b.lifecycleStatus === "Draft" && ( + <> + onEdit(b)} + /> + onDelete(b)} + /> + + )} + + {b.lifecycleStatus === "Active" && ( + <> + onView(b)} + /> + onEdit(b)} + /> + onDuplicate(b)} + /> + onDelete(b)} + /> + + )} + {b.lifecycleStatus === "Submitted" && ( + <> + onView(b)} + /> + onArchive(b._id)} + /> + + )} + {b.lifecycleStatus === "Archived" && ( + <> + onView(b)} + /> + onDuplicate(b)} + /> + onDelete(b)} + /> + + )} +
+
+
+ ); +} diff --git a/frontend/src/Components/Batches/batches.jsx b/frontend/src/Components/Batches/batches.jsx new file mode 100644 index 00000000..7e843301 --- /dev/null +++ b/frontend/src/Components/Batches/batches.jsx @@ -0,0 +1,554 @@ +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; + } + + 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", + }); + } + } + + if(response.status !== 200) return ; + closeModal(); + fire(response.data); + 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 && ( + + )} + + {/** Create */} +
+ +
+
+ + {/* 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..42b718cd --- /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 || !selectedEvent) return; + if (count === 0) { + count = Object.keys(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..c7ede64e --- /dev/null +++ b/frontend/src/Components/Batches/ui.jsx @@ -0,0 +1,321 @@ +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 BATCH_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", + }, +}; + +const APPROVAL_STATUS_MAP = { + Pending: { + label: "Pending", + dot: "#f59e0b", + bg: "#fffbeb", + color: "#92400e", + border: "#fde68a", + }, + Approved: { + label: "Approved", + dot: "#22c55e", + bg: "#f0fdf4", + color: "#166534", + border: "#bbf7d0", + }, + Rejected: { + label: "Rejected", + dot: "#ef4444", + bg: "#fef2f2", + color: "#991b1b", + border: "#fecaca", + }, + NA: { + label: "N/A", + dot: "#a8a29e", + bg: "#f5f5f4", + color: "#78716c", + border: "#e7e5e0", + }, +}; + +function Brackets({ color }) { + const s = { position: "absolute", width: 12, height: 12 }; + return ( + <> + + + + ); +} + +export function BatchStatusBadge({ status }) { + const s = BATCH_STATUS_MAP[status] || BATCH_STATUS_MAP.Draft; + return ( + + + {s.label} + + ); +} + +export function ApprovalStatusBadge({ status }) { + const s = APPROVAL_STATUS_MAP[status] || APPROVAL_STATUS_MAP["NA"]; + 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" ? ( +