diff --git a/backend/app.ts b/backend/app.ts index 56c94c4..1c25471 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -21,7 +21,10 @@ import handoutRoutes from "./routes/handoutRoutes"; import courseCategoriesRoutes from "./routes/courseCategoryRoutes"; import emailRoutes from "./routes/emailRoutes"; import speakerRoutes from "./routes/speakerRoutes"; -import pdfRoutes from "./routes/pdfRoutes"; +import pdfRoutes from "./routes/pdfRoutes"; +import emailTemplateRoutes from "./routes/emailTemplateRoutes"; +import zoomRoutes from "./routes/zoomRoutes"; +import userTypeRoutes from "./routes/userTypeRoutes"; // Import middleware import { notFound, errorHandler } from "./middlewares/errorMiddleware"; @@ -30,6 +33,7 @@ import upload from "./middlewares/upload"; import { uploadImage } from "./controllers/uploadController"; import uploadRoutes from "./routes/uploadRoutes"; import dotenv from "dotenv"; + dotenv.config(); const app: Application = express(); @@ -88,20 +92,13 @@ app.use("/api/certificates", verifyFirebaseAuth, certificateRoutes); app.use("/api/courseCategories", verifyFirebaseAuth, courseCategoriesRoutes); app.use("/api/handout", verifyFirebaseAuth, handoutRoutes); app.use("/api/emails", verifyFirebaseAuth, emailRoutes); +app.use("/api/emailTemplates", verifyFirebaseAuth, emailTemplateRoutes); app.use("/api/speakers", verifyFirebaseAuth, speakerRoutes); app.use("/api/upload", verifyFirebaseAuth, uploadRoutes); -app.use("/api/certificatePDFs", verifyFirebaseAuth, pdfRoutes); +app.use("/api/user-types", userTypeRoutes); +app.use("/api/zoom", verifyFirebaseAuth, zoomRoutes); +app.use("/api/certificatePDFs", verifyFirebaseAuth, pdfRoutes); -// Error middleware -app.use((err: any, req: Request, res: Response, next: NextFunction): void => { - if (err instanceof multer.MulterError) { - console.error("❌ Multer error:", err.message); - res.status(400).json({ message: err.message }); - return; - } - console.error("❌ Unknown error:", err); - res.status(500).json({ message: "Internal server error" }); -}); app.use(notFound); app.use(errorHandler); diff --git a/backend/config/cloudinaryStorage.ts b/backend/config/cloudinaryStorage.ts index 8d792c1..1aa992e 100644 --- a/backend/config/cloudinaryStorage.ts +++ b/backend/config/cloudinaryStorage.ts @@ -4,8 +4,6 @@ import cloudinary from "./cloudinary"; import { Request } from "express"; import { Options } from "multer-storage-cloudinary"; -console.log("cloundaryStorage"); - // Explicit type to override the broken one interface CustomParams { folder: string; diff --git a/backend/config/resend.ts b/backend/config/resend.ts new file mode 100644 index 0000000..2dce56d --- /dev/null +++ b/backend/config/resend.ts @@ -0,0 +1,34 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import { Resend } from "resend"; +import handlebars from "handlebars"; + +const resend = new Resend(process.env.RESEND_API_KEY!); + +export const sendEmail = async ( + to: string | string[], + subject: string, + body: string, // Handlebars HTML string + variables: Record +) => { + const template = handlebars.compile(body); + const html = template(variables); + + try { + const { data, error } = await resend.emails.send({ + from: "Foster Source ", + to, + subject, + html, + }); + + if (error) { + console.error("Email send error:", error); + throw new Error("Failed to send email"); + } + } catch (err) { + console.error(err); + throw err; + } +}; diff --git a/backend/controllers/courseController.ts b/backend/controllers/courseController.ts index c9493f0..4c5adfe 100644 --- a/backend/controllers/courseController.ts +++ b/backend/controllers/courseController.ts @@ -24,12 +24,9 @@ export const getCourses = async ( ): Promise => { try { const filters = req.query; - - // Populate ratings and components fields as needed const courseResponses = await Course.find(filters) - .populate(["ratings", "components"]) + .populate(["ratings"]) .exec(); - res.status(200).json({ success: true, count: courseResponses.length, @@ -56,7 +53,7 @@ export const getCourseById = async ( if (id) { // Find course by ID and populate related fields const course = await Course.findById(id) - .populate(["ratings", "components", "managers"]) + .populate(["ratings", "managers"]) .exec(); if (!course) { @@ -105,8 +102,6 @@ export const createCourse = async ( ratings, className, discussion, - components, - isLive, categories, creditNumber, courseDescription, @@ -115,17 +110,14 @@ export const createCourse = async ( cost, instructorDescription, instructorRole, - lengthCourse, - time, instructorName, - isInPerson, students, managers, speakers, - courseType, regStart, regEnd, productType, + productInfo, shortUrl, draft, } = req.body; @@ -134,23 +126,18 @@ export const createCourse = async ( if (!draft) if ( !className || - isLive === undefined || creditNumber === undefined || !courseDescription || !thumbnailPath || cost === undefined || - !lengthCourse || - !time || !instructorName || - isInPerson === undefined || - !courseType || !regEnd ) { // console.log("[createCourse] Validation failed. Missing required fields"); res.status(400).json({ success: false, message: - "Please provide className, isLive, creditNumber, thumbnailPath, cost, lengthCourse, time, instructorName, isInPerson, courseType, and regStart", + "Please provide className, isLive, creditNumber, thumbnailPath, cost, lengthCourse, instructorName, and regStart", }); return; } @@ -172,8 +159,6 @@ export const createCourse = async ( ratings, className, discussion, - components, - isLive, categories, creditNumber, courseDescription, @@ -182,17 +167,14 @@ export const createCourse = async ( cost, instructorDescription, instructorRole, - lengthCourse, - time, instructorName, - isInPerson, students, managers, speakers, - courseType, regStart, regEnd, productType, + productInfo, shortUrl, draft, }); diff --git a/backend/controllers/emailController.ts b/backend/controllers/emailController.ts new file mode 100644 index 0000000..a77d8eb --- /dev/null +++ b/backend/controllers/emailController.ts @@ -0,0 +1,163 @@ +import { Request, Response } from "express"; +import Progress, { IProgress } from "../models/progressModel"; +import Email, { IEmail } from "../models/emailModel"; +import User from "../models/userModel"; +import Course from "../models/courseModel"; +import { sendEmail } from "../config/resend"; +import { emailQueue } from "../jobs/emailQueue"; +import mongoose from "mongoose"; + +// @desc Get all emails or filter by query parameters +// @route GET /api/email +// @access Public +export const getEmails = async (req: Request, res: Response): Promise => { + try { + const emails = await Email.find(req.query) + .populate({ + path: "course", + select: "className", + }) + .sort({ sendDate: -1 }); + res.status(200).json(emails); + } catch (error) { + res.status(500).json({ message: "Error fetching progress data", error }); + } +}; + +// @desc Create a new email +// @route POST /api/email +// @access Public +export const createEmail = async ( + req: Request, + res: Response +): Promise => { + try { + const newEmail: IEmail = new Email(req.body); + await newEmail.save(); + res.status(201).json(newEmail); + } catch (error) { + res.status(400).json({ message: "Error creating progress entry", error }); + } +}; + +// @desc Create and send a new email +// @route POST /api/email/send +// @access Public +export const createAndSendEmail = async ( + req: Request, + res: Response +): Promise => { + try { + const { subject, body, courseId, sendDate } = req.body; + + const newEmail: IEmail = new Email({ + course: courseId, + subject, + body, + sendDate: sendDate ? new Date(sendDate) : new Date(), + sent: false, + }); + await newEmail.save(); + + const emailId = (newEmail._id as mongoose.Types.ObjectId).toString(); + + const delay = sendDate + ? Math.max(new Date(sendDate).getTime() - Date.now(), 0) + : 0; + + await emailQueue.add( + "send-course-email", // match worker's processor name too + { emailId }, + { + delay, + jobId: emailId, // this ensures you can later retrieve/remove it + } + ); + + console.log("added to queue"); + + res.status(200).json(newEmail); + } catch (error) { + console.error(error); + res + .status(400) + .json({ message: "Error creating and queuing email", error }); + } +}; + +// @desc Update an email +// @route PUT /api/email/:id +// @access Public +export const updateEmail = async ( + req: Request, + res: Response +): Promise => { + console.log(req.params); + const { id } = req.params; // Get the ID from the request parameters + const { subject, body, courseId, sendDate } = req.body; + try { + const updatedEmail = await Email.findByIdAndUpdate( + id, + { + subject, + body, + course: courseId, + sendDate, + wasSent: false, + }, + { new: true } + ); + + if (!updatedEmail) { + res.status(404).json({ message: "Email not found" }); + } + + // Remove old job + const existingJob = await emailQueue.getJob(id); + if (existingJob) { + await existingJob.remove(); + } + + // Re-schedule job + const delay = new Date(sendDate).getTime() - Date.now(); + + await emailQueue.add( + "send-course-email", + { emailId: id }, + { + jobId: id, + delay: Math.max(delay, 0), + } + ); + + res.status(200).json(updatedEmail); + } catch (error) { + console.error(error); + res.status(500); + } +}; + +// @desc Delete an email +// @route DELETE /api/email/:id +// @access Public +export const deleteEmail = async ( + req: Request, + res: Response +): Promise => { + const { id } = req.params; // Get the ID from the request parameters + try { + const deletedEmail = await Email.findByIdAndDelete(id); + if (!deletedEmail) { + res.status(404).json({ message: "Email not found" }); + } + + const existingJob = await emailQueue.getJob(id); + if (existingJob) { + await existingJob.remove(); + } + + res.status(204).send(); // Send a 204 No Content response + } catch (error) { + res.status(500); + } +}; diff --git a/backend/controllers/emailTemplateController.ts b/backend/controllers/emailTemplateController.ts index 1d66f86..215d1ed 100644 --- a/backend/controllers/emailTemplateController.ts +++ b/backend/controllers/emailTemplateController.ts @@ -2,193 +2,206 @@ import { Request, Response } from "express"; import EmailTemplate from "../models/emailTemplateModel"; // @desc Get all emails -// @route GET /api/emails +// @route GET /api/emailTemplates // @access Public export const getEmails = async (req: Request, res: Response): Promise => { - try { - const { title } = req.query; - - let filters: any = {}; - if (title) { - filters.title = { $regex: title, $options: 'i' }; - } - - const emails = await EmailTemplate.find(filters); - - res.status(200).json({ - success: true, - count: emails.length, - data: emails, - }); - } catch (error) { - if (error instanceof Error) { - res.status(500).json({ - success: false, - message: "Server Error", - error: error.message, - }); - } else { - res.status(500).json({ - success: false, - message: "An unexpected error occurred", - }); - } - } + try { + const { subject } = req.query; + + let filters: any = {}; + if (subject) { + filters.subject = { $regex: subject, $options: "i" }; + } + + const emails = await EmailTemplate.find(filters); + + res.status(200).json({ + success: true, + count: emails.length, + data: emails, + }); + } catch (error) { + if (error instanceof Error) { + res.status(500).json({ + success: false, + message: "Server Error", + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: "An unexpected error occurred", + }); + } + } }; // @desc Get single email -// @route GET /api/emails/:id +// @route GET /api/emailTemplates/:id // @access Public -export const getEmailById = async (req: Request, res: Response): Promise => { - try { - const { id } = req.params; - - const email = await EmailTemplate.findById(id); - - if (!email) { - res.status(404).json({ - success: false, - message: `Email with id ${id} not found`, - }); - return; - } - - res.status(200).json({ - success: true, - data: email, - }); - } catch (error) { - if (error instanceof Error) { - res.status(500).json({ - success: false, - message: "Server Error", - error: error.message, - }); - } else { - res.status(500).json({ - success: false, - message: "An unexpected error occurred", - }); - } - } +export const getEmailById = async ( + req: Request, + res: Response +): Promise => { + try { + const { id } = req.params; + + const email = await EmailTemplate.findById(id); + + if (!email) { + res.status(404).json({ + success: false, + message: `Email with id ${id} not found`, + }); + return; + } + + res.status(200).json({ + success: true, + data: email, + }); + } catch (error) { + if (error instanceof Error) { + res.status(500).json({ + success: false, + message: "Server Error", + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: "An unexpected error occurred", + }); + } + } }; // @desc Create a new email -// @route POST /api/emails +// @route POST /api/emailTemplates // @access Public -export const createEmail = async (req: Request, res: Response): Promise => { - try { - const { title, body } = req.body; - - if (!title || !body) { - res.status(400).json({ - success: false, - message: "Please provide title and body", - }); - return; - } - - const newEmail = new EmailTemplate({ - title, - body, - }); - - const savedEmail = await newEmail.save(); - - res.status(201).json({ - success: true, - data: savedEmail, - }); - } catch (error) { - if (error instanceof Error) { - res.status(500).json({ - success: false, - message: "Server Error", - error: error.message, - }); - } else { - res.status(500).json({ - success: false, - message: "An unexpected error occurred", - }); - } - } +export const createEmail = async ( + req: Request, + res: Response +): Promise => { + try { + const { subject, body } = req.body; + + if (!subject || !body) { + res.status(400).json({ + success: false, + message: "Please provide subject and body", + }); + return; + } + + const newEmail = new EmailTemplate({ + subject, + body, + }); + + const savedEmail = await newEmail.save(); + + res.status(201).json({ + success: true, + data: savedEmail, + }); + } catch (error) { + if (error instanceof Error) { + res.status(500).json({ + success: false, + message: "Server Error", + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: "An unexpected error occurred", + }); + } + } }; // @desc Update an email // @route PUT /api/emails/:id // @access Public -export const updateEmail = async (req: Request, res: Response): Promise => { - try { - const { id } = req.params; - const { title, body } = req.body; - - const updatedEmail = await EmailTemplate.findByIdAndUpdate( - id, - { title, body }, - { new: true, runValidators: true } - ); - - if (!updatedEmail) { - res.status(404).json({ - success: false, - message: `Email with id ${id} not found`, - }); - return; - } - - res.status(200).json({ - success: true, - data: updatedEmail, - }); - } catch (error) { - if (error instanceof Error) { - res.status(500).json({ - success: false, - message: "Server Error", - error: error.message, - }); - } else { - res.status(500).json({ - success: false, - message: "An unexpected error occurred", - }); - } - } +export const updateEmail = async ( + req: Request, + res: Response +): Promise => { + try { + const { id } = req.params; + const { subject, body } = req.body; + + const updatedEmail = await EmailTemplate.findByIdAndUpdate( + id, + { subject, body }, + { new: true, runValidators: true } + ); + + if (!updatedEmail) { + res.status(404).json({ + success: false, + message: `Email with id ${id} not found`, + }); + return; + } + + res.status(200).json({ + success: true, + data: updatedEmail, + }); + } catch (error) { + if (error instanceof Error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Server Error", + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: "An unexpected error occurred", + }); + } + } }; // @desc Delete an email // @route DELETE /api/emails/:id // @access Public -export const deleteEmail = async (req: Request, res: Response): Promise => { - try { - const { id } = req.params; - - const deletedEmail = await EmailTemplate.findByIdAndDelete(id); - if (!deletedEmail) { - res.status(404).json({ - success: false, - message: `Email with id ${id} not found`, - }); - return; - } - - res.status(200).json({ - success: true, - data: deletedEmail, - }); - } catch (error) { - if (error instanceof Error) { - res.status(500).json({ - success: false, - message: "Server Error", - error: error.message, - }); - } else { - res.status(500).json({ - success: false, - message: "An unexpected error occurred", - }); - } - } -}; \ No newline at end of file +export const deleteEmail = async ( + req: Request, + res: Response +): Promise => { + try { + const { id } = req.params; + + const deletedEmail = await EmailTemplate.findByIdAndDelete(id); + if (!deletedEmail) { + res.status(404).json({ + success: false, + message: `Email with id ${id} not found`, + }); + return; + } + + res.status(200).json({ + success: true, + data: deletedEmail, + }); + } catch (error) { + if (error instanceof Error) { + res.status(500).json({ + success: false, + message: "Server Error", + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: "An unexpected error occurred", + }); + } + } +}; diff --git a/backend/controllers/loginController.ts b/backend/controllers/loginController.ts index 37b3429..1a4215f 100644 --- a/backend/controllers/loginController.ts +++ b/backend/controllers/loginController.ts @@ -69,7 +69,7 @@ export const createUser = async ( company, progress, payments, - role = "foster parent", // Default role + role, isColorado, // Default value } = req.body; @@ -118,6 +118,7 @@ export const createUser = async ( }); const savedUser = await newUser.save(); + await savedUser.populate("role"); // // Generate JWT token // const accessToken = jwt.sign( @@ -163,7 +164,9 @@ export const loginUser = async (req: Request, res: Response): Promise => { } // Find user in database - let user = await User.findOne({ firebaseId }); + let user = await User.findOne({ firebaseId }).populate("role"); + + console.log(user); if (!user) { res.status(404).json({ message: "User not found" }); diff --git a/backend/controllers/speakerController.ts b/backend/controllers/speakerController.ts index d0ee874..7623cc4 100644 --- a/backend/controllers/speakerController.ts +++ b/backend/controllers/speakerController.ts @@ -34,12 +34,10 @@ export const getSpeakers = async ( const speakers = await Speaker.find(filters); res.status(200).json(speakers); } catch (error) { - res - .status(500) - .json({ - message: - error instanceof Error ? error.message : "An unknown error occurred", - }); + res.status(500).json({ + message: + error instanceof Error ? error.message : "An unknown error occurred", + }); } }; @@ -51,9 +49,6 @@ export const createSpeaker = async ( res: Response ): Promise => { try { - console.log("Request body:", req.body); - console.log("Request file:", req.file); - const { name, title, email, company, bio, disclosures } = req.body; // Store image URL if an image is uploaded @@ -74,12 +69,10 @@ export const createSpeaker = async ( await speaker.save(); res.status(201).json({ speaker, message: "Speaker created successfully" }); } catch (error) { - res - .status(500) - .json({ - message: - error instanceof Error ? error.message : "An unknown error occurred", - }); + res.status(500).json({ + message: + error instanceof Error ? error.message : "An unknown error occurred", + }); } }; @@ -110,12 +103,10 @@ export const updateSpeaker = async ( res.status(200).json(updatedSpeaker); } catch (error) { - res - .status(500) - .json({ - message: - error instanceof Error ? error.message : "An unknown error occurred", - }); + res.status(500).json({ + message: + error instanceof Error ? error.message : "An unknown error occurred", + }); } }; @@ -143,11 +134,9 @@ export const deleteSpeaker = async ( .status(200) .json({ success: true, message: "Speaker deleted successfully." }); } catch (error) { - res - .status(500) - .json({ - message: - error instanceof Error ? error.message : "An unknown error occurred", - }); + res.status(500).json({ + message: + error instanceof Error ? error.message : "An unknown error occurred", + }); } }; diff --git a/backend/controllers/userController.ts b/backend/controllers/userController.ts index 10da7ef..e36614d 100644 --- a/backend/controllers/userController.ts +++ b/backend/controllers/userController.ts @@ -8,7 +8,13 @@ import { AuthenticatedRequest } from "../middlewares/authMiddleware"; export const getUsers = async (req: Request, res: Response): Promise => { try { - const { search, userType, page = 1, limit = 10 } = req.query; + const { + search, + userType, + page = 1, + limit = 10, + pagination = "true", + } = req.query; let query: any = {}; @@ -23,12 +29,27 @@ export const getUsers = async (req: Request, res: Response): Promise => { query.userType = userType; } + // If pagination=false, return all matching users + if (pagination === "false") { + const users = await User.find(query) + .select( + "name email role company certification address1 city state zip phone language certification" + ) + .populate("role"); + res.json({ users, total: users.length, pages: 1 }); + return; + } + + // Otherwise paginate const skip = (Number(page) - 1) * Number(limit); const users = await User.find(query) .skip(skip) .limit(Number(limit)) - .select("name email userType company"); + .select( + "name email role company certification address1 city state zip phone language certification" + ) + .populate("role"); const total = await User.countDocuments(query); @@ -228,12 +249,17 @@ export const checkAdmin = async (req: AuthenticatedRequest, res: Response) => { .json({ message: "Unauthorized: No user data found" }); } - const user = await User.findOne({ firebaseId: req.user.uid }); + const user = await User.findOne({ firebaseId: req.user.uid }).populate( + "role" + ); - if (!user) { - return res.status(404).json({ message: "User not found" }); + if (!user || !user.role) { + return res + .status(404) + .json({ message: "User not found or role missing" }); } - const isAdmin = user.role === "staff"; + + const isAdmin = (user.role as any).name?.toLowerCase() === "staff"; return res.status(200).json({ isAdmin }); } catch (error) { diff --git a/backend/controllers/userTypeController.ts b/backend/controllers/userTypeController.ts new file mode 100644 index 0000000..1d9bbe5 --- /dev/null +++ b/backend/controllers/userTypeController.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction, RequestHandler } from "express"; +import UserType from "../models/userTypeModel"; + +// Fix: Don't return the Response object from the controller function +export const createUserType: RequestHandler = async (req, res, next) => { + try { + const { name, cost } = req.body; + + // if ( + // !name || + // !cost || + // ["foster parent", "staff"].includes(name.toLowerCase()) + // ) { + // res.status(400).json({ message: "This role is reserved or invalid." }); + // return; + // } + + const existing = await UserType.findOne({ name }); + if (existing) { + res.status(409).json({ message: "Role already exists." }); + return; + } + + const newType = await UserType.create({ name, cost }); + res.status(201).json({ success: true, data: newType }); + } catch (err) { + next(err); // Pass errors to Express error handler + } +}; + +export const getUserTypes: RequestHandler = async (_req, res, next) => { + try { + const types = await UserType.find().sort({ name: 1 }); + res.status(200).json({ success: true, data: types }); + } catch (err) { + next(err); // Pass errors to Express error handler + } +}; + +export const deleteUserType: RequestHandler = async (req, res, next) => { + try { + const deleted = await UserType.findByIdAndDelete(req.params.id); + if (!deleted) { + res.status(404).json({ message: "User type not found" }); + return; + } + res.status(200).json({ success: true, message: "User type deleted" }); + } catch (err) { + next(err); + } +}; + +export const updateUserType: RequestHandler = async (req, res, next) => { + try { + const { name, cost } = req.body; + const updated = await UserType.findByIdAndUpdate( + req.params.id, + { name, cost }, + { new: true } + ); + if (!updated) { + res.status(404).json({ message: "User type not found" }); + return; + } + res.status(200).json({ success: true, data: updated }); + } catch (err) { + next(err); + } +}; diff --git a/backend/controllers/zoomController.ts b/backend/controllers/zoomController.ts new file mode 100644 index 0000000..494bac3 --- /dev/null +++ b/backend/controllers/zoomController.ts @@ -0,0 +1,145 @@ +import {Request, Response} from "express"; +import dotenv from "dotenv"; +dotenv.config(); +// @desc Get a bearer token +// @route GET /api/zoom/token +// @access Public +async function getToken(){ + try { + const credentials = btoa(`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`); + + const token = await fetch(`https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${process.env.ZOOM_ACCOUNT_ID}`, { + method: "POST", + headers: { + "Authorization": `Basic ${credentials}`, + "Content-Type": "application/x-www-form-urlencoded" + } + }) + return (await token.json()).access_token + } catch (error) { + console.error(error); + return undefined + } +} + +export const getMeetings = async ( + req: Request, + res: Response +): Promise => { + try { + await getToken().then(async (token) => { + const response = await fetch(`https://api.zoom.us/v2/users/${process.env.ZOOM_USER_ID}/meetings`, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/x-www-form-urlencoded", + } + }) + let meetings = await response.json() + res.status(200).json(meetings) + }) + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Internal service error", + }); + } +}; + +export const getWebinars = async ( + req: Request, + res: Response +): Promise => { + try { + await getToken().then(async (token) => { + + const response = await fetch(`https://api.zoom.us/v2/users/${process.env.ZOOM_USER_ID}/webinars`, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/x-www-form-urlencoded", + } + }) + let webinars = (await response.json()).webinars + res.status(200).json({ + webinars: webinars + }) + }) + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Internal service error", + }); + } +}; + +export const createMeeting = async ( + req: Request, + res: Response +): Promise => { + const { topic, startTime, duration } = req.body; + try { + await getToken().then(async (token) => { + + const response = await fetch(`https://api.zoom.us/v2/users/${process.env.ZOOM_USER_ID}/meetings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ + topic, + start_time: startTime, + duration: duration + }) + }); + let meeting = await response.json() + console.log(meeting) + res.status(200).json({ + meeting: meeting + }) + }) + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Internal service error", + }); + } +}; + +export const createWebinar = async ( + req: Request, + res: Response +): Promise => { + const { topic, startTime, duration } = req.body; + try { + await getToken().then(async (token) => { + const response = await fetch(`https://api.zoom.us/v2/users/${process.env.ZOOM_USER_ID}/webinars`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ + topic, + start_time: startTime, + duration: duration + }) + }); + let webinar = await response.json() + console.log(webinar) + res.status(200).json({ + webinar: webinar + }) + }) + } catch (error) { + console.error(error); + res.status(500).json({ + success: false, + message: "Internal service error", + }); + } +}; \ No newline at end of file diff --git a/backend/jobs/emailQueue.ts b/backend/jobs/emailQueue.ts new file mode 100644 index 0000000..51b49a5 --- /dev/null +++ b/backend/jobs/emailQueue.ts @@ -0,0 +1,9 @@ +// jobs/emailQueue.ts +import { Queue } from "bullmq"; +import { Redis } from "ioredis"; + +const connection = new Redis(process.env.REDIS_URL!, { + maxRetriesPerRequest: null, +}); + +export const emailQueue = new Queue("emailQueue", { connection }); diff --git a/backend/models/courseModel.ts b/backend/models/courseModel.ts index 0ce7f93..c9708b5 100644 --- a/backend/models/courseModel.ts +++ b/backend/models/courseModel.ts @@ -9,7 +9,6 @@ export interface ICourse extends Document { ratings: (mongoose.Types.ObjectId | IRating)[]; className: string; discussion: string; - components: (mongoose.Types.ObjectId | Object)[]; isLive: boolean; categories: string[]; creditNumber: number; @@ -20,16 +19,14 @@ export interface ICourse extends Document { instructorName: string; instructorDescription: string; instructorRole: string; - courseType: "webinar" | "course" | "meeting"; - lengthCourse: number; - time: Date; - isInPerson: boolean; students: (mongoose.Types.ObjectId | IUser)[]; //for the users managers: (mongoose.Types.ObjectId | IUser)[]; speakers: (mongoose.Types.ObjectId | ISpeaker)[]; regStart: Date; regEnd: Date; - productType: string[]; + //Virtual Training - Live Meeting, In-Person Training, Virtual Training - On Demand, Virtual Training - Live Webinar + productType: string; + productInfo: string shortUrl: string; draft: boolean; } @@ -50,8 +47,6 @@ const CourseSchema: Schema = new Schema( ], className: { type: String, required: true }, discussion: { type: String, required: false }, - components: [{ type: Schema.Types.Mixed, required: false }], - isLive: { type: Boolean, required: false }, categories: [{ type: String, required: false }], creditNumber: { type: Number, required: false }, courseDescription: { type: String, required: false }, @@ -60,7 +55,6 @@ const CourseSchema: Schema = new Schema( cost: { type: Number, required: false }, instructorDescription: { type: String, required: false }, instructorRole: { type: String, required: false }, - lengthCourse: { type: Number, required: false }, time: { type: Date, required: false }, instructorName: { type: String, required: false }, isInPerson: { type: Boolean, required: false }, @@ -70,11 +64,6 @@ const CourseSchema: Schema = new Schema( ref: "User", }, ], - courseType: { - type: String, - enum: ["webinar", "course", "meeting"], - required: true, - }, managers: [ { type: Schema.Types.ObjectId, @@ -89,7 +78,8 @@ const CourseSchema: Schema = new Schema( ], regStart: { type: Date, required: false }, regEnd: { type: Date, required: false }, - productType: [{ type: String, required: false }], + productType: { type: String, required: false }, + productInfo: {type:String, required: false}, shortUrl: { type: String, required: false }, draft: { type: Boolean, required: true, default: true }, }, diff --git a/backend/models/emailModel.ts b/backend/models/emailModel.ts new file mode 100644 index 0000000..1f848e6 --- /dev/null +++ b/backend/models/emailModel.ts @@ -0,0 +1,52 @@ +import mongoose, { Schema, Document, Model } from "mongoose"; +import { ICourse } from "./courseModel"; + +// Define the interface for the document +export interface IEmail extends Document { + course: mongoose.Types.ObjectId | ICourse; + subject: string; + body: string; + sendDate: Date; + sent: boolean; + // templateVars: Record; +} + +// Define the schema +const emailSchema: Schema = new Schema( + { + course: { + type: mongoose.Schema.Types.ObjectId, + ref: "Course", + required: true, + }, + subject: { + type: String, + required: true, + }, + body: { + type: String, // This stores HTML or template string like "

