diff --git a/.gitignore b/.gitignore index 85d4ddf3..d042671c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ backend/.env node_modules .history -edit.md \ No newline at end of file +edit.md +backend/uploads/ diff --git a/backend/controllers/certificateBatchController.js b/backend/controllers/certificateBatchController.js new file mode 100644 index 00000000..f6f7fab4 --- /dev/null +++ b/backend/controllers/certificateBatchController.js @@ -0,0 +1,685 @@ +const { User } = require("../models/schema"); +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 { getApprovers } = require("../services/user.service"); +const { + getOrganization, + getCoordinatorOrganization, +} = require("../services/organization.service"); +const { HttpError } = require("../utils/httpError"); +const generateCertificates = require("../services/certificates.service"); + +async function createBatch(req, res) { + try { + const { id } = req.user; + + 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 organization + const club = await getCoordinatorOrganization(req.user); + + const eventOrgId = event.organizing_unit_id.toString(); + const clubOrgId = club._id.toString(); + + if (eventOrgId !== clubOrgId) { + return res.status(403).json({ + message: "You are not authorized to initiate batches.", + }); + } + + 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's domain. + // approverIds MUST be stored in this exact order: [GENSEC, PRESIDENT]. + // currentApprovalLevel indexes directly into this array (0 = GENSEC's + // turn, 1 = President's turn), so this order is load-bearing for the + // entire approval workflow. Do not reorder without also updating + // approveBatch/rejectBatch below. + const { gensecObj, presidentObj } = await getApprovers(club.category); + + if (!gensecObj || !presidentObj) { + return res.status(500).json({ + message: + "Could not resolve GENSEC/President for this club's domain. Please contact an admin.", + }); + } + + // Order is significant: index 0 = GENSEC, index 1 = President. + 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", + currentApprovalLevel: 0, + users: users, + signatoryDetails, + }); + + try{ + // Email sending is disabled project-wide for now. Do not re-enable here. + // await newBatchSendEmail(req.user.personal_info.email, ccEmails, link, emailBatchObj); + } catch(err){ + console.error("Email sending failed", err.message); + } + + 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) => + typeof user === "string" + ? user + : user?._id +).filter(Boolean); + 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); + + batch.lifecycleStatus = action; + +if (action === "Submitted") { + batch.approvalStatus = "Pending"; + batch.currentApprovalLevel = 0; +} + // NOTE: currentApprovalLevel is intentionally left untouched here. + // See "Edge cases" notes below regarding resubmission after a rejection. + 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.toObject(), + title: title, + lifecycleStatus: "Draft", + currentApprovalLevel: 0, + approvalStatus: undefined, + }); + + 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", + }, + { + path: "approverIds", + select: "personal_info", + }, +]); + if (!batches || batches.length === 0) { + return res.status(200).json({ + message: batches, + info: batches.length === 0 + ? "No batches found" + : undefined, + }); + } + + 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" }); +} + +/** + * Approve a batch. + * + * FIX (Problem 1/2/3/4): membership in approverIds is NOT sufficient to + * approve. The caller must be the approver AT THE CURRENT LEVEL + * (batch.approverIds[batch.currentApprovalLevel]). The state transition is + * also performed as a single atomic findOneAndUpdate keyed on the current + * level, so two concurrent/duplicate requests can't both succeed and + * double-generate certificates. + */ +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.findById(batchId); + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + + // Prevent approving an already approved / non-submitted batch + if ( + batch.approvalStatus === "Approved" || + batch.lifecycleStatus === "Active" + ) { + return res.status(400).json({ + message: "Batch has already been approved.", + }); + } + + if (batch.lifecycleStatus !== "Submitted") { + return res.status(400).json({ + message: "Batch is not currently awaiting approval.", + }); + } + + const level = batch.currentApprovalLevel; + + if (level !== 0 && level !== 1) { + return res.status(400).json({ + message: "Batch is not awaiting approval at this stage.", + }); + } + + // Whose turn is it, really? (index 0 = GENSEC, index 1 = President) + const expectedApproverId = batch.approverIds[level]; + if (!expectedApproverId || expectedApproverId.toString() !== id.toString()) { + return res.status(403).json({ + message: "It is not your turn to approve this batch yet.", + }); + } + + // Atomic transition: the match query re-verifies both the level AND + // that this exact user occupies that array position, at write time. + // If another request already advanced the level, this update matches + // nothing and updatedBatch comes back null. + const matchQuery = { + _id: batchId, + currentApprovalLevel: level, + }; + matchQuery[`approverIds.${level}`] = id; + + let update; + if (level === 0) { + // GENSEC approval -> hand off to President + update = { + currentApprovalLevel: 1, + lifecycleStatus: "Submitted", + approvalStatus: "Pending", + }; + } else { + // President (final) approval -> batch becomes Active + update = { + currentApprovalLevel: 2, + lifecycleStatus: "Active", + approvalStatus: "Approved", + }; + } + + const updatedBatch = await CertificateBatch.findOneAndUpdate( + matchQuery, + update, + { new: true }, + ); + + if (!updatedBatch) { + return res.status(409).json({ + message: + "This batch was already updated by another approver. Please refresh.", + }); + } + + if (level === 1) { + // Final (President) approval just happened - generate certificates + // exactly once, from the freshly-updated, level===2 document. + await generateCertificates(updatedBatch); + } + + return res.status(200).json({ + message: + level === 0 + ? "Batch approved by GENSEC. Forwarded to President." + : "Batch approved successfully. Certificates are being generated.", + }); + } 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", + }); + } +} + +/** + * Reject a batch. + * + * FIX (Problem 1/6): same turn-based check as approveBatch - only the + * approver at the current level may reject. + * + * FIX (crash bug): the previous implementation read + * `batch.approverIds.find(a => a._id...)` and `batch.eventId.title` / + * `batch.initiatedBy.personal_info.name` on a batch fetched WITHOUT + * .populate(). Those fields are raw ObjectIds, not populated documents, so + * `a._id` and `.personal_info` were always undefined, and + * `currentApprover.email` threw a TypeError on every single reject call. + * Since email sending is disabled project-wide right now, that entire + * object-building block has been removed rather than "fixed", to avoid + * resurrecting dead/untested email code. + * + * BEHAVIOR CHANGE: the previous code incremented `currentApprovalLevel` on + * rejection (e.g. GENSEC reject moved level 0 -> 1, effectively handing the + * batch to the President next). That looked unintentional - rejecting + * shouldn't advance the approval chain forward. This version marks the + * batch Rejected and leaves currentApprovalLevel untouched. Please confirm + * this matches the workflow you want (see "Edge cases" notes below on what + * should happen when the coordinator edits and resubmits after a + * rejection). + */ +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.findById(batchId); + if (!batch) { + return res.status(404).json({ message: "Batch not found" }); + } + + if (batch.lifecycleStatus !== "Submitted") { + return res.status(400).json({ + message: "Batch is not currently awaiting approval.", + }); + } + + const level = batch.currentApprovalLevel; + if (level !== 0 && level !== 1) { + return res.status(400).json({ + message: "Batch is not awaiting approval at this stage.", + }); + } + + const expectedApproverId = batch.approverIds[level]; + if (!expectedApproverId || expectedApproverId.toString() !== id.toString()) { + return res.status(403).json({ + message: "It is not your turn to act on this batch yet.", + }); + } + + const matchQuery = { _id: batchId, currentApprovalLevel: level }; + matchQuery[`approverIds.${level}`] = id; + + const updatedBatch = await CertificateBatch.findOneAndUpdate( + matchQuery, + { + approvalStatus: "Rejected", + lifecycleStatus: "Submitted", + }, + { new: true }, + ); + + if (!updatedBatch) { + return res.status(409).json({ + message: + "This batch was already updated by another approver. Please refresh.", + }); + } + + // Email notifications are disabled project-wide. Do not add them back. + + 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, +}; \ No newline at end of file diff --git a/backend/controllers/certificateController.js b/backend/controllers/certificateController.js new file mode 100644 index 00000000..7345dcea --- /dev/null +++ b/backend/controllers/certificateController.js @@ -0,0 +1,57 @@ +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) { + try { + const id = req.user._id; + + const certificates = await Certificate.find({ userId: id }).populate({ + path: "batchId", + select: "title lifecycleStatus approvalStatus", + populate: { + path: "eventId", + select: "title schedule organizing_unit_id", + populate: { + path: "organizing_unit_id", + select: "name", + }, + }, + }); + + const certificateObjs = certificates + .filter((cert) => cert.batchId) + .map((cert) => ({ + _id: cert._id, + event: cert.batchId?.eventId?.title || "Unknown Event", + issuedBy: cert.batchId?.eventId?.organizing_unit_id?.name || "Unknown", + date: new Date(cert.createdAt).toLocaleDateString("en-GB"), + status: cert.status, + certificateUrl: cert.certificateUrl || "#", + rejectionReason: + cert.status === "Rejected" ? cert.rejectionReason : "", + })); + + // Always 200 with an array (possibly empty) - see fix note above. + return res.status(200).json({ message: certificateObjs }); + } catch (err) { + console.error("getCertificates error:", err); + return res + .status(500) + .json({ message: err.message || "Internal server error" }); + } +} + +module.exports = { + getCertificates, +}; \ No newline at end of file diff --git a/backend/controllers/templateController.js b/backend/controllers/templateController.js new file mode 100644 index 00000000..55abf2e2 --- /dev/null +++ b/backend/controllers/templateController.js @@ -0,0 +1,156 @@ +const Template = require("../models/templateSchema"); + +// GET /api/templates +async function getTemplates(req, res) { + try { + const templates = await Template.find() + .populate({ + path: "createdBy", + select: "personal_info", + }) + .sort({ createdAt: -1 }); + + return res.status(200).json({ + message: templates, + }); + } catch (err) { + return res.status(500).json({ + message: err.message, + }); + } +} + +// GET /api/templates/:id +async function getTemplate(req, res) { + try { + const { id } = req.params; + + const template = await Template.findById(id).populate({ + path: "createdBy", + select: "personal_info", + }); + + if (!template) { + return res.status(404).json({ + message: "Template not found", + }); + } + + return res.status(200).json({ + message: template, + }); + } catch (err) { + return res.status(500).json({ + message: err.message, + }); + } +} + +// POST /api/templates +async function createTemplate(req, res) { + try { + const { + title, + description, + category, + design, + status, + } = req.body; + + if (!title || !category) { + return res.status(400).json({ + message: "Title and category are required", + }); + } + + const template = await Template.create({ + title, + description, + category, + design: design || "default.html", + status: status || "Draft", + createdBy: req.user._id, + }); + + return res.status(201).json({ + message: template, + }); + } catch (err) { + return res.status(500).json({ + message: err.message, + }); + } +} + +// PATCH /api/templates/:id +async function updateTemplate(req, res) { + try { + const { id } = req.params; + + const template = await Template.findById(id); + + if (!template) { + return res.status(404).json({ + message: "Template not found", + }); + } + + const ALLOWED_FIELDS = [ + "title", + "description", + "category", + "design", + "status", +]; + +ALLOWED_FIELDS.forEach((field) => { + if (req.body[field] !== undefined) { + template.set(field, req.body[field]); + } +}); + + + await template.save(); + + return res.status(200).json({ + message: template, + }); + } catch (err) { + return res.status(500).json({ + message: err.message, + }); + } +} + +// DELETE /api/templates/:id +async function deleteTemplate(req, res) { + try { + const { id } = req.params; + + const template = await Template.findById(id); + + if (!template) { + return res.status(404).json({ + message: "Template not found", + }); + } + + await template.deleteOne(); + + return res.status(200).json({ + message: "Template deleted successfully", + }); + } catch (err) { + return res.status(500).json({ + message: err.message, + }); + } +} + +module.exports = { + getTemplates, + getTemplate, + createTemplate, + updateTemplate, + deleteTemplate, +}; \ No newline at end of file diff --git a/backend/index.js b/backend/index.js index 4f2de1bc..3612fd2c 100644 --- a/backend/index.js +++ b/backend/index.js @@ -23,6 +23,11 @@ const analyticsRoutes = require("./routes/analytics.js"); const porRoutes = require("./routes/por.js"); const budgetRoutes = require("./routes/budget.js"); const roomBookingRoutes = require("./routes/roomBooking.js"); +const certificateRoutes = require("./routes/certificate"); +const certificateBatchRoutes = require("./routes/certificateBatch"); +const templateRoutes = require("./routes/template"); + + const app = express(); if (process.env.NODE_ENV === "production") { @@ -64,6 +69,9 @@ app.use("/", routes_general); app.use("/auth", routes_auth); app.use("/onboarding", onboardingRoutes); app.use("/profile", profileRoutes); +app.use("/api/certificates", certificateRoutes); +app.use("/api/batches", certificateBatchRoutes); +app.use("/api/templates", templateRoutes); app.use("/api/feedback", feedbackRoutes); app.use("/api/events", eventsRoutes); app.use("/api/skills", skillsRoutes); diff --git a/backend/models/certificateSchema.js b/backend/models/certificateSchema.js new file mode 100644 index 00000000..b1126004 --- /dev/null +++ b/backend/models/certificateSchema.js @@ -0,0 +1,178 @@ +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( + { certificateId: 1 }, + { + unique: true, + partialFilterExpression: { certificateId: { $exists: true }, + }, + }, +); + +// Fast lookup for a user's certificates +certificateSchema.index( + { userId: 1, certificateId: 1 }, + { + 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"]} } ) + +*/ \ No newline at end of file diff --git a/backend/models/templateSchema.js b/backend/models/templateSchema.js new file mode 100644 index 00000000..1656c8d5 --- /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/package-lock.json b/backend/package-lock.json index dec1d123..b917e697 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "axios": "^1.5.1", "body-parser": "^1.20.2", - "cloudinary": "^2.6.1", + "cloudinary": "^2.10.0", "connect-mongodb-session": "^3.1.1", "cors": "^2.8.5", "dotenv": "^16.3.2", @@ -20,6 +20,7 @@ "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", @@ -35,8 +36,10 @@ "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", "passport-local-mongoose": "^8.0.0", + "puppeteer": "^25.2.1", "streamifier": "^0.1.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^4.4.3" }, "devDependencies": { "cookie": "^0.5.0", @@ -969,6 +972,30 @@ "node": ">= 8" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.5.tgz", + "integrity": "sha512-xYXNuEQmHNIPWWcbL/skf2KF7seyp7c1xmKFRk3wmdFx7VwBsKVrtOLKs8ecaezsKPsWeF1YsgwIiElAscaryA==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", @@ -2217,6 +2244,31 @@ "node": ">= 6" } }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "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,13 +2302,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cloudinary": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.9.0.tgz", - "integrity": "sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz", + "integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==", "license": "MIT", "dependencies": { - "lodash": "^4.17.21" + "lodash": "^4.17.23" }, "engines": { "node": ">=9" @@ -2634,6 +2727,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1638949", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz", + "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==", + "license": "BSD-3-Clause" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2691,7 +2790,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -2886,6 +2984,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", @@ -3624,11 +3731,19 @@ "node": ">= 0.4" } }, + "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/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -3786,6 +3901,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", @@ -4636,7 +4772,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -5039,6 +5174,30 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -5354,6 +5513,12 @@ "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/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -8345,6 +8510,44 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.2.1.tgz", + "integrity": "sha512-2D5RMkQH9FRhDU57a1/jV9xWoxqZvUjaZOYjAAPdRCEY8A01V5sxzyGOMs8XiKU9fPF91SOSwNYpHRu5SD958g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.5", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.2.1", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.2.1.tgz", + "integrity": "sha512-MwEZ4FFGJ1ZLOmu/04eISxoEMKtCnHyJBRFfgpwPPSYNG6gT6Xw1laNziFSV7uwDcx3jK+ATYIo9SfOd8Uhc3w==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.5", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -9000,6 +9203,15 @@ "npm": ">= 3.0.0" } }, + "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", @@ -9071,7 +9283,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -9089,7 +9300,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9102,7 +9312,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -9456,12 +9665,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "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 +9793,12 @@ "node": ">= 0.8" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "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", @@ -9696,11 +9930,16 @@ "node": ">=0.10.0" } }, + "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/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -9718,7 +9957,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9731,7 +9969,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9744,7 +9981,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -9762,6 +9998,36 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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/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/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -9778,6 +10044,32 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9789,6 +10081,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 3814a629..77a919f0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,7 +31,7 @@ "dependencies": { "axios": "^1.5.1", "body-parser": "^1.20.2", - "cloudinary": "^2.6.1", + "cloudinary": "^2.10.0", "connect-mongodb-session": "^3.1.1", "cors": "^2.8.5", "dotenv": "^16.3.2", @@ -40,6 +40,7 @@ "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", @@ -55,8 +56,10 @@ "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", "passport-local-mongoose": "^8.0.0", + "puppeteer": "^25.2.1", "streamifier": "^0.1.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^4.4.3" }, "devDependencies": { "cookie": "^0.5.0", diff --git a/backend/routes/certificate.js b/backend/routes/certificate.js new file mode 100644 index 00000000..0e56279a --- /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..53db26f7 --- /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; \ No newline at end of file diff --git a/backend/routes/template.js b/backend/routes/template.js new file mode 100644 index 00000000..1a57c6e9 --- /dev/null +++ b/backend/routes/template.js @@ -0,0 +1,53 @@ +const router = require("express").Router(); + +const { + getTemplates, + getTemplate, + createTemplate, + updateTemplate, + deleteTemplate, +} = require("../controllers/templateController"); + +const isAuthenticated = require("../middlewares/isAuthenticated"); +const authorizeRole = require("../middlewares/authorizeRole"); +const { ROLE_GROUPS } = require("../utils/roles"); + +// View all templates +router.get( + "/", + isAuthenticated, + getTemplates +); + +// View a single template +router.get( + "/:id", + isAuthenticated, + getTemplate +); + +// Create template +router.post( + "/", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + createTemplate +); + +// Update template +router.patch( + "/:id", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + updateTemplate +); + +// Delete template +router.delete( + "/:id", + isAuthenticated, + authorizeRole(ROLE_GROUPS.COORDINATORS), + deleteTemplate +); + +module.exports = router; \ No newline at end of file diff --git a/backend/services/certificates.service.js b/backend/services/certificates.service.js new file mode 100644 index 00000000..8742c8d4 --- /dev/null +++ b/backend/services/certificates.service.js @@ -0,0 +1,110 @@ +const puppeteer = require("puppeteer"); +const crypto = require("crypto"); +const { User } = require("../models/schema"); +const renderToPdf = require("../utils/renderPdf"); +const { Certificate } = require("../models/certificateSchema"); + +async function generateCertificates(batch) { + const users = await User.find({ + _id: { $in: batch.users }, + }).select("personal_info"); + + const failures = []; + let browser; + + try { + browser = await puppeteer.launch({ headless: true }); + + for (const user of users) { + try { + + const existingCertificate = await Certificate.findOne({ + batchId: batch._id, + userId: user._id, + }); + + if (existingCertificate) { + console.log( + `Certificate already exists for ${user.personal_info.name}. Skipping...`, + ); + continue; + } + + const uniqueCertificateId = `${batch._id.toString()}_${user._id.toString()}`; + + const certificateNumber = `COSA-${crypto + .createHash("md5") + .update(uniqueCertificateId) + .digest("hex") + .slice(0, 8) + .toUpperCase()}`; + + 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: uniqueCertificateId, + certificateNumber, + issueDate: new Date().toLocaleDateString("en-GB"), + + signatories: (batch.signatoryDetails || []).map((s) => ({ + name: s.name, + role: s.role, + })), + }; + + // Generate PDF + const pdfId = await renderToPdf(data, browser); + + // Upload to Cloudinary + // TODO: Replace with Cloudinary URL once upload networking issue is resolved. + const certificateUrl = "#"; + + if (!certificateUrl) { + throw new Error("Cloudinary upload failed."); + } + + // Save certificate record + await Certificate.create({ + batchId: batch._id, + userId: user._id, + certificateUrl, + certificateId: data.certificateId, + status: "Approved", + }); + + console.log(`Certificate created for ${user.personal_info.name}`); + } catch (userErr) { + console.error( + `Certificate generation failed for user ${user._id}:`, + userErr.message, + ); + failures.push({ userId: user._id.toString(), reason: userErr.message }); + } + } + } finally { + if (browser) { + try { + await browser.close(); + } catch (closeErr) { + console.error("Failed to close browser:", closeErr.message); + } + } + } + + if (failures.length > 0) { + console.error( + `generateCertificates: batch ${batch._id} completed with ${failures.length} failure(s):`, + failures, + ); + const err = new Error( + `Certificate generation completed with ${failures.length} failure(s) out of ${users.length}.`, + ); + err.partialFailures = failures; + 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..bac7b3df --- /dev/null +++ b/backend/services/email.service.js @@ -0,0 +1,270 @@ +const nodemailer = require("nodemailer"); + +const transport = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS + } +}) + +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..f72249cd --- /dev/null +++ b/backend/services/event.service.js @@ -0,0 +1,31 @@ +const { Event } = require("../models/schema"); +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; +} + +async function isEventContact(user, event) { + const unitEmail = String( + event.organizing_unit_id?.contact_info?.email || "" + ) + .trim() + .toLowerCase(); + + const userEmail = String( + user.username || + user.personal_info?.email || + "" + ) + .trim() + .toLowerCase(); + + return unitEmail === userEmail; +} + +module.exports = { + findEvent, + isEventContact +} \ 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..9f14886f --- /dev/null +++ b/backend/services/organization.service.js @@ -0,0 +1,68 @@ +const { OrganizationalUnit } = require("../models/schema"); +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; +} + + +async function getCoordinatorOrganization(user) { + const userEmail = String( + user.username || user.personal_info?.email || "" + ) + .trim() + .toLowerCase(); + + const organization = await OrganizationalUnit.findOne({ + "contact_info.email": new RegExp(`^${userEmail}$`, "i"), + }); + + if (!organization) { + throw new HttpError( + 403, + "Coordinator is not associated with any organization" + ); + } + + return organization; +} + +module.exports = { + getOrganization, + getPresidentOrganization, + getCoordinatorOrganization +} \ 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..412ce60c --- /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..ab1eb9c9 --- /dev/null +++ b/backend/services/user.service.js @@ -0,0 +1,51 @@ +const { PositionHolder } = require("../models/schema"); +const { Position }= require("../models/schema"); +const { User } = require("../models/schema"); +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}`, + strategy: "local", + }); + + 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", + strategy: "local", + }); + 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 +} \ No newline at end of file diff --git a/backend/template-designs/certificate.hbs b/backend/template-designs/certificate.hbs new file mode 100644 index 00000000..d65e3dbf --- /dev/null +++ b/backend/template-designs/certificate.hbs @@ -0,0 +1,401 @@ + + + + + + {{certificateTitle}} – {{recipientName}} + + + +
+ + +
+ + +
+
+ + +
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+ + +
+
+ + +
+
+ + +
+
Certificate No. {{certificateNumber}}
+
{{issueDate}}
+
+ + +
+
+ COSA +
+
Council of Student Affairs, IIT Bhilai
+
+ + +
+
{{certificateType}}
+
{{certificateTitle}}
+ +
+
+
+
+
+ +
This certificate is proudly presented to
+
{{recipientName}}
+
+
{{description}}
+
+ + + + +
+ + diff --git a/backend/utils/batchValidate.js b/backend/utils/batchValidate.js new file mode 100644 index 00000000..f814ad94 --- /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, +}; \ No newline at end of file diff --git a/backend/utils/cloudinary.js b/backend/utils/cloudinary.js new file mode 100644 index 00000000..778e5b27 --- /dev/null +++ b/backend/utils/cloudinary.js @@ -0,0 +1,27 @@ +const path = require("path"); +const fs = require("fs"); +const cloudinary = require("cloudinary").v2; + +async function uploadTocloudinary(pdfId) { + const pdfPath = path.join(__dirname, "../uploads", `${pdfId}.pdf`); + + try { + const result = await cloudinary.uploader.upload(pdfPath, { + resource_type: "raw", + folder: "certificates", + timeout: 120000, + }); + + return result.secure_url; + } catch (err) { + console.error("Cloudinary upload failed:"); + console.dir(err, { depth: null }); + throw err; + } finally { + if (fs.existsSync(pdfPath)) { + fs.unlinkSync(pdfPath); + } + } +} + +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..872ad8bb --- /dev/null +++ b/backend/utils/httpError.js @@ -0,0 +1,15 @@ +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..9dab7409 --- /dev/null +++ b/backend/utils/renderPdf.js @@ -0,0 +1,75 @@ +const puppeteer = require("puppeteer"); +const handlebars = require("handlebars"); +const fs = require("fs"); +const path = require("path"); + +function sanitizeForFilename(value) { + return String(value) + .trim() + .replace(/[^a-zA-Z0-9_-]/g, "_") + .replace(/_+/g, "_"); +} + + async function renderToPdf(data, sharedBrowser = null) { + let browser = sharedBrowser; + let ownBrowser = false; + let page; + + try { + if (!browser) { + browser = await puppeteer.launch({ headless: true }); + ownBrowser = true; + } + + page = await browser.newPage(); + + const filePath = path.join( + __dirname, + "../template-designs/certificate.hbs", + ); + const template = fs.readFileSync(filePath, "utf-8"); + + const html = handlebars.compile(template)(data); + await page.setContent(html, { waitUntil: "networkidle0" }); + + const safeName = sanitizeForFilename(data.recipientName); + const safeCertId = sanitizeForFilename(data.certificateId); + const fileId = `${safeName}_${safeCertId}`; + + const uploadDir = path.join(__dirname, "../uploads"); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + const outputPath = path.join(uploadDir, `${fileId}.pdf`); + await page.pdf({ + path: outputPath, + width: "1122px", + height: "794px", + printBackground: true, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }); + + console.log("Certificate successfully generated at", outputPath); + return fileId; + } catch (err) { + throw err; + } finally { + if (page) { + try { + await page.close(); + } catch(err){ + console.warn("Failed to close page:", err.message); + } + } + if (ownBrowser && browser) { + try { + await browser.close(); + } catch(err){ + console.warn("Failed to close browser:", err.message); + } + } + } +} + +module.exports = renderToPdf; \ 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..6bd582e4 --- /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(" · ")} +
+ )} +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/Components/Batches/batchCard.jsx b/frontend/src/Components/Batches/batchCard.jsx new file mode 100644 index 00000000..8ef9acee --- /dev/null +++ b/frontend/src/Components/Batches/batchCard.jsx @@ -0,0 +1,464 @@ +import { BatchStatusBadge, CertThumb, ApprovalStatusBadge } from "./ui"; +import { + SquarePen, + ScanEye, + Trash2, + Copy, + Archive, + CalendarRange, + MapPin, + Users, +} from "lucide-react"; + +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" }, +]; + +function ActionBtn({ label, primary, danger, onClick }) { + return ( + + ); +} + +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)} + /> + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/Components/Batches/batches.jsx b/frontend/src/Components/Batches/batches.jsx new file mode 100644 index 00000000..8eeeb6ae --- /dev/null +++ b/frontend/src/Components/Batches/batches.jsx @@ -0,0 +1,555 @@ +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); + if (templates?.status === 200) { + setTemplates(templates.data); + } + } 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?._id || "", + 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 && response.status !== 201) 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} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/Components/Batches/modalDialog.jsx b/frontend/src/Components/Batches/modalDialog.jsx new file mode 100644 index 00000000..188084e4 --- /dev/null +++ b/frontend/src/Components/Batches/modalDialog.jsx @@ -0,0 +1,378 @@ +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 ? ( + <> + + +
+ + + + + + ) : ( + + )} +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/Components/Batches/select.jsx b/frontend/src/Components/Batches/select.jsx new file mode 100644 index 00000000..f62e0cc2 --- /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..a31a492e --- /dev/null +++ b/frontend/src/Components/Batches/studentPanel.jsx @@ -0,0 +1,293 @@ +import { useState, useEffect } from "react"; +import { fetchBatchUsers } from "../../services/batch"; +import { Users, ChevronDown, ChevronUp, Search, X } from "lucide-react"; +import { Avatar, Checkbox } from "./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 || []) + .map((s) => (typeof s === "string" ? s : s?._id)) + .filter(Boolean); + 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); + + console.log("studentIds:", studentIds); + + 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(" · ")} +
+ )} +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/Components/Batches/ui.jsx b/frontend/src/Components/Batches/ui.jsx new file mode 100644 index 00000000..a112d4d1 --- /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" ? ( +