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}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {/* Body */}
+
+ {filtered?.map((b, i) => {
+ const c = BATCH_COLORS[b.color % BATCH_COLORS.length];
+
+ return (
+
+ {/* Batch Name */}
+ |
+
+ |
+
+ {/* Organization */}
+
+
+
+ {b.eventId.organizing_unit_id.name}
+
+
+ |
+
+ {/* Students */}
+
+
+ |
+
+ {/* Batch Status */}
+
+
+
+
+ |
+
+ {/* Approval Status */}
+
+
+ |
+
+ {/* Created By */}
+
+
+
+ {b.initiatedBy.personal_info.name}
+
+
+ |
+
+ {/* Modified */}
+
+
+
+ {new Date(b.updatedAt).toLocaleDateString("en-GB").replaceAll("/", "-")}
+
+
+ |
+
+ {/* Actions */}
+
+
+ {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 ? (
+

+ ) : (
+ 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" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export function Divider({ label }) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/Components/Certificates/CertificatesList.jsx b/frontend/src/Components/Certificates/CertificatesList.jsx
new file mode 100644
index 00000000..182218da
--- /dev/null
+++ b/frontend/src/Components/Certificates/CertificatesList.jsx
@@ -0,0 +1,293 @@
+import React, { useState, useEffect } from "react";
+import { Download, Eye, Calendar, User, Award, Search } from "lucide-react";
+import { useAdminContext } from "../../context/AdminContext";
+import { fetchCertificates } from "../../services/certificate";
+import {toast} from "react-toastify";
+
+const CertificatesList = () => {
+ const { isUserLoggedIn } = useAdminContext();
+ const [certificates, setCertificates] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [certificateFilter, setCertificateFilter] = useState("ALL");
+ const [searchTerm, setSearchTerm] = useState("");
+
+ useEffect(() => {
+ async function loadCertificates() {
+ try {
+ setLoading(true);
+ /*{
+ _id: "1",
+ event: "Tech Fest 2024",
+ issuedBy: "Computer Science Club",
+ date: "2024-01-15",
+ status: "Approved",
+ certificateUrl: "#",
+ rejectionReason: undefined,
+ },*/
+
+ const response = await fetchCertificates();
+ if(Array.isArray(response)){
+ toast.success("Certificates fetched successfully");
+ setCertificates(response);
+ return ;
+ }
+
+ } catch (err) {
+ console.error("Error fetching certificates:", err);
+ setError("Failed to fetch certificates");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (isUserLoggedIn && Object.keys(isUserLoggedIn).length > 0) {
+ loadCertificates();
+ }
+ }, [isUserLoggedIn]);
+
+ const filteredCertificates = certificates.filter((cert) => {
+ // Filter by status
+ const matchesStatus =
+ certificateFilter === "ALL" || cert.status === certificateFilter;
+
+ // Filter by search term (searches in event name and issuedBy)
+ const matchesSearch =
+ !searchTerm ||
+ cert.event?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ cert.issuedBy?.toLowerCase().includes(searchTerm.toLowerCase());
+
+ return matchesStatus && matchesSearch;
+ });
+
+ const getStatusColor = (status) => {
+ switch (status) {
+ case "Approved":
+ return "bg-green-100 text-green-800 border-green-300";
+ case "Pending":
+ return "bg-yellow-100 text-yellow-800 border-yellow-300";
+ case "Rejected":
+ return "bg-red-100 text-red-800 border-red-300";
+ default:
+ return "bg-gray-100 text-gray-800 border-gray-300";
+ }
+ };
+
+ const formatDate = (dateString) => {
+ if (!dateString) return "N/A";
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ };
+
+ const isSafeCertificateUrl = (url) => {
+ try {
+ const parsed = new URL(url, window.location.origin);
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+ };
+
+
+ const filterButtons = [
+ { label: "ALL", value: "ALL" },
+ { label: "Pending", value: "Pending" },
+ { label: "Approved", value: "Approved" },
+ { label: "Rejected", value: "Rejected" },
+ ];
+
+ if (loading) {
+ return (
+
+
Loading certificates...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Filter Buttons */}
+
+ {filterButtons.map((btn) => (
+
+ ))}
+
+ {/**search bar */}
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full pl-10 pr-4 py-1.5 rounded-xl border-2 border-black bg-white text-black placeholder-gray-400 "
+ />
+
+
+
+ {/* Certificates Grid */}
+
+ {filteredCertificates.length === 0 ? (
+
+
+
+ {searchTerm
+ ? `No certificates found matching ${searchTerm}`
+ : `No ${certificateFilter === "ALL" ? "" : certificateFilter.toLowerCase()} certificates found.`}
+
+
+ ) : (
+
+ {filteredCertificates.map((certificate) => (
+
+ {/* Certificate Header */}
+
+
+
+ {certificate.status}
+
+
+
+ {/* Certificate Details */}
+
+
+
Event
+
+ {certificate.event || "N/A"}
+
+
+
+
+
+
+ Issued By
+
+
+ {certificate.issuedBy || "N/A"}
+
+
+
+
+
+
+ Date
+
+
+ {formatDate(certificate.date)}
+
+
+
+ {certificate.rejectionReason && (
+
+
+ Rejection Reason
+
+
+ {certificate.rejectionReason}
+
+
+ )}
+
+
+ {/* Action Buttons */}
+
+ {certificate.status === "Approved" &&
+ (certificate.certificateUrl === "#" ? (
+
+ ) : (
+ <>
+
+
+
+ >
+ ))}
+
+
+ {certificate.status === "Pending" && (
+
+ )}
+ {certificate.status === "Rejected" && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default CertificatesList;
\ No newline at end of file
diff --git a/frontend/src/Components/Requests/Card.jsx b/frontend/src/Components/Requests/Card.jsx
new file mode 100644
index 00000000..a1b140d1
--- /dev/null
+++ b/frontend/src/Components/Requests/Card.jsx
@@ -0,0 +1,220 @@
+import { useState } from "react";
+import { useAdminContext } from "../../context/AdminContext";
+
+const STATUS = {
+ Pending: {
+ cls: "bg-yellow-50 text-yellow-700 border border-yellow-200",
+ dot: "bg-yellow-400",
+ },
+ Approved: {
+ cls: "bg-emerald-50 text-emerald-700 border border-emerald-100",
+ dot: "bg-emerald-500",
+ },
+ Rejected: {
+ cls: "bg-red-50 text-red-600 border border-red-100",
+ dot: "bg-red-400",
+ },
+ Draft: {
+ cls: "bg-gray-50 text-gray-600 border border-gray-100",
+ dot: "bg-gray-400",
+ },
+};
+
+const COL_BORDER = { borderRight: "1px solid #fef3c7" };
+
+export default function RequestsTable({
+ requests,
+ onApprove,
+ onReject,
+ onView,
+ onEdit,
+}) {
+ const { isUserLoggedIn } = useAdminContext();
+ return (
+
+
+
+
+ {/* ── thead ─────────────────────────────────────────── */}
+
+
+
+ {[
+ "Event",
+ "Organization",
+ "Event Schedule",
+ "Venue",
+ "Status",
+ "Actions",
+ ].map((h, i, arr) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {/* ── tbody ─────────────────────────────────────────── */}
+
+ {requests.map((req, i) => {
+ const currentApprover =
+ req.approverIds?.[req.currentApprovalLevel];
+
+ const currentApproverId =
+ currentApprover?._id || currentApprover;
+
+ const isCurrentApprover =
+ currentApproverId?.toString() ===
+ isUserLoggedIn?._id?.toString();
+
+ const locked =
+ req.approvalStatus !== "Pending" ||
+ !isCurrentApprover;
+
+
+ const last = i === requests.length - 1;
+ return (
+
+ {/* Event Name */}
+ |
+
+ {req.eventId.title}
+
+ |
+
+ {/* organization Name */}
+
+
+ {req.eventId.organizing_unit_id.name}
+
+ |
+
+ {/* Event Schedule */}
+
+
+ {new Date(req.eventId.schedule.start).toLocaleDateString(
+ "en-GB",
+ )}{" "}
+ -{" "}
+ {new Date(req.eventId.schedule.end).toLocaleDateString(
+ "en-GB",
+ )}
+
+ |
+
+ {/* Venue */}
+
+
+ {req.eventId.schedule.venue}
+
+ |
+
+ {/* status */}
+
+ {(() => {
+ const conf = STATUS[req.approvalStatus] || STATUS.Draft;
+ return (
+
+
+ {req.approvalStatus || "Draft"}
+
+ );
+ })()}
+ |
+
+ {/* actions */}
+
+
+
+ {isCurrentApprover ? (
+ <>
+
+
+
+ >
+ ) : null}
+
+ |
+
+ );
+ })}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/Components/Requests/Requests.jsx b/frontend/src/Components/Requests/Requests.jsx
new file mode 100644
index 00000000..b025b605
--- /dev/null
+++ b/frontend/src/Components/Requests/Requests.jsx
@@ -0,0 +1,219 @@
+import { Search, OctagonAlert, FileText } from "lucide-react";
+import { useEffect, useState, useCallback } from "react";
+import { useRequest } from "../../context/RequestContext";
+import { useAdminContext } from "../../context/AdminContext";
+import { useNavigate } from "react-router-dom";
+import { toast } from "react-toastify";
+
+import RequestsTable from "./Card";
+import { ViewModal } from "./viewModal";
+import { EditModal } from "./editModal";
+
+import { fetchBatches, approveBatch, rejectBatch } from "../../services/batch";
+
+export default function Requests() {
+ const { requestStatus, setRequestStatus } = useRequest();
+
+ const { isUserLoggedIn } = useAdminContext();
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+ const [requests, setRequests] = useState([]);
+ const [viewing, setViewing] = useState(null);
+ const [editing, setEditing] = useState(null);
+ const [approveStatus, setApproveStatus] = useState(null);
+ const [rejectStatus, setRejectStatus] = useState(null);
+ const [search, setSearch] = useState("");
+
+ const stats = [
+ { dot: "bg-yellow-400", label: "Pending", value: requestStatus.pending },
+ { dot: "bg-emerald-500", label: "Approved", value: requestStatus.approved },
+ { dot: "bg-red-500", label: "Rejected", value: requestStatus.rejected },
+ ];
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ if (!isUserLoggedIn || Object.keys(isUserLoggedIn).length === 0) {
+ navigate("/");
+ return;
+ }
+
+ setLoading(true);
+ //api req to fetch data
+ const response = await fetchBatches(isUserLoggedIn._id);
+
+ if (Array.isArray(response)) {
+ response && toast.success("User batches fetched successfully");
+ setRequests(response || []);
+ return;
+ }
+ } catch (err) {
+ toast.error(err.message || "Requests could not be fetched");
+ console.error(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ fetchData();
+ }, [isUserLoggedIn]);
+
+ const filteredRequests = useCallback(() => {
+ return (requests || []).filter((req) => {
+ if (!search || !search.trim()) return true;
+
+ const event = req.eventId.title.toLowerCase();
+ const organization = req.eventId.organizing_unit_id.name.toLowerCase();
+ const requestedBy = req.initiatedBy.personal_info.name.toLowerCase();
+
+ const searchWords = search.toLowerCase().split(" ").filter(Boolean);
+
+ return searchWords.every(
+ (word) =>
+ event.includes(word) ||
+ organization.includes(word) ||
+ requestedBy.includes(word),
+ );
+ });
+ }, [requests, search]);
+
+ // keep request stats in sync with current requests
+ useEffect(() => {
+ if (!requests || requests.length === 0) {
+ setRequestStatus({ pending: 0, approved: 0, rejected: 0, total: 0 });
+ return;
+ }
+
+ let pending = 0;
+ let approved = 0;
+ let rejected = 0;
+ let total = 0;
+
+ (requests || []).forEach((req) => {
+ if (req.approvalStatus === "Approved") {
+ approved++;
+ total += req.users.length || 0;
+ } else if (req.approvalStatus === "Rejected") rejected++;
+ else pending++;
+ });
+
+ setRequestStatus({
+ pending,
+ approved,
+ rejected,
+ total,
+ });
+ }, [requests]);
+
+ const approve = async function (batch) {
+ const response = await approveBatch(batch);
+ console.log("Approve Batch ", response);
+ response && toast.success(response);
+ const updated = await fetchBatches(isUserLoggedIn?._id);
+ updated && setRequests(updated);
+ };
+
+ const reject = async function (batch) {
+ const response = await rejectBatch(batch);
+ response && toast.success(response);
+ const updated = await fetchBatches(isUserLoggedIn?._id);
+ updated && setRequests(updated);
+ };
+
+ function handleUpdateRequest(updatedRequest) {
+ setRequests((prev) =>
+ prev.map((req) => (req.id === updatedRequest.id ? updatedRequest : req)),
+ );
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {/* stats + search bar */}
+
+ {/* Stats Box */}
+
+ {stats.map(({ dot, label, value }) => (
+
+
+
+ {label}:{" "}
+ {value}
+
+
+ ))}
+
+
+
+
+ Certificates Generated:{" "}
+
+ {requestStatus.total}
+
+
+
+
+
+ {/* Search Input */}
+
+
+ setSearch(e.target.value)}
+ placeholder="Search by event, organization or requester"
+ className="pl-9 pr-4 py-2 w-full text-sm rounded-xl border border-gray-200 bg-gray-50 text-gray-700 placeholder:text-gray-400 outline-none"
+ />
+
+
+
+ {/* Request Table */}
+ {filteredRequests().length === 0 ? (
+
+ No requests match your search.
+
+ ) : (
+
+ )}
+
+ {viewing && (
+
{
+ setViewing(null);
+ approveStatus && setApproveStatus(null);
+ rejectStatus && setRejectStatus(null);
+ }}
+ />
+ )}
+ {editing && (
+ setEditing(null)}
+ setRequests={setRequests}
+ />
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/Components/Requests/editModal.jsx b/frontend/src/Components/Requests/editModal.jsx
new file mode 100644
index 00000000..4bcf1950
--- /dev/null
+++ b/frontend/src/Components/Requests/editModal.jsx
@@ -0,0 +1,355 @@
+import { useState } from "react";
+import {
+ X,
+ CalendarDays,
+ Users,
+ UserCircle2,
+ Award,
+ Pencil,
+ FlameKindling,
+ Save,
+ Building2,
+ ChevronDown,
+} from "lucide-react";
+
+import { Overlay, C } from "./ui";
+import { approverEditBatch, fetchBatches } from "../../services/batch";
+import { toast } from "react-toastify";
+import { useAdminContext } from "../../context/AdminContext";
+
+/* EDIT MODAL */
+const Field = ({ label, icon: Icon, children, half }) => (
+
+
+ {children}
+
+);
+
+const inputStyle = {
+ width: "100%",
+ padding: "10px 14px",
+ borderRadius: 10,
+ fontSize: 14,
+ color: C.text,
+ background: C.white,
+ border: `1.5px solid ${C.border}`,
+ outline: "none",
+ boxSizing: "border-box",
+ fontFamily: "inherit",
+ transition: "border-color 0.15s",
+};
+
+export const EditModal = ({ request, onClose, setRequests }) => {
+ const { isUserLoggedIn } = useAdminContext();
+ const [form, setForm] = useState({ ...request });
+ const [selected, setSelected] = useState(new Set());
+
+ const allIds = (request.users || []).map((u) => u._id);
+ const allSelected =
+ allIds.length > 0 && allIds.every((id) => selected.has(id));
+ const someSelected = allIds.some((id) => selected.has(id));
+ const toggleAll = () => {
+ if (allSelected) {
+ setSelected(new Set());
+ } else {
+ setSelected(new Set(allIds));
+ }
+ };
+
+ const toggleOne = (id) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ };
+
+ const onSave = async () => {
+ if (selected.size === 0) {
+ toast.error("Please select at least one user");
+ return;
+ }
+
+ const response = await approverEditBatch({
+ ...form,
+ users: Array.from(selected),
+ });
+ response && toast.success(response);
+ const updated = await fetchBatches(isUserLoggedIn?._id);
+ updated && setRequests(updated);
+ onClose();
+ };
+
+ return (
+
+
+ {/* header */}
+
+
+
+
+
+ Edit participant Details
+
+
+ Changes will update the certificate request record
+
+
+
+
+
+
+ {/* scrollable body */}
+
+ {/* Selection summary */}
+ {selected.size > 0 && (
+
+
+ {selected.size} participant{selected.size > 1 ? "s" : ""}{" "}
+ selected
+
+
+
+ )}
+
+ {/* Scrollable Table */}
+
+
+
+ {/* footer */}
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/Components/Requests/ui.jsx b/frontend/src/Components/Requests/ui.jsx
new file mode 100644
index 00000000..83399e93
--- /dev/null
+++ b/frontend/src/Components/Requests/ui.jsx
@@ -0,0 +1,47 @@
+/* ─── palette tokens (warm yellow theme) ─────────────────── */
+export const C = {
+ cream: "#FEFBE8",
+ creamDark: "#F5F0C8",
+ amber: "#D4A017",
+ amberLight: "#F0C040",
+ amberSoft: "#FBF0C0",
+ warmGray: "#6B6340",
+ text: "#2E2A1A",
+ textMuted: "#8A8060",
+ border: "#E8DFA0",
+ borderStrong: "#C9BB70",
+ green: "#3D7A4F",
+ greenBg: "#EAF5EE",
+ red: "#B94040",
+ redBg: "#FDEAEA",
+ white: "#FFFEF5",
+};
+
+
+/* ─── Modal Popup (click on it and it will come infront of screen) ─────────────────────────────────────── */
+export const Overlay = ({ children, onClose }) => (
+
+
e.stopPropagation()}>
+ {children}
+
+
+);
+
+ /* ─── pill badge (for showing status or priority)─────────────────────────────────────────── */
+export const Pill = ({ label, color = "amber" }) => {
+ const styles = {
+ amber: "bg-yellow-100 text-yellow-800",
+ red: "bg-red-100 text-red-700",
+ green: "bg-green-100 text-green-700 ",
+ };
+
+ return (
+
+ {label}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/Components/Requests/viewModal.jsx b/frontend/src/Components/Requests/viewModal.jsx
new file mode 100644
index 00000000..1ff2d312
--- /dev/null
+++ b/frontend/src/Components/Requests/viewModal.jsx
@@ -0,0 +1,245 @@
+import { Overlay, Pill, C } from "./ui";
+import { X, CalendarDays, Users, UserCircle2, Award } from "lucide-react";
+
+const labelColor = {
+ Approved: "green",
+ Rejected: "red",
+ Pending: "amber",
+};
+
+/* ─── info tile (used in View modal) ────────────────────── */
+const InfoTile = ({ icon: Icon, label, value, wide }) => (
+
+
+
+
+ {label}
+
+
+
+
+ {value}
+
+
+);
+
+/* VIEW MODAL */
+export const ViewModal = ({
+ request,
+ onClose,
+ approveStatus,
+ approve,
+ rejectStatus,
+ reject,
+}) => (
+
+
+ {/* header strip */}
+
+
+ {/* Left: title + org */}
+
+
+ {request.eventId.title}
+
+
+ {request.eventId.organizing_unit_id.name}
+
+
+
+ {/* Right: close button */}
+
+
+
+
+ {/* body */}
+
+ {/* Participants Table */}
+
+ {/* Table Header Bar */}
+
+
+ Participants
+
+
+ {request.users?.length || 0} members
+
+
+
+ {/* Scrollable Table */}
+
+
+
+
+ {["SL.No", "Name", "Email", "Department", "Batch Year"].map(
+ (h, i) => (
+ |
+ {h}
+ |
+ ),
+ )}
+
+
+
+ {(request.users || []).map((user, idx) => (
+
+ |
+ {idx + 1}
+ |
+
+ {user.personal_info?.name || ""}
+ |
+
+ {user.personal_info?.email || ""}
+ |
+
+
+ {user.academic_info?.branch || ""}
+
+ |
+
+
+ {user.academic_info?.batch_year || ""}
+
+ |
+
+ ))}
+
+
+
+
+
+ {/* Event Details */}
+
+
+ Event Details
+
+
+ {[
+ { label: "Event Title", value: request.eventId.title },
+ { label: "Venue", value: request.eventId.schedule.venue },
+ {
+ label: "Start Date",
+ value: new Date(
+ request.eventId.schedule.start,
+ ).toLocaleDateString("en-GB"),
+ },
+ {
+ label: "End Date",
+ value: new Date(
+ request.eventId.schedule.end,
+ ).toLocaleDateString("en-GB"),
+ },
+ { label: "Mode", value: request.eventId.schedule.mode || "—" },
+ {
+ label: "Batch Created",
+ value: new Date(request.createdAt).toLocaleDateString("en-GB"),
+ },
+ {
+ label: "Initiated By",
+ value: request.initiatedBy.personal_info.name,
+ wide: true,
+ },
+ ].map(({ label, value, wide }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+
+
+ {/* Close Button */}
+
+
+ {approveStatus && (
+
+ )}
+ {rejectStatus && (
+
+ )}
+
+
+
+
+);
\ No newline at end of file
diff --git a/frontend/src/Components/Templates/Select.jsx b/frontend/src/Components/Templates/Select.jsx
new file mode 100644
index 00000000..e555aac4
--- /dev/null
+++ b/frontend/src/Components/Templates/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/Templates/Template.jsx b/frontend/src/Components/Templates/Template.jsx
new file mode 100644
index 00000000..ba83f006
--- /dev/null
+++ b/frontend/src/Components/Templates/Template.jsx
@@ -0,0 +1,538 @@
+import { useState, useEffect, useMemo } from "react";
+import {
+ Search,
+ Plus,
+ Eye,
+ Pencil,
+ Trash2,
+ FileText,
+ LayoutGrid,
+ List,
+ X,
+ Filter,
+ SquareUser,
+ Info,
+} from "lucide-react";
+
+import { toast } from "react-toastify";
+
+import TemplateModal from "./TemplateModal";
+import ThumbnailPreview from "./thumbnailPreview";
+import { Select } from "./Select";
+import { useAdminContext } from "../../context/AdminContext";
+import { fetchTemplates } from "../../services/templates";
+import { deleteTemplate } from "../../services/templates";
+
+const STATUS_STYLE = {
+ Active: "bg-emerald-100 text-emerald-700 border border-emerald-200",
+ Draft: "bg-yellow-100 text-yellow-700 border border-yellow-300",
+ Archived: "bg-gray-100 text-gray-500 border border-gray-200",
+};
+
+const STATUS_DOT = {
+ Active: "bg-emerald-500",
+ Draft: "bg-yellow-400",
+ Archived: "bg-gray-400",
+};
+
+const TEMPLATE_STATUS_MAP = {
+ Active: "#5c8a6e", // muted green
+ Draft: "#8a7d5c", // muted amber/tan
+ Archived: "#6e7a8a", // muted slate blue
+};
+
+export default function Templates() {
+ const [templates, setTemplates] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const [showModal, setShowModal] = useState(false);
+ const [selectedTemplate, setSelectedTemplate] = useState(null);
+
+ const [search, setSearch] = useState("");
+ const [catFilter, setCat] = useState("All");
+ const [statusFilter, setStatus] = useState("All");
+ const [creatorFilter, setCreator] = useState("All");
+ const [mode, setMode] = useState("create");
+ const [view, setView] = useState("grid"); // "grid" | "list"
+
+ const { userRole} = useAdminContext();
+ const role = (userRole || "").toUpperCase();
+
+ const loadTemplates = async () => {
+ setLoading(true);
+
+ try {
+ const res = await fetchTemplates();
+
+ if (res?.status === 200) {
+ setTemplates(res.data);
+ }
+ } finally {
+ setLoading(false);
+ }
+};
+
+useEffect(() => {
+ loadTemplates();
+}, []);
+
+
+ const filtered = templates?.filter((t) => {
+ if(!t?.title || !t?.category || !t?.createdBy?.personal_info?.name || !t.status) return false;
+
+ const matchSearch = !search || t.title.toLowerCase().includes(search?.toLowerCase());
+ const matchCat = catFilter === "All" || t.category === catFilter;
+ const matchStatus = statusFilter === "All" || t.status === statusFilter;
+ const matchCreator =
+ creatorFilter === "All" ||
+ t.createdBy.personal_info.name === creatorFilter;
+
+ return matchSearch && matchCat && matchStatus && matchCreator;
+ });
+
+ const filters = useMemo(
+ () => ({
+ categories: [...new Set(templates?.map((t) => t.category))],
+ statuses: [...new Set(templates?.map((t) => t.status))],
+ creators: [
+ ...new Set(templates?.map((t) => t.createdBy.personal_info.name)),
+ ],
+ }),
+ [templates],
+ );
+
+
+ const del = async (id) => {
+ const ok = window.confirm(
+ "Are you sure you want to archive this template?"
+ );
+
+ if (!ok) return;
+
+ try {
+ await deleteTemplate(id);
+ await loadTemplates();
+ } catch (err) {
+ console.error(err);
+ toast.error("Failed to archive template.");
+ }
+};
+
+ const hasFilters =
+ catFilter !== "All" ||
+ statusFilter !== "All" ||
+ creatorFilter !== "All" ||
+ search;
+
+ const clearAll = () => {
+ setCat("All");
+ setStatus("All");
+ setCreator("All");
+ setSearch("");
+ };
+
+ if (loading) {
+ return (
+
+ );
+}
+
+ return (
+
+
+
+ {/* ── filters bar ───────────────────────────────────── */}
+
+ {/* view toggle */}
+
+
+
+
+
+ {/* search */}
+
+
+ setSearch(e.target.value)}
+ placeholder="Search templates…"
+ className="w-full pl-8 pr-4 py-2 text-xs font-medium rounded-xl border border-yellow-100 bg-white text-gray-700 placeholder:text-gray-400 outline-none focus:border-yellow-300 focus:ring-2 focus:ring-yellow-100 transition-all"
+ />
+ {search && (
+
+ )}
+
+
+
+
+
+
+
+
+ {hasFilters && (
+
+ )}
+
+
+
+
+
+
+ {/* ── empty state ───────────────────────────────────── */}
+ {filtered?.length === 0 && (
+
+
+
No templates found
+ {search ? (
+
+ Try adjusting your filters or search
+
+ ) : (
+
+ No templates exist. Create one today
+
+ )}
+
+ )}
+
+ {/* GRID VIEW */}
+ {view === "grid" && filtered?.length > 0 && (
+
+ {filtered?.map((t) => (
+
+ {/* thumbnail */}
+
+
+ {/* hover overlay */}
+
+ {/* status badge */}
+
+
+
+ {t.status}
+
+
+
+
+ {/* card body */}
+
+ {/* inner panel mimicking the rounded sub-card in the image */}
+
+ {/* Row 1: NAME field + small category box */}
+
+
+
+
+ {t.category}
+
+
+
+
+ {/* Row 2 */}
+
+ {/* LEFT: action buttons */}
+
+
+
+ {(role === "PRESIDENT" ||
+ role.startsWith("GENSEC") ||
+ t.title.includes("Copy")) ? (
+ <>
+
+
+
+ >
+ ) : null }
+
+
+ {/* RIGHT: avatar + author + date */}
+
+
+ {t.createdBy.personal_info.name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")}
+
+
+
+ {t.createdBy.personal_info.name}
+
+
+
+ {new Date(t.createdAt).toLocaleDateString("en-GB").replaceAll("/", "-")}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* LIST VIEW */}
+ {view === "list" && filtered?.length > 0 && (
+
+
+
+
+ {[
+ "Template",
+ "Category",
+ "Status",
+ "Created By",
+ "Last Modified",
+ "Actions",
+ ].map((h, i, arr) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {filtered.map((t, i) => (
+
+ |
+
+ |
+
+
+ {t.category}
+
+ |
+
+
+
+ {t.status}
+
+ |
+
+
+
+ {t.createdBy.personal_info.name}
+
+
+ |
+
+
+ {new Date(t.updatedAt).toLocaleDateString("en-GB").replaceAll("/", "-")}
+
+ |
+
+
+
+
+
+ {(role === "PRESIDENT" ||
+ role.startsWith("GENSEC") ||
+ t.title.includes("Copy")) ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>>
+ )}
+
+ |
+
+ ))}
+
+
+
+ )}
+
{
+ setShowModal(false);
+ setSelectedTemplate(null);
+ }}
+ onSuccess={loadTemplates}
+/>
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/Components/Templates/TemplateModal.jsx b/frontend/src/Components/Templates/TemplateModal.jsx
new file mode 100644
index 00000000..c445cec5
--- /dev/null
+++ b/frontend/src/Components/Templates/TemplateModal.jsx
@@ -0,0 +1,307 @@
+import { useEffect, useState } from "react";
+import { X } from "lucide-react";
+import { toast } from "react-toastify";
+
+import {
+ createTemplate,
+ updateTemplate,
+} from "../../services/templates";
+
+const categories = [
+ "TECHNICAL",
+ "CULTURAL",
+ "SPORTS",
+ "ACADEMIC",
+ "OTHER",
+];
+
+const statuses = [
+ "Draft",
+ "Active",
+ "Archived",
+];
+
+const designs = [
+ "Default",
+ "Classic",
+ "Modern",
+];
+
+const initialState = {
+ title: "",
+ description: "",
+ category: "TECHNICAL",
+ design: "Default",
+ status: "Draft",
+};
+
+export default function TemplateModal({
+ open,
+ mode = "create",
+ template,
+ onClose,
+ onSuccess,
+}) {
+ const editing = mode === "edit";
+ const viewing = mode === "view";
+
+ const [form, setForm] = useState(initialState);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (template) {
+ setForm({
+ title: template.title || "",
+ description: template.description || "",
+ category: template.category || "TECHNICAL",
+ design: template.design || "Default",
+ status: template.status || "Draft",
+ });
+ } else {
+ setForm(initialState);
+ }
+ }, [template, open]);
+
+ if (!open) return null;
+
+ const handleChange = (field, value) => {
+ setForm((prev) => ({
+ ...prev,
+ [field]: value,
+ }));
+ };
+
+ const handleClose = () => {
+ if (!loading) {
+ setForm(initialState);
+ onClose();
+ }
+ };
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+
+ if (viewing) {
+ handleClose();
+ return;
+ }
+
+ if (!form.title.trim()) {
+ toast.error("Template title is required");
+ return;
+ }
+
+ try {
+ setLoading(true);
+
+ if (editing) {
+ const res = await updateTemplate(template._id, form);
+
+ if (res?.status === 200) {
+ toast.success("Template updated successfully");
+ }
+ } else {
+ const res = await createTemplate(form);
+
+ if (res?.status === 201 || res?.status === 200) {
+ toast.success("Template created successfully");
+ }
+ }
+
+ await onSuccess?.();
+ handleClose();
+ } catch (err) {
+ toast.error(err?.response?.data?.message || "Something went wrong");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+ {viewing
+ ? "View Template"
+ : editing
+ ? "Edit Template"
+ : "Create Template"}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/Components/Templates/thumbnailPreview.jsx b/frontend/src/Components/Templates/thumbnailPreview.jsx
new file mode 100644
index 00000000..c251c28b
--- /dev/null
+++ b/frontend/src/Components/Templates/thumbnailPreview.jsx
@@ -0,0 +1,32 @@
+/* ── thumbnail pattern generator ────────────────────────── */
+export default function ThumbnailPreview({ color, category }){
+ const c = color;
+ return (
+
+ {/* decorative lines */}
+
+ {/* corner accents */}
+
+
+
+
+ {/* category chip */}
+
+
+ {category}
+
+
+
+ );
+ };
+
\ No newline at end of file
diff --git a/frontend/src/config/dashboardComponents.js b/frontend/src/config/dashboardComponents.js
index 4a7d641d..f90ae029 100644
--- a/frontend/src/config/dashboardComponents.js
+++ b/frontend/src/config/dashboardComponents.js
@@ -27,6 +27,10 @@ import AnnouncementsPage from "../pages/announcementsPage";
import TenurePage from "../pages/TenurePage";
import BudgetPage from "../pages/budgetPage";
import RoomBookingPage from "../pages/roomBookingPage";
+import BatchesPage from "../pages/batchesPage";
+import TemplatesPage from "../pages/templatesPage";
+import RequestsPage from "../pages/requestsPage";
+import CertificatesPage from "../pages/certificatesPage";
export const DashboardComponents = {
dashboard: HomePage,
@@ -38,6 +42,10 @@ export const DashboardComponents = {
achievements: AchievementsPage,
endorsement: EndorsementPage,
profile: ProfilePage,
+ certificates: CertificatesPage,
+ batches: BatchesPage,
+ templates: TemplatesPage,
+ requests: RequestsPage,
cosa: ViewTenure,
announcements: AnnouncementsPage,
"manage-positions": CreateTenure,
diff --git a/frontend/src/config/navbarConfig.js b/frontend/src/config/navbarConfig.js
index a9547599..9fc25b89 100644
--- a/frontend/src/config/navbarConfig.js
+++ b/frontend/src/config/navbarConfig.js
@@ -12,6 +12,10 @@ import {
Megaphone,
Wallet,
DoorOpen,
+ Dock,
+ ClipboardPaste,
+ LayoutTemplate,
+ FolderOpen,
} from "lucide-react";
const GENSEC_COMMON_NAV = [
@@ -26,6 +30,7 @@ const GENSEC_COMMON_NAV = [
{ key: "por", label: "PORs", icon: UserPlus },
{ key: "budget", label: "Budget", icon: Wallet },
{ key: "profile", label: "Profile", icon: User },
+ { key: "requests", label: "Requests", icon: ClipboardPaste },
{ key: "organization", label: "Clubs", icon: Users },
{ key: "tenure", label: "Tenure", icon: Users },
];
@@ -43,6 +48,7 @@ export const NavbarConfig = {
{ key: "roombooking", label: "RoomBooking", icon: DoorOpen },
// { key: "add-event", label: "Add Event", icon: Plus },
{ key: "profile", label: "Profile", icon: User },
+ { key: "requests", label: "Requests", icon: ClipboardPaste },
{ key: "organization", label: "Clubs", icon: Users },
{ key: "tenure", label: "Tenure", icon: Users },
],
@@ -64,6 +70,8 @@ export const NavbarConfig = {
// { key: "add-event", label: "Add Event", icon: Plus },
{ key: "feedback", label: "Feedback", icon: ClipboardList },
{ key: "profile", label: "Profile", icon: User },
+ { key: "batches", label: "Batches", icon: FolderOpen },
+ { key: "templates", label: "Templates", icon: LayoutTemplate },
{ key: "endorsement", label: "Endorsements", icon: Award },
{ key: "tenure", label: "Tenure", icon: Users },
],
@@ -74,6 +82,7 @@ export const NavbarConfig = {
{ key: "profile", label: "Profile", icon: User },
// { key: "cosa", label: "CoSA", icon: Users },
+ { key: "certificates", label: "Certificates", icon: Dock },
{ key: "events", label: "Events", icon: Calendar },
{ key: "feedback", label: "Feedback", icon: MessageSquare },
// { key: "view-feedback", label: "Feedback", icon: ClipboardList },
diff --git a/frontend/src/context/RequestContext.js b/frontend/src/context/RequestContext.js
new file mode 100644
index 00000000..729f77f1
--- /dev/null
+++ b/frontend/src/context/RequestContext.js
@@ -0,0 +1,26 @@
+import { useContext } from "react";
+import { createContext, useState } from "react";
+
+export const RequestContext = createContext();
+
+export function RequestProvider({ children }) {
+ const [requestStatus, setRequestStatus] = useState({
+ pending: 0,
+ approved: 0,
+ rejected: 0,
+ total: 0
+ })
+
+ return (
+
+ {children}
+
+ );
+}
+
+//Custom Hook
+export function useRequest() {
+ return useContext(RequestContext);
+}
\ No newline at end of file
diff --git a/frontend/src/pages/batchesPage.jsx b/frontend/src/pages/batchesPage.jsx
new file mode 100644
index 00000000..5bf6f787
--- /dev/null
+++ b/frontend/src/pages/batchesPage.jsx
@@ -0,0 +1,34 @@
+import Layout from "../Components/common/Layout.jsx";
+import Batches from "../Components/Batches/batches.jsx"
+import { useSidebar } from "../hooks/useSidebar.js";
+
+export default function BatchesPage() {
+ const { isCollapsed } = useSidebar();
+
+ const components = {
+ Batches: Batches,
+ };
+
+ const gridConfig = [
+ {
+ id: "batches",
+ component: "Batches",
+ position: {
+ colStart: 0,
+ colEnd: isCollapsed ? 26 : 20,
+ rowStart: 0,
+ rowEnd: 16,
+ },
+ },
+ ];
+
+ return (
+ <>
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/certificatesPage.jsx b/frontend/src/pages/certificatesPage.jsx
new file mode 100644
index 00000000..c976fb51
--- /dev/null
+++ b/frontend/src/pages/certificatesPage.jsx
@@ -0,0 +1,32 @@
+import Layout from "../Components/common/Layout";
+import CertificatesList from "../Components/Certificates/CertificatesList";
+import { useSidebar } from "../hooks/useSidebar";
+
+export default function CertificatesPage() {
+ const { isCollapsed } = useSidebar();
+
+ const components = {
+ CertificatesList: CertificatesList,
+ };
+
+ const gridConfig = [
+ {
+ id: "certificates",
+ component: "CertificatesList",
+ position: {
+ colStart: 0,
+ colEnd: isCollapsed ? 26 : 20,
+ rowStart: 0,
+ rowEnd: 16,
+ },
+ },
+ ];
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/requestsPage.jsx b/frontend/src/pages/requestsPage.jsx
new file mode 100644
index 00000000..1c6053a7
--- /dev/null
+++ b/frontend/src/pages/requestsPage.jsx
@@ -0,0 +1,36 @@
+import Layout from "../Components/common/Layout";
+import Requests from "../Components/Requests/Requests.jsx";
+import { RequestProvider } from "../context/RequestContext.js";
+import { useSidebar } from "../hooks/useSidebar";
+
+export default function RequestsPage() {
+ const { isCollapsed } = useSidebar();
+
+ const components = {
+
+ Requests: Requests,
+ };
+
+ const gridConfig = [
+ {
+ id: "requests",
+ component: "Requests",
+ position: {
+ colStart: 0,
+ colEnd: isCollapsed ? 26 : 20,
+ rowStart: 0,
+ rowEnd: 16,
+ },
+ },
+ ];
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/templatesPage.jsx b/frontend/src/pages/templatesPage.jsx
new file mode 100644
index 00000000..ecee5e5a
--- /dev/null
+++ b/frontend/src/pages/templatesPage.jsx
@@ -0,0 +1,35 @@
+import Layout from "../Components/common/Layout";
+import Templates from "../Components/Templates/Template";
+//import { RequestProvider } from "../context/RequestContext.js";
+import { useSidebar } from "../hooks/useSidebar";
+
+export default function TemplatesPage() {
+ const { isCollapsed } = useSidebar();
+
+ const components = {
+ Templates: Templates,
+ };
+
+ const gridConfig = [
+ {
+ id: "templates",
+ component: "Templates",
+ position: {
+ colStart: 0,
+ colEnd: isCollapsed ? 25 : 20,
+ rowStart: 0,
+ rowEnd: 16,
+ },
+ },
+ ];
+
+ return (
+ <>
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/routes/StudentRoutes.js b/frontend/src/routes/StudentRoutes.js
index 7ec1c251..e28f0c9f 100644
--- a/frontend/src/routes/StudentRoutes.js
+++ b/frontend/src/routes/StudentRoutes.js
@@ -11,6 +11,8 @@ import Logout from "../Components/Logout";
import Home from "../Components/OldComponents/Home";
import StudentProfile from "../Components/Profile/ProfilePage";
import TenurePage from "../pages/TenurePage";
+import CertificatesPage from "../pages/certificatesPage";
+
export const getStudentRoutes = (isUserLoggedIn, isOnboardingComplete) => [
[
}
/>,
+
+
+
+ }
+/>,
];
diff --git a/frontend/src/services/batch.js b/frontend/src/services/batch.js
new file mode 100644
index 00000000..923d5e6e
--- /dev/null
+++ b/frontend/src/services/batch.js
@@ -0,0 +1,107 @@
+import api from "../utils/api";
+
+export async function fetchBatchUsers(userIds = []) {
+ try {
+ const res = await api.post("/api/batches/batch-users", { userIds });
+ return res.data.message;
+ } catch (err) {
+ console.error("fetchBatchUsers error:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function fetchBatches(userId) {
+ try {
+ const res = await api.get(`/api/batches/${userId}`);
+ return res.data.message;
+ } catch (err) {
+ console.error("fetchBatches error:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function createBatch(data) {
+ try {
+ const response = await api.post("/api/batches/create-batch", data);
+ console.log("Response", response);
+ return {data: response.data.message, status: response.status};
+ } catch (err) {
+ console.error("createBatch error:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function editBatch(data) {
+ try {
+ const res = await api.patch("/api/batches/edit-batch", data);
+ return res.data.message;
+ } catch (err) {
+ console.error("Error while editing batch:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function approverEditBatch(data) {
+ try {
+ const res = await api.patch("/api/batches/approver/edit-batch", data);
+ return res.data.message;
+ } catch (err) {
+ console.error("Error while editing batch:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function duplicateBatch(batchId) {
+ try {
+ const res = await api.post("/api/batches/duplicate-batch", { batchId });
+ return res.data.message;
+ } catch (err) {
+ console.error("Error while duplicating batch:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function deleteBatch(batchId) {
+ try {
+ //console.log("Deleting batch:", batchId);
+ const res = await api.delete("/api/batches/delete-batch", {
+ data: { batchId },
+ });
+ return res.data.message;
+ } catch (err) {
+ console.error("Error while deleting batch:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function archiveBatchApi(batchId) {
+ try {
+ //console.log("Deleting batch:", batchId);
+ const res = await api.patch("/api/batches/archive-batch", { batchId });
+ return res.data.message;
+ } catch (err) {
+ console.error("Error while archiving batch:", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function approveBatch(batch) {
+ try {
+ const res = await api.get(`/api/batches/${batch?._id}/approve`);
+ console.log(res);
+ return res.data.message;
+ } catch (err) {
+ console.error("Error while approving batch: ", err);
+ return err.response?.data.message;
+ }
+}
+
+export async function rejectBatch(batch) {
+ try {
+ const res = await api.get(`/api/batches/${batch?._id}/reject`);
+ return res.data.message;
+ } catch (err) {
+ console.error("Error while rejecting batch: ", err);
+ return err.response?.data.message;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/services/certificate.js b/frontend/src/services/certificate.js
new file mode 100644
index 00000000..d0ab4cb2
--- /dev/null
+++ b/frontend/src/services/certificate.js
@@ -0,0 +1,12 @@
+import api from "../utils/api";
+
+export async function fetchCertificates() {
+ try{
+ const res = await api.get("/api/certificates");
+ return res.data.message;
+ }
+ catch(err){
+ console.error("Error while fetching user certificates:", err);
+ return err.response?.data.message;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/services/events.js b/frontend/src/services/events.js
new file mode 100644
index 00000000..3d2c36bf
--- /dev/null
+++ b/frontend/src/services/events.js
@@ -0,0 +1,13 @@
+import api from "../utils/api";
+
+/* api/events */
+export async function fetchEvents() {
+ try {
+ const res = await api.get("/api/events/events");
+ // Axios auto-throws on non-2xx; res.data is the response body directly
+ return res.data;
+ } catch (err) {
+ console.error("fetchEvents error:", err);
+ return err.response?.data.message;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/services/templates.js b/frontend/src/services/templates.js
new file mode 100644
index 00000000..65299fc7
--- /dev/null
+++ b/frontend/src/services/templates.js
@@ -0,0 +1,91 @@
+import api from "../utils/api";
+
+/* GET /api/templates */
+export async function fetchTemplates() {
+ try {
+ const res = await api.get("/api/templates");
+
+ return {
+ data: res.data.message,
+ status: res.status,
+ };
+ } catch (err) {
+ console.error("fetchTemplates error:", err);
+ return err.response?.data?.message;
+ }
+}
+
+/* GET /api/templates/:id */
+export async function fetchTemplate(id) {
+ try {
+ const res = await api.get(`/api/templates/${id}`);
+
+ return {
+ data: res.data.message,
+ status: res.status,
+ };
+ } catch (err) {
+ console.error("fetchTemplate error:", err);
+ return err.response?.data?.message;
+ }
+}
+
+/* POST /api/templates */
+export async function createTemplate(data) {
+ try {
+ const res = await api.post("/api/templates", data);
+
+ return {
+ data: res.data.message,
+ status: res.status,
+ };
+ } catch (err) {
+ console.error("createTemplate error:", err);
+ return err.response?.data?.message;
+ }
+}
+
+/* PATCH /api/templates/:id */
+export async function updateTemplate(id, data) {
+ try {
+ const res = await api.patch(`/api/templates/${id}`, data);
+
+ return {
+ data: res.data.message,
+ status: res.status,
+ };
+ } catch (err) {
+ console.error("updateTemplate error:", err);
+ return err.response?.data?.message;
+ }
+}
+
+/* DELETE /api/templates/:id */
+/* (Archives the template if your backend uses soft delete) */
+export async function deleteTemplate(id) {
+ try {
+ const res = await api.delete(`/api/templates/${id}`);
+
+ return {
+ data: res.data.message,
+ status: res.status,
+ };
+ } catch (err) {
+ console.error("deleteTemplate error:", err);
+ return err.response?.data?.message;
+ }
+}
+
+export async function archiveTemplate(id) {
+ try {
+ const res = await api.delete(`/api/templates/${id}`);
+
+ return {
+ data: res.data.message,
+ status: res.status,
+ };
+ } catch (err) {
+ console.error("archiveTemplate error:", err);
+ throw err;
+ }
+}
\ No newline at end of file