Hello {{name}}

" + required: true, + }, + sendDate: { + type: Date, + required: true, + }, + sent: { + type: Boolean, + required: true, + default: false, + }, + // templateVars: { + // type: Schema.Types.Mixed, // Allows flexibility for key-value pairs + // default: {}, + // }, + }, + { + timestamps: true, + } +); + +// Export the model +const Email: Model = mongoose.model("Email", emailSchema); + +export default Email; diff --git a/backend/models/emailTemplateModel.ts b/backend/models/emailTemplateModel.ts index a07fc65..0b4de22 100644 --- a/backend/models/emailTemplateModel.ts +++ b/backend/models/emailTemplateModel.ts @@ -1,22 +1,25 @@ import mongoose, { Schema, Document, Model } from "mongoose"; -export interface IEmail extends Document { - title: string; - body: string; - createdAt?: Date; - updatedAt?: Date; +export interface IEmailTemplate extends Document { + subject: string; + body: string; + createdAt?: Date; + updatedAt?: Date; } -const emailSchema: Schema = new Schema( - { - title: { type: String, required: true }, - body: { type: String, required: true }, - }, - { - timestamps: true, - } +const emailTemplateSchema: Schema = new Schema( + { + subject: { type: String, required: true }, + body: { type: String, required: true }, + }, + { + timestamps: true, + } ); -const EmailTemplate: Model = mongoose.model("Email", emailSchema); +const EmailTemplate: Model = mongoose.model( + "EmailTemplate", + emailTemplateSchema +); -export default EmailTemplate; \ No newline at end of file +export default EmailTemplate; diff --git a/backend/models/userModel.ts b/backend/models/userModel.ts index f0a86c0..e4f67af 100644 --- a/backend/models/userModel.ts +++ b/backend/models/userModel.ts @@ -1,23 +1,13 @@ import mongoose, { Schema, Document, Model } from "mongoose"; import { IProgress } from "./progressModel"; import { IPayment } from "./paymentModel"; +import { IUserType } from "./userTypeModel"; export interface IUser extends Document { firebaseId: string; email: string; isColorado: boolean; - role: - | "foster parent" - | "certified kin" - | "non-certified kin" - | "staff" - | "casa" - | "teacher" - | "county/cpa worker" - | "speaker" - | "former parent" - | "caregiver"; - // TODO: update after user types can be created in admin + role: mongoose.Types.ObjectId | IUserType; name: string; address1: string; address2?: string; @@ -39,20 +29,8 @@ const userSchema: Schema = new Schema( email: { type: String, required: true }, isColorado: { type: Boolean, required: true }, role: { - type: String, - enum: [ - "foster parent", - "certified kin", - "non-certified kin", - "staff", - "casa", - "teacher", - "county/cpa worker", - "speaker", - "former parent", - "caregiver", - ], - required: true, + type: Schema.Types.ObjectId, + ref: "UserType", }, name: { type: String, required: true }, address1: { type: String, required: true }, @@ -63,7 +41,11 @@ const userSchema: Schema = new Schema( certification: { type: String, required: true }, company: { type: String, required: true }, phone: { type: String, required: true }, - language: { type: String, enum: ["English", "Spanish"], default: "English" }, + language: { + type: String, + enum: ["English", "Spanish"], + default: "English", + }, progress: [ { type: Schema.Types.ObjectId, diff --git a/backend/models/userTypeModel.ts b/backend/models/userTypeModel.ts new file mode 100644 index 0000000..1e75e1a --- /dev/null +++ b/backend/models/userTypeModel.ts @@ -0,0 +1,21 @@ +// backend/models/userTypeModel.ts +import mongoose, { Schema, Document, Model } from "mongoose"; + +export interface IUserType extends Document { + name: string; + cost: number; +} + +const userTypeSchema: Schema = new Schema( + { + name: { type: String, required: true, unique: true }, + cost: { type: Number, required: true }, + }, + { timestamps: true } +); + +const UserType: Model = mongoose.model( + "UserType", + userTypeSchema +); +export default UserType; diff --git a/backend/package-lock.json b/backend/package-lock.json index 5cf3ebb..cecddb3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,20 +8,25 @@ "name": "backend", "version": "1.0.0", "dependencies": { + "@sendgrid/mail": "^8.1.4", "axios": "^1.8.4", "backend": "file:", + "bullmq": "^5.49.2", "cloudinary": "^1.41.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", "firebase": "^11.0.1", "firebase-admin": "^12.7.0", + "handlebars": "^4.7.8", + "ioredis": "^5.6.1", "mongoose": "^8.7.0", "multer": "^1.4.5-lts.1", "multer-storage-cloudinary": "^4.0.0", "paypal-rest-sdk": "^1.8.1", - "react-select-country-list": "^2.2.3", "pdfkit": "^0.16.0", + "react-select-country-list": "^2.2.3", + "resend": "^4.4.0", "ts-node": "^10.9.2" }, "devDependencies": { @@ -1261,6 +1266,11 @@ "node": ">=6" } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1628,6 +1638,78 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -1691,6 +1773,70 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@react-email/render": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.6.tgz", + "integrity": "sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ==", + "dependencies": { + "html-to-text": "9.0.5", + "prettier": "3.5.3", + "react-promise-suspense": "0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/@sendgrid/client": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.4.tgz", + "integrity": "sha512-VxZoQ82MpxmjSXLR3ZAE2OWxvQIW2k2G24UeRPr/SYX8HqWLV/8UBN15T2WmjjnEb5XSmFImTJOKDzzSeKr9YQ==", + "dependencies": { + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.7.4" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@sendgrid/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", + "dependencies": { + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@sendgrid/mail": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.4.tgz", + "integrity": "sha512-MUpIZykD9ARie8LElYCqbcBhGGMaA/E6I7fEcG7Hc2An26QJyLtwOaKQ3taGp8xO8BICPJrSKuYV4bDeAJKFGQ==", + "dependencies": { + "@sendgrid/client": "^8.1.4", + "@sendgrid/helpers": "^8.0.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2591,6 +2737,43 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/bullmq": { + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.49.2.tgz", + "integrity": "sha512-LPtQ5+dTNTJi/TcmWt+kd7zWl4Okz6rEvidwphyuzcMwQMP8l26qQcguVxeN8fZ9z9O6BoksZfsWExdtAr0wRg==", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.4.1", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^9.0.0" + } + }, + "node_modules/bullmq/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2756,6 +2939,14 @@ "lodash": ">=4.0" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2965,6 +3156,17 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", @@ -3011,7 +3213,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3040,6 +3241,14 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3057,6 +3266,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3100,6 +3318,57 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -3191,6 +3460,17 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4085,6 +4365,26 @@ "node": ">=14.0.0" } }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "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-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4169,6 +4469,39 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4343,6 +4676,50 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ioredis": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5334,6 +5711,14 @@ "node": ">=6" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -5400,11 +5785,21 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -5480,6 +5875,14 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -5856,6 +6259,35 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/msgpackr": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", + "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "1.4.5-lts.2", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", @@ -5896,6 +6328,11 @@ "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==" + }, "node_modules/new-find-package-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", @@ -5934,6 +6371,11 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -5984,6 +6426,20 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6155,6 +6611,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6235,6 +6703,14 @@ "png-js": "^1.0.0" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -6287,10 +6763,9 @@ "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", - "dev": true, + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "bin": { "prettier": "bin/prettier.cjs" }, @@ -6468,12 +6943,46 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/react-promise-suspense": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", + "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==" + }, "node_modules/react-select-country-list": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-select-country-list/-/react-select-country-list-2.2.3.tgz", @@ -6494,6 +7003,25 @@ "node": ">= 6" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6502,6 +7030,17 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-4.4.0.tgz", + "integrity": "sha512-SmVI3JCpgPNt4/m3Uy403LjoSeeleUE2X+KwPYQZcw+jiBCFsqL6vdf1r/XuQ7yOjvxYmlI8GD/oIWonFF9t9w==", + "dependencies": { + "@react-email/render": "1.0.6" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6602,6 +7141,23 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "peer": true + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6750,7 +7306,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6791,6 +7346,11 @@ "node": ">=10" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -7334,6 +7894,18 @@ "node": ">=14.17" } }, + "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==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -7515,6 +8087,11 @@ "node": ">= 8" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index d06d992..c94b496 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,23 +6,29 @@ "test": "jest", "dev": "ts-node server.ts", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "worker": "ts-node workers/emailWorker.ts" }, "dependencies": { + "@sendgrid/mail": "^8.1.4", "axios": "^1.8.4", "backend": "file:", + "bullmq": "^5.49.2", "cloudinary": "^1.41.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", "firebase": "^11.0.1", "firebase-admin": "^12.7.0", + "handlebars": "^4.7.8", + "ioredis": "^5.6.1", "mongoose": "^8.7.0", "multer": "^1.4.5-lts.1", "multer-storage-cloudinary": "^4.0.0", "paypal-rest-sdk": "^1.8.1", - "react-select-country-list": "^2.2.3", "pdfkit": "^0.16.0", + "react-select-country-list": "^2.2.3", + "resend": "^4.4.0", "ts-node": "^10.9.2" }, "devDependencies": { diff --git a/backend/routes/emailRoutes.ts b/backend/routes/emailRoutes.ts index 712e4ab..c0543ee 100644 --- a/backend/routes/emailRoutes.ts +++ b/backend/routes/emailRoutes.ts @@ -1,27 +1,27 @@ import express from "express"; import { - getEmails, - getEmailById, - createEmail, - updateEmail, - deleteEmail, -} from "../controllers/emailTemplateController"; + getEmails, + createEmail, + updateEmail, + deleteEmail, + createAndSendEmail, +} from "../controllers/emailController"; const router = express.Router(); // GET all emails or filter by query parameters router.get("/", getEmails); -// GET single email by ID -router.get("/:id", getEmailById); - // POST new email router.post("/", createEmail); +// Create and send a new email +router.post("/send", createAndSendEmail); + // PUT update email by ID router.put("/:id", updateEmail); // DELETE email by ID router.delete("/:id", deleteEmail); -export default router; \ No newline at end of file +export default router; diff --git a/backend/routes/emailTemplateRoutes.ts b/backend/routes/emailTemplateRoutes.ts new file mode 100644 index 0000000..712e4ab --- /dev/null +++ b/backend/routes/emailTemplateRoutes.ts @@ -0,0 +1,27 @@ +import express from "express"; +import { + getEmails, + getEmailById, + createEmail, + updateEmail, + deleteEmail, +} from "../controllers/emailTemplateController"; + +const router = express.Router(); + +// GET all emails or filter by query parameters +router.get("/", getEmails); + +// GET single email by ID +router.get("/:id", getEmailById); + +// POST new email +router.post("/", createEmail); + +// PUT update email by ID +router.put("/:id", updateEmail); + +// DELETE email by ID +router.delete("/:id", deleteEmail); + +export default router; \ No newline at end of file diff --git a/backend/routes/userTypeRoutes.ts b/backend/routes/userTypeRoutes.ts new file mode 100644 index 0000000..d9a9f37 --- /dev/null +++ b/backend/routes/userTypeRoutes.ts @@ -0,0 +1,17 @@ +// backend/routes/userTypeRoutes.ts +import express from "express"; +import { + createUserType, + getUserTypes, + deleteUserType, + updateUserType, +} from "../controllers/userTypeController"; + +const router = express.Router(); + +router.post("/", createUserType); +router.get("/", getUserTypes); +router.put("/:id", updateUserType); +router.delete("/:id", deleteUserType); + +export default router; diff --git a/backend/routes/zoomRoutes.ts b/backend/routes/zoomRoutes.ts new file mode 100644 index 0000000..e4cbaae --- /dev/null +++ b/backend/routes/zoomRoutes.ts @@ -0,0 +1,15 @@ +import express from "express"; +import { + createMeeting, createWebinar, + getMeetings, getWebinars +} from "../controllers/zoomController"; + +const router = express.Router(); + +router.get("/meetings", getMeetings); +router.get("/webinars", getWebinars); + +router.post("/meeting", createMeeting) +router.post("/webinar", createWebinar) + +export default router; diff --git a/backend/workers/emailWorker.ts b/backend/workers/emailWorker.ts new file mode 100644 index 0000000..c1fe1b0 --- /dev/null +++ b/backend/workers/emailWorker.ts @@ -0,0 +1,87 @@ +import { Worker } from "bullmq"; +import { Redis } from "ioredis"; +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import Email from "../models/emailModel"; +import User from "../models/userModel"; +import { sendEmail } from "../config/resend"; +import { ICourse } from "../models/courseModel"; +import connectDB from "../config/db"; +import "../models/courseModel"; +import { emailQueue } from "../jobs/emailQueue"; + +dotenv.config(); + +const connection = new Redis(process.env.REDIS_URL!, { + maxRetriesPerRequest: null, +}); + +const recoverMissedEmails = async () => { + const unsentEmails = await Email.find({ + sendDate: { $lt: new Date() }, + wasSent: false, + }); + + for (const email of unsentEmails) { + const emailId = (email._id as mongoose.Types.ObjectId).toString(); + console.log("🔁 Re-queuing missed email:", emailId); + + const existingJob = await emailQueue.getJob(emailId); + if (existingJob) { + await existingJob.remove(); + console.log("🗑️ Removed old job for:", emailId); + } + + await emailQueue.add( + "send-course-email", + { emailId }, + { + jobId: emailId, + delay: 0, // Send immediately since it's overdue + } + ); + console.log("📬 Re-added job for:", emailId); + } +}; + +(async () => { + await connectDB(); + await recoverMissedEmails(); + + const worker = new Worker( + "emailQueue", + async (job) => { + try { + console.log("📦 Worker picked up job:", job.id, job.data); + + const email = await Email.findById(job.data.emailId).populate("course"); + if (!email) { + console.warn("⚠️ Email not found for ID:", job.data.emailId); + return; + } + + const course = email.course as ICourse; + const users = await User.find({ _id: { $in: course.students } }); + + console.log( + `👥 Sending to ${users.length} users for course ${course.className}` + ); + + for (const user of users) { + await sendEmail(user.email, email.subject, email.body, { + name: user.name, + course: course.className, + courselink: `https://yourapp.org/courses/${email.course._id}`, + }); + console.log(`📧 Sent to ${user.email}`); + } + + await Email.findByIdAndUpdate(email._id, { wasSent: true }); + console.log(`✅ Email marked as sent: ${email.subject}`); + } catch (error) { + console.error("❌ Worker error:", error); + } + }, + { connection } + ); +})(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 542299d..6aeefa4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,10 +23,12 @@ "firebase": "^11.0.1", "framer-motion": "^12.4.1", "lucide-react": "^0.473.0", + "quill": "^2.0.3", "react": "^18.3.1", "react-country-state-city": "^1.1.12", "react-dom": "^18.3.1", "react-icons": "^5.4.0", + "react-quill": "^2.0.0", "react-router-dom": "^6.29.0", "react-scripts": "^5.0.1", "react-select": "^5.10.1", @@ -4526,6 +4528,14 @@ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==" }, + "node_modules/@types/quill": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz", + "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==", + "dependencies": { + "parchment": "^1.1.2" + } + }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -6222,6 +6232,14 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -8405,11 +8423,21 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -11568,16 +11596,32 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead." + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -12334,6 +12378,11 @@ "tslib": "^2.0.3" } }, + "node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14011,6 +14060,43 @@ } ] }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/quill/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, + "node_modules/quill/node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -14230,6 +14316,75 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-quill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz", + "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==", + "dependencies": { + "@types/quill": "^1.3.10", + "lodash": "^4.17.4", + "quill": "^1.3.7" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/react-quill/node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/react-quill/node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==" + }, + "node_modules/react-quill/node_modules/fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==" + }, + "node_modules/react-quill/node_modules/quill": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", + "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "dependencies": { + "clone": "^2.1.1", + "deep-equal": "^1.0.1", + "eventemitter3": "^2.0.3", + "extend": "^3.0.2", + "parchment": "^1.1.4", + "quill-delta": "^3.6.2" + } + }, + "node_modules/react-quill/node_modules/quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "dependencies": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7d0a971..f7a2fe3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,10 +18,12 @@ "firebase": "^11.0.1", "framer-motion": "^12.4.1", "lucide-react": "^0.473.0", + "quill": "^2.0.3", "react": "^18.3.1", "react-country-state-city": "^1.1.12", "react-dom": "^18.3.1", "react-icons": "^5.4.0", + "react-quill": "^2.0.0", "react-router-dom": "^6.29.0", "react-scripts": "^5.0.1", "react-select": "^5.10.1", diff --git a/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx b/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx index 9572e3d..dfdd353 100644 --- a/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx +++ b/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx @@ -9,7 +9,7 @@ import { Star, Trash2, } from "lucide-react"; -import AdminCourseDeleteModal from "./AdminCourseDeleteModal" +import AdminCourseDeleteModal from "./AdminCourseDeleteModal"; import { Link } from "react-router-dom"; export interface Product { @@ -21,15 +21,15 @@ export interface Product { endTime: Date; timeZone: string; selected: boolean; - categories?: string[] + categories?: string[]; } interface AdminCoursePreviewProps { product: Product; toggleSelection: (id: string) => void; - category?: string | null - credit?: number | null - status?: string | null + category?: string | null; + credit?: number | null; + status?: string | null; refreshCourses: () => void; } @@ -46,47 +46,40 @@ function AdminCoursePreview({ category, credit, status, - refreshCourses + refreshCourses, }: AdminCoursePreviewProps) { - const [isLive, setIsLive] = useState(false) - const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [isLive, setIsLive] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); useEffect(() => { if (status !== null) { if (status === "Live") { - setIsLive(true) + setIsLive(true); + } else { + setIsLive(false); } - else { - setIsLive(false) - } - } - else { - setIsLive(null) + } else { + setIsLive(null); } - }, [status]) + }, [status]); const handleCloseDeleteModal = () => { setDeleteModalOpen(false); - }; + }; if (credit !== null && product.course.creditNumber !== credit) { return null; } - if (isLive !== null && product.course.isLive !== isLive) { - return null; - } - if ( category !== null && - (!product.course.categories || product.course.categories.length === 0 || - !product.course.categories.some(cat => cat === category)) + (!product.course.categories || + product.course.categories.length === 0 || + !product.course.categories.some((cat) => cat === category)) ) { return null; } - - return (
- {product.course.isLive ? "Live" : "Virtual"} Event{" "} {product.startTime.getMonth() + 1}/{product.startTime.getDate()}/ {product.startTime.getFullYear()} at {product.startTime.getHours()}: {product.startTime.getMinutes().toString().padStart(2, "0")}{" "} @@ -190,20 +182,20 @@ function AdminCoursePreview({
{deleteModalOpen && (
- +
)} diff --git a/frontend/src/components/AdminSidebar/AdminSidebar.tsx b/frontend/src/components/AdminSidebar/AdminSidebar.tsx index 633e5a3..41a08d3 100644 --- a/frontend/src/components/AdminSidebar/AdminSidebar.tsx +++ b/frontend/src/components/AdminSidebar/AdminSidebar.tsx @@ -55,8 +55,8 @@ export const mainItems: SidebarItem[] = [ { icon: , description: "Emails", href: "/admin/email" }, { icon: , - description: "Registrants", - href: "/admin/registrants", + description: "Templates", + href: "/admin/templates", }, ], }, @@ -108,7 +108,7 @@ interface AdminSidebarProps { export function AdminSidebar({ isLoggedIn, setIsLoggedIn }: AdminSidebarProps) { // User Info const name = isLoggedIn ? JSON.parse(localStorage.user).name : "Log In"; - const role = isLoggedIn ? JSON.parse(localStorage.user).role : "Log In"; + const role = isLoggedIn ? JSON.parse(localStorage.user).role.name : "Log In"; // State for tracking the active item (can be parent or sub-item href) const [activeItem, setActiveItem] = useState( @@ -132,11 +132,6 @@ export function AdminSidebar({ isLoggedIn, setIsLoggedIn }: AdminSidebarProps) { } }); - useEffect(() => { - console.log("isFocused", isFocused); - console.log("focusedItem", focusedItem); - }, [isFocused, focusedItem]); - return (
+
{item.description !== "Home" && (
)} diff --git a/frontend/src/components/EmailPreviewModal.tsx b/frontend/src/components/EmailPreviewModal.tsx new file mode 100644 index 0000000..6eee930 --- /dev/null +++ b/frontend/src/components/EmailPreviewModal.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import Modal from "./Modal"; +import { Email, EmailTemplate, MongoEmail } from "../shared/types"; +import { ALLOWED_PLACEHOLDERS } from "../shared/placeholders"; + +interface PreviewProps { + modalOpen: boolean; + onClose: () => void; + email: Email | EmailTemplate | MongoEmail | null; +} + +export default function EmailPreviewModal({ + modalOpen, + onClose, + email, +}: PreviewProps) { + // Replace placeholders with preview values + const previewBody = email?.body + ?.replaceAll("{{name}}", "Jane Doe") + .replaceAll("{{course}}", "Trauma 101") + .replaceAll("{{courselink}}", "https://fostersource.org/courses/123"); + + return ( + +
+

+ {email?.subject} +

+
+
+ + ); +} diff --git a/frontend/src/components/Sidebar/sidebar.tsx b/frontend/src/components/Sidebar/sidebar.tsx index c1b31e3..580f28b 100644 --- a/frontend/src/components/Sidebar/sidebar.tsx +++ b/frontend/src/components/Sidebar/sidebar.tsx @@ -20,10 +20,10 @@ import authService from "../../services/authService"; // User information export const userInfo = { name: "First L.", - role: localStorage.user ? localStorage.user.role : "No role", + role: localStorage.user ? JSON.parse(localStorage.user).role.name : "No role", isLoggedIn: false, isAdmin: localStorage.user - ? localStorage.user.role === "staff" + ? JSON.parse(localStorage.user).role.name === "Staff" ? true : false : false, @@ -102,7 +102,7 @@ export function Sidebar({ }: SidebarProps) { // User Info const name = isLoggedIn ? JSON.parse(localStorage.user).name : "Log In"; - const role = isLoggedIn ? JSON.parse(localStorage.user).role : "Log In"; + const role = isLoggedIn ? JSON.parse(localStorage.user).role.name : "Log In"; // Automatically collapse sidebar for narrow screens useEffect(() => { const handleResize = () => { @@ -187,7 +187,7 @@ export function SidebarItems({ cartItemCount, }: SidebarItemsProps) { const [isAdmin, setIsAdmin] = useState( - localStorage.user && JSON.parse(localStorage.user).role === "staff" + localStorage.user && JSON.parse(localStorage.user).role.name === "Staff" ); // const checkAdmin = async () => { diff --git a/frontend/src/pages/Admin/AdminPage.tsx b/frontend/src/pages/Admin/AdminPage.tsx index 3d9739d..1e4a504 100644 --- a/frontend/src/pages/Admin/AdminPage.tsx +++ b/frontend/src/pages/Admin/AdminPage.tsx @@ -77,9 +77,6 @@ export default function AdminPage() { status: "Ongoing", avgRating: calculateAverageRating(course.ratings), startTime: new Date(course.time), - endTime: new Date( - new Date(course.time).getTime() + course.lengthCourse * 60000 - ), timeZone: "(CST)", selected: false, }) diff --git a/frontend/src/pages/Admin/ComponentPage/DropDownSearch.tsx b/frontend/src/pages/Admin/ComponentPage/DropDownSearch.tsx index a51b2d8..6950ead 100644 --- a/frontend/src/pages/Admin/ComponentPage/DropDownSearch.tsx +++ b/frontend/src/pages/Admin/ComponentPage/DropDownSearch.tsx @@ -30,6 +30,14 @@ export default function SearchDropdown({ ); }, [options, search]); + useEffect(() => { + if (selected.length > 0) { + setSearch(selected[0]); // set the input text to match the selected item + } else { + setSearch(""); // clear if no selection + } + }, [selected]); + const handleSearch = (e: any) => { const value = e.target.value; setSearch(value); @@ -62,7 +70,7 @@ export default function SearchDropdown({ className="w-full border p-2 rounded" /> {showDropdown && ( -
    +
      {filteredOptions.length > 0 ? ( filteredOptions.map((option) => (
    • void; + title: string; + isEdit: boolean; + setEmails: Dispatch>; + email: MongoEmail | null; + courseId: string; + setCurrentEmail: Dispatch>; + isSingleCourse: boolean; +} + +export default function EmailModal({ + modalOpen, + onClose, + title, + isEdit, + setEmails, + email, + courseId, + setCurrentEmail, + isSingleCourse, +}: ModalProps) { + const [courseOptions, setCourseOptions] = useState< + { label: string; value: string }[] + >([]); + const [subject, setSubject] = useState(""); + const [course, setCourse] = useState([]); + const [body, setBody] = useState(""); + const [sendOption, setSendOption] = useState<"now" | "later">("now"); + const [scheduledTime, setScheduledTime] = useState(""); // ISO format + + const resetForm = () => { + setSubject(""); + setBody(""); + setCourse([]); + setSendOption("now"); + setScheduledTime(""); + if (!isSingleCourse) { + setSelectedCourseId(""); + } + }; + + const createEmail = async () => { + try { + let response; + if (sendOption === "now") { + console.log("starting api"); + response = await apiClient.post("/emails/send", { + subject, + body, + courseId: selectedCourseId, + }); + console.log("finished api call"); + } else + response = await apiClient.post("/emails/send", { + subject, + body, + courseId: selectedCourseId, + sendDate: scheduledTime, + }); + + const newEmail = response.data; + const selectedCourse = courseOptions.find( + (opt) => opt.value === selectedCourseId + ); + + setEmails((prev) => [ + { + ...newEmail, + course: { + _id: selectedCourseId, + className: selectedCourse?.label ?? "Unknown Course", + }, + }, + ...prev, + ]); + resetForm(); + setCurrentEmail({ + ...newEmail, + course: { + id: selectedCourseId, + className: selectedCourse?.label ?? "Unknown Course", + }, + }); + } catch (error) { + console.error(error); + } + }; + + const updateEmail = async () => { + console.log("inside update email function"); + console.log(email); + + if (email) { + try { + const response = await adminApiClient.put(`/emails/${email._id}`, { + subject, + body, + courseId: selectedCourseId, + sendDate: scheduledTime, + }); + + const updatedEmail = response.data; + console.log("updated email", updatedEmail); + const selectedCourse = courseOptions.find( + (opt) => opt.value === selectedCourseId + ); + + setEmails((prev) => + prev.map((t) => + t._id === updatedEmail._id + ? { + ...updatedEmail, + course: { + id: selectedCourseId, + className: selectedCourse?.label ?? "Unknown Course", + }, + } + : t + ) + ); + resetForm(); + setCurrentEmail(null); + } catch (error) { + console.error(error); + } + } + }; + + const getCourses = async () => { + try { + const response = await apiClient.get("/courses"); + const mapped = response.data.data.map((course: any) => ({ + label: course.className, + value: course._id, + })); + setCourseOptions(mapped); + } catch (error) { + console.error("Failed to fetch courses:", error); + } + }; + + const saveAsTemplate = async () => { + try { + await apiClient.post("/emailTemplates", { + subject, + body, + }); + window.alert("Template created successfully"); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + if (modalOpen) { + getCourses().then(() => { + if (isSingleCourse) { + setSelectedCourseId(courseId); + } + }); + } + }, [modalOpen]); + + const [selectedCourseId, setSelectedCourseId] = useState(""); + + useEffect(() => { + if (!isSingleCourse && course.length > 0) { + const selectedOption = courseOptions.find( + (opt) => opt.label === course[0] + ); + if (selectedOption) { + setSelectedCourseId(selectedOption.value); + } + } + }, [course, courseOptions]); + + useEffect(() => { + if (email) { + setSubject(email.subject); + setBody(email.body); + + const now = new Date(); + const sendDate = new Date(email.sendDate); + if (sendDate > now) { + setSendOption("later"); + setScheduledTime( + new Date(sendDate.getTime() - new Date().getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16) + ); + } else { + setSendOption("now"); + setScheduledTime(""); + } + + if (!isSingleCourse) { + setSelectedCourseId(email.course._id); + const courseOption = courseOptions.find( + (opt) => opt.value === email.course._id + ); + if (courseOption) setCourse([courseOption.label]); + } + } else { + resetForm(); + } + }, [email]); + + useEffect(() => { + if (email && courseOptions.length > 0) { + console.log("📩 Email course:", email.course); + console.log("📩 Email course ID:", courseId); + console.log("📚 Available course options:", courseOptions); + const courseOption = courseOptions.find((opt) => opt.value === courseId); + console.log("🎯 Matched course option:", courseOption); + if (courseOption) { + setCourse([courseOption.label]); // triggers selectedCourseId logic + } + } + }, [email, courseOptions]); + + return ( + + {isEdit ? ( + <> + ) : ( + + )} + + +
+ } + > +
+ {isSingleCourse ? ( + <> + ) : ( + opt.label)} + selected={course} + setSelected={setCourse} + placeholder="Select course" + /> + )} + + setSubject(e.target.value)} + className="border rounded-lg px-4 py-2 mb-4 w-full" + placeholder="Subject" + required + /> +
+
+ + setSendOption("now"), + }, + { + label: "Schedule for Later", + onClick: () => setSendOption("later"), + }, + ]} + /> +
+ + {sendOption === "later" && ( +
+ + setScheduledTime(e.target.value)} + className="border rounded-lg px-4 py-2 w-full" + required + /> +
+ )} +
+
+ +
+
+

Available placeholders:

+ {ALLOWED_PLACEHOLDERS.map((placeholder) => ( +

{placeholder}

+ ))} +
+ +
+ ); +} diff --git a/frontend/src/pages/Admin/EmailPage/EmailPage.tsx b/frontend/src/pages/Admin/EmailPage/EmailPage.tsx index f096c11..53b5e45 100644 --- a/frontend/src/pages/Admin/EmailPage/EmailPage.tsx +++ b/frontend/src/pages/Admin/EmailPage/EmailPage.tsx @@ -1,42 +1,81 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Search, Edit2, Trash2 } from "lucide-react"; +import EmailModel from "./EmailModal"; +import EmailModal from "./EmailModal"; +import adminApiClient from "../../../services/adminApiClient"; +import { Email, MongoEmail } from "../../../shared/types"; +import apiClient from "../../../services/apiClient"; +import EmailPreviewModal from "../../../components/EmailPreviewModal"; +import { useParams } from "react-router-dom"; -interface Email { - id: number; - subject: string; - product: string; - sendTime: string; - selected: boolean; -} - -export default function EmailPage() { +export default function EmailPage({ + isSingleCourse, +}: { + isSingleCourse: boolean; +}) { const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); const [searchQuery, setSearchQuery] = useState(""); - const [emails, setEmails] = useState([ - ...Array(50) - .fill(null) - .map((_, i) => ({ - id: i + 1, - subject: - "Details for Tomorrow #" + - (i + 1) + - ": Please READ: This is going to be AWESOME!", - product: - "Avoiding Head Trauma and Early Exposure - Live Virtual (01/18/2025)", - sendTime: "Scheduled: 02/15/2025 13:00", - selected: false, - })), - ]); + const [emails, setEmails] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [isEdit, setIsEdit] = useState(false); + const [currentEmail, setCurrentEmail] = useState(null); + + const [previewModalOpen, setPreviewModalOpen] = useState(false); const selectedCount = emails.filter((e) => e.selected).length; - const handleDelete = (id: number) => { - const updatedEmails = emails.filter((e) => e.id !== id); - setEmails(updatedEmails); - const totalPages = Math.ceil(updatedEmails.length / itemsPerPage); - if (currentPage > totalPages) { - setCurrentPage(totalPages > 0 ? totalPages : 1); + const handleDelete = async (id: string, wasSent: boolean) => { + try { + const confirmMessage = wasSent + ? "This email has already been sent. Deleting it will NOT unsend the email. Do you still want to delete it?" + : "Are you sure you want to delete this email?"; + + const confirmed = window.confirm(confirmMessage); + if (!confirmed) return; + + await apiClient.delete(`/emails/${id}`); + const updatedEmails = emails.filter((e) => e._id !== id); + setEmails(updatedEmails); + const totalPages = Math.ceil(updatedEmails.length / itemsPerPage); + if (currentPage > totalPages) { + setCurrentPage(totalPages > 0 ? totalPages : 1); + } + } catch (error) { + console.error(error); + } + }; + + const handleBulkDelete = async () => { + const selectedEmails = emails.filter((e) => e.selected); + + if (selectedEmails.length === 0) return; + + const hasSentEmails = selectedEmails.some( + (e) => new Date(e.sendDate) < new Date() + ); + + const confirmMessage = hasSentEmails + ? "Some emails have already been sent. Deleting them will NOT unsend them. Do you still want to delete the selected emails?" + : "Are you sure you want to delete the selected emails?"; + + const confirmed = window.confirm(confirmMessage); + if (!confirmed) return; + + try { + await Promise.all( + selectedEmails.map((email) => apiClient.delete(`/emails/${email._id}`)) + ); + + const updatedEmails = emails.filter((e) => !e.selected); + setEmails(updatedEmails); + + const totalPages = Math.ceil(updatedEmails.length / itemsPerPage); + if (currentPage > totalPages) { + setCurrentPage(totalPages > 0 ? totalPages : 1); + } + } catch (error) { + console.error("Error deleting selected emails:", error); } }; @@ -57,6 +96,38 @@ export default function EmailPage() { } }; + const { id: courseId } = useParams(); + + const fetchEmails = async () => { + try { + let response; + if (isSingleCourse) { + response = await apiClient.get("/emails", { + params: { course: courseId }, + }); + } else { + response = await adminApiClient.get("/emails"); + } + + const transformed = response.data.map((email: any) => ({ + ...email, + id: email._id, + })); + + setEmails(transformed); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + fetchEmails(); + }, []); + + useEffect(() => { + console.log("current email:", currentEmail); + }, [currentEmail]); + return (
@@ -66,26 +137,22 @@ export default function EmailPage() {
-
- - {selectedCount} Selected - - +
+ {selectedCount === 0 ? ( + <> + ) : ( +
+ + {selectedCount} Selected + + +
+ )}
@@ -99,7 +166,11 @@ export default function EmailPage() { @@ -132,7 +203,7 @@ export default function EmailPage() { {displayedEmails.map((email) => ( @@ -142,7 +213,7 @@ export default function EmailPage() { onChange={() => { setEmails( emails.map((e) => - e.id === email.id + e._id === email._id ? { ...e, selected: !e.selected } : e ) @@ -156,20 +227,50 @@ export default function EmailPage() { {email.subject} - {email.product} + {email.course.className} - {email.sendTime} + {new Date(email.sendDate) < new Date() + ? "Sent on " + : "Scheduled for "} + {new Date(email.sendDate).toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + })}
- - - + )} + +
@@ -216,6 +317,24 @@ export default function EmailPage() { )}
+ setModalOpen(false)} + title={isEdit ? "Edit Email" : "Create New Email"} + isEdit={isEdit} + setEmails={setEmails} + email={currentEmail} + courseId={ + isSingleCourse ? courseId || "" : currentEmail?.course._id || "" + } + setCurrentEmail={setCurrentEmail} + isSingleCourse={isSingleCourse} + /> + setPreviewModalOpen(false)} + email={currentEmail} + />
); } diff --git a/frontend/src/pages/Admin/EmailTemplatePage/EmailTemplateModal.tsx b/frontend/src/pages/Admin/EmailTemplatePage/EmailTemplateModal.tsx new file mode 100644 index 0000000..0b48321 --- /dev/null +++ b/frontend/src/pages/Admin/EmailTemplatePage/EmailTemplateModal.tsx @@ -0,0 +1,155 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import Modal from "../../../components/Modal"; +import adminApiClient from "../../../services/adminApiClient"; +import SearchDropdown from "../ComponentPage/DropDownSearch"; +import ReactQuill from "react-quill"; +import "react-quill/dist/quill.snow.css"; +import Dropdown from "../../../components/dropdown-select"; +import "../EmailPage/EmailModal.css"; +import { ALLOWED_PLACEHOLDERS } from "../../../shared/placeholders"; +import apiClient from "../../../services/apiClient"; +import { EmailTemplate } from "../../../shared/types"; + +interface ModalProps { + modalOpen: boolean; + onClose: () => void; + title: string; + isEdit: boolean; + templates: EmailTemplate[]; + setTemplates: Dispatch>; + template: EmailTemplate | null; +} + +export default function EmailTemplateModal({ + modalOpen, + onClose, + title, + isEdit, + templates, + setTemplates, + template, +}: ModalProps) { + const [subject, setSubject] = useState(""); + const [body, setBody] = useState(""); + + const createEmailTemplate = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + const response = await apiClient.post("/emailTemplates", { + subject, + body, + }); + + const newTemplate = response.data.data; + + setTemplates((prev) => [...prev, newTemplate]); + + onClose(); + } catch (error) { + console.error(error); + } + }; + + const updateEmailTemplate = async (e: React.FormEvent) => { + e.preventDefault(); + + console.log(template); + + if (template) { + try { + const response = await apiClient.put( + `/emailTemplates/${template._id}`, + { + subject, + body, + } + ); + + const updatedTemplate = response.data.data; + + setTemplates((prev) => + prev.map((t) => (t._id === updatedTemplate._id ? updatedTemplate : t)) + ); + + onClose(); + } catch (error) { + console.error(error); + } + } + }; + + useEffect(() => { + if (isEdit && template) { + setSubject(template.subject); + setBody(template.body); + } else { + setSubject(""); + setBody(""); + } + }, [isEdit, template]); + + return ( + + + +
+ } + > +
+ setSubject(e.target.value)} + className="border rounded-lg px-4 py-2 mb-4 w-full" + placeholder="Subject" + required + /> +
+ +
+
+

Available placeholders:

+ {ALLOWED_PLACEHOLDERS.map((placeholder) => ( +

{placeholder}

+ ))} +
+
+ + ); +} diff --git a/frontend/src/pages/Admin/EmailTemplatePage/EmailTemplates.tsx b/frontend/src/pages/Admin/EmailTemplatePage/EmailTemplates.tsx new file mode 100644 index 0000000..c9790a0 --- /dev/null +++ b/frontend/src/pages/Admin/EmailTemplatePage/EmailTemplates.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from "react"; +import apiClient from "../../../services/apiClient"; +import { Edit2, Search, Trash2 } from "lucide-react"; +import EmailTemplateModal from "./EmailTemplateModal"; +import { EmailTemplate } from "../../../shared/types"; +import EmailPreviewModal from "../../../components/EmailPreviewModal"; + +export default function EmailTemplates() { + const [templates, setTemplates] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [isEdit, setIsEdit] = useState(false); + const [currentTemplate, setCurrentTemplate] = useState( + null + ); + const [previewOpen, setPreviewOpen] = useState(false); + + const getTemplates = async () => { + try { + const response = await apiClient.get("/emailTemplates"); + setTemplates(response.data.data); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + getTemplates(); + }, []); + + const handleDelete = async (id: string) => { + try { + await apiClient.delete(`/emailTemplates/${id}`); + setTemplates((prevTemplates) => + prevTemplates.filter((template) => template._id !== id) + ); + } catch (error) { + console.error(error); + } + }; + + return ( +
+
+
+
+

Email Templates

+
+
+ +
+
+ + + + + + + + + + {templates.map((template) => ( + + + + + + ))} + +
+ Email Template Name + + Actions +
+ {template.subject} + +
+ + + +
+
+
+
+
+ setModalOpen(false)} + modalOpen={modalOpen} + isEdit={isEdit} + title={isEdit ? "Edit Template" : "Create New Template"} + templates={templates} + setTemplates={setTemplates} + template={currentTemplate} + /> + setPreviewOpen(false)} + modalOpen={previewOpen} + email={currentTemplate} + /> +
+ ); +} diff --git a/frontend/src/pages/Admin/ProductPage/ProductPage.tsx b/frontend/src/pages/Admin/ProductPage/ProductPage.tsx index 09cf315..c77d655 100644 --- a/frontend/src/pages/Admin/ProductPage/ProductPage.tsx +++ b/frontend/src/pages/Admin/ProductPage/ProductPage.tsx @@ -256,9 +256,6 @@ export default function ProductPage() { status: "Ongoing", avgRating: calculateAverageRating(course.ratings), startTime: new Date(course.time), - endTime: new Date( - new Date(course.time).getTime() + course.lengthCourse * 60000 - ), timeZone: "(CST)", selected: false, categories: categories, diff --git a/frontend/src/pages/Admin/SurveyPage/Survey.tsx b/frontend/src/pages/Admin/SurveyPage/Survey.tsx index 07bd601..8acacf7 100644 --- a/frontend/src/pages/Admin/SurveyPage/Survey.tsx +++ b/frontend/src/pages/Admin/SurveyPage/Survey.tsx @@ -24,9 +24,7 @@ const Survey = () => { useEffect(() => { const fetchSurvey = async () => { try { - const response = await apiClient.get( - "http://localhost:5001/api/surveys" - ); // Fetch the existing survey + const response = await apiClient.get("/surveys"); // Fetch the existing survey const surveyData = response.data; console.log(response.data); @@ -166,16 +164,13 @@ const Survey = () => { questions.map(async (q) => { if (q.isEdited) { // If question is edited, create a new question - const response = await apiClient.post( - "http://localhost:5001/api/questions", - { - question: q.question, - explanation: q.explanation, - answerType: q.answerType, - answers: q.answers || [], - isRequired: q.isRequired, - } - ); + const response = await apiClient.post("/questions", { + question: q.question, + explanation: q.explanation, + answerType: q.answerType, + answers: q.answers || [], + isRequired: q.isRequired, + }); return response.data._id; // Return new question's ID } else { // If question is not edited, return the existing question ID @@ -192,7 +187,7 @@ const Survey = () => { if (surveyId) { // If surveyId exists, update the existing survey const response = await apiClient.put( - `http://localhost:5001/api/surveys/${surveyId}`, + `/surveys/${surveyId}`, surveyData ); alert("Survey saved successfully!"); diff --git a/frontend/src/pages/Admin/UserManagementPage/Users.tsx b/frontend/src/pages/Admin/UserManagementPage/Users.tsx index ecddeca..e6e432d 100644 --- a/frontend/src/pages/Admin/UserManagementPage/Users.tsx +++ b/frontend/src/pages/Admin/UserManagementPage/Users.tsx @@ -11,6 +11,7 @@ import { Pagination } from "../../../components/Pagination/Pagination"; import apiClient from "../../../services/apiClient"; import Select from "react-select"; import countryList from "react-select-country-list"; +import { UserType } from "../../../shared/types"; type IconProps = SVGProps; @@ -26,7 +27,7 @@ interface User { lastName: string; name?: string; email: string; - userType: string; + userType: UserType | null; company: string; addressLine?: string; city?: string; @@ -37,9 +38,9 @@ interface User { country?: string; phoneNumber?: string; phone?: string; - timezone?: string; language: "English" | "Spanish"; selected?: boolean; + certification?: string; } interface UserForm { @@ -53,9 +54,9 @@ interface UserForm { zipPostalCode: string; country: string; phoneNumber: string; - userType: string; - timezone: string; + userType: UserType | null; language: "English" | "Spanish"; + certification?: string; } interface SpeakerProduct { @@ -63,24 +64,6 @@ interface SpeakerProduct { onDemand: boolean; } -const USER_TYPES = [ - "Foster Parent (Colorado)", - "CASA", - "County/CPA Worker", - "Former FP/Adoptive Parent/Not currently fostering", - "Foster Source Staff", - "Teacher", - "Speaker", - "Foster Parent (Outside Colorado)", - "Certified Kin Parent (Colorado)", - "Certified Kin Parent (Outside Colorado)", - "Non-certified Kin Parent (Colorado)", - "Non-certified Kin Parent (Outside Colorado)", - "New Mexico Misc.", - "Foster Parent (New Mexico)", - "Foster Source Admin", -]; - const SPEAKER_PRODUCTS: SpeakerProduct[] = [ { title: "Connecting with Teens", onDemand: true }, { title: "Parenting Children with Attachment Struggles", onDemand: true }, @@ -90,14 +73,14 @@ const SPEAKER_PRODUCTS: SpeakerProduct[] = [ const UserManagementPage: React.FC = () => { const [searchTerm, setSearchTerm] = useState(""); - const [selectedUserType, setSelectedUserType] = useState("All"); + const [selectedUserType, setSelectedUserType] = useState( + null + ); const [isUserModalOpen, setIsUserModalOpen] = useState(false); const [isSpeakerModalOpen, setIsSpeakerModalOpen] = useState(false); const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage] = useState(10); + const [itemsPerPage] = useState(20); const [users, setUsers] = useState([]); - const [totalUsers, setTotalUsers] = useState(0); - const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(false); const [userForm, setUserForm] = useState({ firstName: "", @@ -110,34 +93,36 @@ const UserManagementPage: React.FC = () => { zipPostalCode: "", country: "", phoneNumber: "", - userType: "", - timezone: "", + userType: null, language: "English", + certification: "", }); const [editingUserId, setEditingUserId] = useState(null); const [currentSpeaker, setCurrentSpeaker] = useState(null); + const [userType, setUserType] = useState(); + const [userTypes, setUserTypes] = useState([]); + + const fetchUserTypes = async () => { + try { + const response = await apiClient.get("/user-types"); + setUserTypes(response.data.data); + } catch (error) { + console.error(error); + } + }; + useEffect(() => { + fetchUserTypes(); fetchUsers(); - }, [currentPage, selectedUserType]); + }, []); const fetchUsers = async () => { setIsLoading(true); try { - const params = new URLSearchParams(); - - if (searchTerm) { - params.append("search", searchTerm); - } - - if (selectedUserType !== "All") { - params.append("userType", selectedUserType); - } + const response = await apiClient.get(`/users?pagination=false`); // no params - params.append("page", currentPage.toString()); - params.append("limit", itemsPerPage.toString()); - - const response = await apiClient.get(`/users?${params.toString()}`); + console.log(response.data); const mappedUsers = response.data.users.map((user: any) => ({ _id: user._id, @@ -146,7 +131,7 @@ const UserManagementPage: React.FC = () => { lastName: user.name?.split(" ")[1] || "", name: user.name, email: user.email, - userType: user.userType || "", + userType: user.role || user.userType || "", company: user.company || "", addressLine: user.address1 || "", city: user.city || "", @@ -155,12 +140,11 @@ const UserManagementPage: React.FC = () => { country: user.country || "", phoneNumber: user.phone || "", language: user.language || "English", + certification: user.certification || "", selected: false, })); setUsers(mappedUsers); - setTotalUsers(response.data.total); - setTotalPages(response.data.pages); } catch (error) { console.error("Error fetching users:", error); setUsers([]); @@ -199,6 +183,7 @@ const UserManagementPage: React.FC = () => { country: userData.country, phone: userData.phoneNumber, language: userData.language, + certification: userData.certification, }; }; @@ -229,6 +214,7 @@ const UserManagementPage: React.FC = () => { country: userForm.country, phoneNumber: userForm.phoneNumber, language: userForm.language, + certification: userForm.certification, } : user ) @@ -237,7 +223,7 @@ const UserManagementPage: React.FC = () => { const response = await apiClient.post("/users", { ...backendUserData, - role: userForm.userType.includes("Admin") ? "staff" : "user", + // role: userForm.userType.includes("Admin") ? "staff" : "user", }); const newUser = { @@ -256,6 +242,7 @@ const UserManagementPage: React.FC = () => { country: userForm.country, phoneNumber: userForm.phoneNumber, language: userForm.language, + certification: userForm.certification, selected: false, }; @@ -283,9 +270,9 @@ const UserManagementPage: React.FC = () => { zipPostalCode: "", country: "", phoneNumber: "", - userType: "", - timezone: "", + userType: null, language: "English", + certification: "", }); }; @@ -302,8 +289,8 @@ const UserManagementPage: React.FC = () => { country: user.country || "", phoneNumber: user.phoneNumber || "", userType: user.userType, - timezone: user.timezone || "", language: user.language || "English", + certification: user.certification || "", }); setEditingUserId(user._id || null); setIsUserModalOpen(true); @@ -362,18 +349,24 @@ const UserManagementPage: React.FC = () => { } }; - const filteredUsers = searchTerm - ? users.filter((user) => { - const fullName = `${user.firstName} ${user.lastName}`.toLowerCase(); - return ( - fullName.includes(searchTerm.toLowerCase()) || - user.email.toLowerCase().includes(searchTerm.toLowerCase()) || - user.company.toLowerCase().includes(searchTerm.toLowerCase()) - ); - }) - : users; + const filteredUsers = users.filter((user) => { + const fullName = `${user.firstName} ${user.lastName}`.toLowerCase(); + const matchesSearch = + searchTerm === "" || + fullName.includes(searchTerm.toLowerCase()) || + user.email.toLowerCase().includes(searchTerm.toLowerCase()) || + user.company.toLowerCase().includes(searchTerm.toLowerCase()); - const displayedUsers = filteredUsers; + const matchesUserType = + !selectedUserType || user.userType?.name === selectedUserType.name; + + return matchesSearch && matchesUserType; + }); + + const totalPages = Math.ceil(filteredUsers.length / itemsPerPage); + const startIdx = (currentPage - 1) * itemsPerPage; + const endIdx = startIdx + itemsPerPage; + const displayedUsers = filteredUsers.slice(startIdx, endIdx); const selectedCount = users.filter((user) => user.selected).length; const handleSearchSubmit = (e: React.FormEvent) => { @@ -381,6 +374,10 @@ const UserManagementPage: React.FC = () => { fetchUsers(); }; + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, selectedUserType]); + const Tooltip: React.FC<{ text: string; children: React.ReactNode }> = ({ text, children, @@ -440,14 +437,20 @@ const UserManagementPage: React.FC = () => {
@@ -506,6 +509,9 @@ const UserManagementPage: React.FC = () => { Language + + Certified Through + Actions @@ -543,7 +549,7 @@ const UserManagementPage: React.FC = () => { {user.email} - {user.userType} + {user.userType?.name} {user.company} @@ -556,6 +562,9 @@ const UserManagementPage: React.FC = () => { {user.language} + + {user.certification} +
@@ -850,33 +859,23 @@ const UserManagementPage: React.FC = () => { User Type setNewName(e.target.value)} + placeholder="Type" + /> + + + +
+ + +
+
+
+ )} + + {/* Edit Modal */} + {showEdit && ( +
+
+
+

Edit User Type

+ +
+ + +
+ + +
+
+
+ )} + + {/* Delete Confirmation Modal */} + {showDelete && ( +
+
+
+

Confirmation

+ +
+

+ Are you sure you want to remove this user type? +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Admin/WorkshopCreation/InPerson.tsx b/frontend/src/pages/Admin/WorkshopCreation/InPerson.tsx index d8b9fcb..da94cec 100644 --- a/frontend/src/pages/Admin/WorkshopCreation/InPerson.tsx +++ b/frontend/src/pages/Admin/WorkshopCreation/InPerson.tsx @@ -2,11 +2,7 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; import {WebinarType} from "../../../shared/types/Webinar"; interface InPersonComponentProps { - inPersonData:{ - startTime: Date | null, - duration: number, - location: string - }; + inPersonData:any; setInPersonData: Dispatch>; } export default function InPersonComponent({inPersonData, setInPersonData}:InPersonComponentProps) { diff --git a/frontend/src/pages/Admin/WorkshopCreation/Meeting.tsx b/frontend/src/pages/Admin/WorkshopCreation/Meeting.tsx new file mode 100644 index 0000000..4cd0af8 --- /dev/null +++ b/frontend/src/pages/Admin/WorkshopCreation/Meeting.tsx @@ -0,0 +1,46 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import apiClient from "../../../services/apiClient"; + +interface MeetingComponentProps { + meetingData: any; + setMeetingData: Dispatch>; + openModal: string | null; + setOpenModal: Dispatch>; +} + +export default function MeetingComponent({ + meetingData, + setMeetingData, + openModal, + setOpenModal, + }: MeetingComponentProps) { + return ( +
+ {meetingData.meetingID !== "string" && ( +
+

Selected Meeting:

+

+ {meetingData.topic} -{" "} + {new Date(meetingData.start_time).toLocaleString()} +

+
+ )} +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/Admin/WorkshopCreation/Modal.tsx b/frontend/src/pages/Admin/WorkshopCreation/Modal.tsx index 99bf39a..f8f02d2 100644 --- a/frontend/src/pages/Admin/WorkshopCreation/Modal.tsx +++ b/frontend/src/pages/Admin/WorkshopCreation/Modal.tsx @@ -25,16 +25,8 @@ const Modal: React.FC = ({ isOpen, onClose, title, children }) => { {title &&

{title}

} {/* Content */} -
{children}
- - {/* Footer */} -
- +
+ {children}
diff --git a/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/ExistingMeetingList.tsx b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/ExistingMeetingList.tsx new file mode 100644 index 0000000..ad0ef88 --- /dev/null +++ b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/ExistingMeetingList.tsx @@ -0,0 +1,61 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import apiClient from "../../../../services/apiClient"; + +interface ExistingMeetingListProps { + setMeetingData: Dispatch>; + setOpenModal: Dispatch>; +} + +export default function ExistingMeetingList({ + setMeetingData, + setOpenModal, + }: ExistingMeetingListProps) { + const [meetings, setMeetings] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchMeetings() { + try { + const res = await apiClient.get("zoom/meetings"); + setMeetings(res.data.meetings); + } catch (err) { + console.error("Failed to fetch meetings", err); + } finally { + setLoading(false); + } + } + fetchMeetings(); + }, []); + + if (loading) return

Loading meetings...

; + + return ( +
+ {meetings.map((meeting) => ( +
{ + setMeetingData({ + meetingID: meeting.id.toString(), + start_time: meeting.start_time, + duration: meeting.duration, + serviceType: "Zoom", + authParticipants: false, + autoRecord: false, + enablePractice: false, + topic: meeting.topic, + join_url: meeting.join_url, + }); + setOpenModal(null); + }} + > +

{meeting.topic}

+

+ {new Date(meeting.start_time).toLocaleString()} +

+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/ExistingWebinarList.tsx b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/ExistingWebinarList.tsx new file mode 100644 index 0000000..48f3138 --- /dev/null +++ b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/ExistingWebinarList.tsx @@ -0,0 +1,62 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import apiClient from "../../../../services/apiClient"; + +interface ExistingWebinarListProps { + setWebinarData: Dispatch>; + setOpenModal: Dispatch>; +} + +export default function ExistingWebinarList({ + setWebinarData, + setOpenModal, + }: ExistingWebinarListProps) { + const [webinars, setWebinars] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchWebinars() { + try { + const res = await apiClient.get("zoom/webinars"); + + setWebinars(res.data.webinars); + } catch (err) { + console.error("Failed to fetch webinars", err); + } finally { + setLoading(false); + } + } + fetchWebinars(); + }, []); + + if (loading) return

Loading meetings...

; + + return ( +
+ {webinars.map((webinar) => ( +
{ + setWebinarData({ + meetingID: webinar.id.toString(), + start_time: webinar.start_time, + duration: webinar.duration, + serviceType: "Zoom", + authParticipants: false, + autoRecord: false, + enablePractice: false, + topic: webinar.topic, + join_url: webinar.join_url, + }); + setOpenModal(null); + }} + > +

{webinar.topic}

+

+ {new Date(webinar.start_time).toLocaleString()} +

+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/NewMeeting.tsx b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/NewMeeting.tsx new file mode 100644 index 0000000..0cadba5 --- /dev/null +++ b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/NewMeeting.tsx @@ -0,0 +1,104 @@ +import {Dispatch, SetStateAction, useState} from 'react'; +import apiClient from "../../../../services/apiClient"; +interface NewMeetingProps { + setMeetingData: Dispatch>; + setOpenModal: Dispatch>; +} + +export default function NewMeeting({ setMeetingData, setOpenModal }:NewMeetingProps) { + const [topic, setTopic] = useState(''); + const [startTime, setStartTime] = useState(''); + const [duration, setDuration] = useState(60); + const [loading, setLoading] = useState(false); + + const currentDateTime = new Date(); + const minDateTime = currentDateTime.toISOString().slice(0, 16); + + const isValidStartTime = startTime && new Date(startTime) >= currentDateTime; + + const createMeeting = async () => { + setLoading(true); + try { + const meeting = (await apiClient.post("zoom/meeting", { + topic, + start_time: new Date(startTime).toISOString(), + duration: duration + })).data.meeting + setMeetingData({meetingID: meeting.id.toString(), + start_time: meeting.start_time, + duration: meeting.duration, + serviceType: "Zoom", + authParticipants: false, + autoRecord: false, + enablePractice: false, + topic: meeting.topic, + join_url: meeting.join_url,}); + setOpenModal(false); + } catch (err) { + console.error(err) + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setTopic(e.target.value)} + required + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2" + /> +

This is the title your attendees will see.

+
+ +
+ + setStartTime(e.target.value)} + required + min={minDateTime} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+ + setDuration(parseInt(e.target.value))} + required + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2" + /> +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/NewWebinar.tsx b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/NewWebinar.tsx new file mode 100644 index 0000000..06c0815 --- /dev/null +++ b/frontend/src/pages/Admin/WorkshopCreation/ModalComponents/NewWebinar.tsx @@ -0,0 +1,104 @@ +import {Dispatch, SetStateAction, useState} from 'react'; +import apiClient from "../../../../services/apiClient"; +interface NewWebinarProps { + setWebinarData: Dispatch>; + setOpenModal: Dispatch>; +} + +export default function NewWebinar({ setWebinarData, setOpenModal }:NewWebinarProps) { + const [topic, setTopic] = useState(''); + const [startTime, setStartTime] = useState(''); + const [duration, setDuration] = useState(60); + const [loading, setLoading] = useState(false); + + const currentDateTime = new Date(); + const minDateTime = currentDateTime.toISOString().slice(0, 16); + + const isValidStartTime = startTime && new Date(startTime) >= currentDateTime; + + const createWebinar = async () => { + setLoading(true); + try { + const webinar = (await apiClient.post("zoom/webinar", { + topic, + start_time: new Date(startTime).toISOString(), + duration: duration + })).data.meeting + setWebinarData({webinarID: webinar.id.toString(), + start_time: webinar.start_time, + duration: webinar.duration, + serviceType: "Zoom", + authParticipants: false, + autoRecord: false, + enablePractice: false, + topic: webinar.topic, + join_url: webinar.join_url,}); + setOpenModal(false); + } catch (err) { + console.error(err) + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setTopic(e.target.value)} + required + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2" + /> +

This is the title your attendees will see.

+
+ +
+ + setStartTime(e.target.value)} + required + min={minDateTime} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+ + setDuration(parseInt(e.target.value))} + required + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2" + /> +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/Admin/WorkshopCreation/OnDemand.tsx b/frontend/src/pages/Admin/WorkshopCreation/OnDemand.tsx index caffc1c..bde91b5 100644 --- a/frontend/src/pages/Admin/WorkshopCreation/OnDemand.tsx +++ b/frontend/src/pages/Admin/WorkshopCreation/OnDemand.tsx @@ -1,9 +1,9 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import apiClient from "../../../services/apiClient"; +import {getCleanCourseData} from "../../../store/useCourseEditStore"; interface OnDemandComponentProps { - onDemandData: { - embeddingLink: string; - }; + onDemandData: any; setOnDemandData: Dispatch>; } export default function OnDemandComponent({ @@ -14,17 +14,40 @@ export default function OnDemandComponent({ const { name, value } = e.target; setOnDemandData({ ...onDemandData, [name]: value }); }; + const course = getCleanCourseData(); + + const createVideo = async () => { + try { + const response = await apiClient.post("/videos", { + title: course.className, + description: course.courseDescription, + videoUrl: onDemandData.embeddingLink, + courseId: course._id, + published: true, // could be toggled later + }); + } catch (error) { + console.error(error); + } + }; return ( -
+
+ +
); } diff --git a/frontend/src/pages/Admin/WorkshopCreation/Webinar.tsx b/frontend/src/pages/Admin/WorkshopCreation/Webinar.tsx index 1bbcbdd..d00733c 100644 --- a/frontend/src/pages/Admin/WorkshopCreation/Webinar.tsx +++ b/frontend/src/pages/Admin/WorkshopCreation/Webinar.tsx @@ -1,8 +1,7 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; -import {WebinarType} from "../../../shared/types/Webinar"; interface WebinarComponentProps { - webinarData:WebinarType; + webinarData:any; setWebinarData: Dispatch>; openModal:string|null; setOpenModal: Dispatch>; @@ -12,7 +11,7 @@ export default function WebinarComponent( ) { return (
- - + +
) } \ No newline at end of file diff --git a/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx b/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx index a36bb26..f2802c2 100644 --- a/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx +++ b/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx @@ -3,45 +3,40 @@ import WebinarComponent from "./Webinar"; import InPersonComponent from "./InPerson"; import OnDemandComponent from "./OnDemand"; import Modal from "./Modal"; +import MeetingComponent from "./Meeting"; +import ExistingMeetingList from "./ModalComponents/ExistingMeetingList"; +import NewMeeting from "./ModalComponents/NewMeeting"; +import ExistingWebinarList from "./ModalComponents/ExistingWebinarList"; +import NewWebinar from "./ModalComponents/NewWebinar"; import SaveCourseButton from "../../../components/SaveCourseButtons"; import apiClient from "../../../services/apiClient"; import { getCleanCourseData } from "../../../store/useCourseEditStore"; export default function WorkshopCreation() { - const [formData, setFormData] = useState({ - title: "", - summary: "", - type: "webinar", - audioInstructions: "", - markAttendance: false, - requireAttendance: false, - gradeUser: false, - emailUnattended: false, - hideAfter: false, - minTime: 0, - }); const [webinarData, setWebinarData] = useState({ - serviceType: "", + serviceType: "webinar", + meetingURL: "string", + }); + + const [meetingData, setMeetingData] = useState({ + serviceType: "meeting", meetingID: "string", - startTime: new Date(), - duration: 0, - authParticipants: false, - autoRecord: false, - enablePractice: false, }); const [inPersonData, setInPersonData] = useState({ + serviceType: "in-person", startTime: null, duration: 0, location: "", }); const [onDemandData, setOnDemandData] = useState({ + serviceType: "on demand", embeddingLink: "", }); - const [openModal, setOpenModal] = useState<"New" | "Existing" | null>(null); + const [openModal, setOpenModal] = useState<"NewWebinar" | "ExistingWebinar" | "NewMeeting" | "ExistingMeeting" | null>(null); const handleChange = (e: any) => { const { name, value } = e.target; @@ -49,28 +44,43 @@ export default function WorkshopCreation() { }; const course = getCleanCourseData(); + console.log(course) + + function getInitialType() { + if(course.productType === "Virtual Training - On Demand"){ + return "on demand" + } + else if(course.productType === "Virtual Training - Live Meeting"){ + return "meeting" + } + else if(course.productType === "Virtual Training - Live Webinar"){ + return "webinar" + } + else if(course.productType === "In-Person Training"){ + return "in-person" + } + else{ + return "meeting" + } + } + + const [formData, setFormData] = useState({ + className: course.className, + courseDescription: course.courseDescription, + type: getInitialType(), + markAttendance: false, + requireAttendance: false, + gradeUser: false, + emailUnattended: false, + hideAfter: false, + minTime: 0, + }); const handleSubmit = (event: any) => { event.preventDefault(); console.log("Form submitted:", formData); }; - const createVideo = async () => { - try { - const response = await apiClient.post("/videos", { - title: formData.title, - description: formData.summary, - videoUrl: onDemandData.embeddingLink, - courseId: course._id, - published: true, // could be toggled later - }); - } catch (error) { - console.error(error); - } - }; - - // TODO: get video or webinar - return (
@@ -78,7 +88,7 @@ export default function WorkshopCreation() { Summary