From 027ce17e220973d528af35c32b3f0d32f2dcc169 Mon Sep 17 00:00:00 2001 From: mai-vu <96965521+mai-vu@users.noreply.github.com> Date: Tue, 28 May 2024 11:53:29 -0700 Subject: [PATCH] Feature/send email (#105) * Added admin page to send email * added send email api * added Circle Check icon * updated sengrid dependency * migrated API URLs to .env * debugged redirect to login in home * added table component * added admin page * updated API URL for sent status update * added sent field * added cron job to send email * added contact-stat schema * added API endpoints for contact-stats * updated cron job to interact with contact-stats collection * changed func name * migrated code to emailHandler * removed sent const * added Mass send feature * changed button hover color * added dependency * aded addEmailModal * added total number of emails added in response * add function add emails * implemented api endpoint * update UI when new email is added * added Delete endpoint and integration * added delete feature * improved UI * added more detailed alert * clear input field upon opening * renamed button * uncomment send email function call * updated email sender * added confirmation alert * renamed file and updated run instruction * updated more descriptive messege * updated dependency * deleted ununsed file * deleted unused import * added dependecy * return number of new emails added * send email using Sendgrid * Added email cron job * Checks if user is admin and redirects to email panel * Only allow admins on email page * added loading and unauthorized access html --------- Co-authored-by: shye <113232835+justshye@users.noreply.github.com> --- src/app/admin-panel/home/page.js | 7 ++ src/app/admin/emailHandler.js | 129 +++++++++++++++++++ src/app/admin/layout.js | 15 +++ src/app/admin/page.js | 140 +++++++++++++++++++++ src/components/component/addEmailModal.jsx | 127 +++++++++++++++++++ src/components/component/adminPage.jsx | 106 ++++++++++++++++ src/components/icons.js | 39 ++++++ src/components/ui/addJobPostingForm.jsx | 1 + src/components/ui/dialog.jsx | 97 ++++++++++++++ src/components/ui/table.jsx | 95 ++++++++++++++ 10 files changed, 756 insertions(+) create mode 100644 src/app/admin/emailHandler.js create mode 100644 src/app/admin/layout.js create mode 100644 src/app/admin/page.js create mode 100644 src/components/component/addEmailModal.jsx create mode 100644 src/components/component/adminPage.jsx create mode 100644 src/components/ui/dialog.jsx create mode 100644 src/components/ui/table.jsx diff --git a/src/app/admin-panel/home/page.js b/src/app/admin-panel/home/page.js index f03c3bf..742c05a 100644 --- a/src/app/admin-panel/home/page.js +++ b/src/app/admin-panel/home/page.js @@ -37,6 +37,13 @@ export default function Home() { if (response.ok) { const userData = await response.json(); setUser(userData); + console.log('User:', userData); + if (userData.data && userData.data.admin === true) { + console.log('User is admin'); + window.location.href = '/admin'; + } else { + console.log('User is not admin'); + } } else { console.error('Failed to fetch user:', response.statusText); window.location.href = '/'; diff --git a/src/app/admin/emailHandler.js b/src/app/admin/emailHandler.js new file mode 100644 index 0000000..a9dc32b --- /dev/null +++ b/src/app/admin/emailHandler.js @@ -0,0 +1,129 @@ +const CONTACT_STAT_API_URL = process.env.NEXT_PUBLIC_CONTACT_STAT_API_URL; +const SEND_EMAIL_API_URL = process.env.NEXT_PUBLIC_SEND_EMAIL_API_URL; + +const getContactStat = async () => { + try { + const response = await fetch(CONTACT_STAT_API_URL, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + return data.contactStats; + } catch (error) { + console.error('Error fetching contact stats:', error); + return []; + } +}; + +// Function to send emails +const sendEmail = async recipient => { + try { + console.log(`Sending email to ${recipient}`); + const apiUrl = SEND_EMAIL_API_URL; + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + to: recipient, + subject: 'Expand Your Hiring Reach with [Job Panel]!', + html: ` +

Hello from our team at [Job Panel],

+

We are here to revolutionize your hiring process and help you reach a diverse pool of talent. Our web app offers a seamless solution to distribute your job postings to multiple platforms, including newcomers, indigenous communities, students, asylum-seekers, and individuals with disabilities.

+

Here's what makes our job panel stand out:

+ +

Ready to take your hiring to the next level? Sign up now via the link below and start reaching the talent you've been looking for!

+

Sign Up Now

+

Don't miss out on the opportunity to find your next great hire effortlessly. Join us today and experience the power of our job panel!

+

Best regards,

+

The [Job Panel] Team

`, + }), + }); + if (response.ok) { + // Update the status of the email in the contactStat collection + await updateSentStatus(recipient, true); + } else { + throw new Error('Failed to send email'); + } + } catch (error) { + console.error('Error sending email:', error); + } +}; + +const updateSentStatus = async (email, sent) => { + try { + const response = await fetch(CONTACT_STAT_API_URL, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, sent }), + }); + + if (!response.ok) { + throw new Error('Failed to update sent status'); + } + } catch (error) { + console.error('Error updating sent status:', error); + } +}; + +// Function to add email objects to the contactStat collection, take an array of email objects with email and sent properties +const addEmailObjects = async emailObjects => { + try { + const response = await fetch(CONTACT_STAT_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(emailObjects), + }); + + if (!response.ok) { + throw new Error('Failed to add email objects'); + } + + const responseBody = await response.json(); + const { emailsAdded } = responseBody; + return emailsAdded; + } catch (error) { + console.error('Error adding email objects:', error); + } +}; + +const deleteEmail = async email => { + try { + const response = await fetch(CONTACT_STAT_API_URL, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }); + + if (!response.ok) { + throw new Error('Failed to delete email'); + } + } catch (error) { + console.error('Error deleting email:', error); + } +}; + +const emailHandler = { + getContactStat, + sendEmail, + updateSentStatus, + addEmailObjects, + deleteEmail, +}; + +export default emailHandler; diff --git a/src/app/admin/layout.js b/src/app/admin/layout.js new file mode 100644 index 0000000..f161cea --- /dev/null +++ b/src/app/admin/layout.js @@ -0,0 +1,15 @@ +import { Libre_Franklin } from 'next/font/google'; + +const libre_franklin = Libre_Franklin({ + subsets: ['latin'], + display: 'swap', + variable: '--font-libre_franklin', +}); + +export default function Layout({ children }) { + return ( + + {children} + + ); +} diff --git a/src/app/admin/page.js b/src/app/admin/page.js new file mode 100644 index 0000000..2a230ed --- /dev/null +++ b/src/app/admin/page.js @@ -0,0 +1,140 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import emailHandler from './emailHandler'; +import { AdminPage } from '@/components/component/adminPage'; +import Navbar from '@/components/ui/navbar'; + +export default function Home() { + const [emails, setEmails] = useState([]); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [unauthorized, setUnauthorized] = useState(false); + + const links = [{ text: 'Logout', url: '/api/auth/logout' }]; + + const fetchUser = useCallback(async () => { + try { + const response = await fetch('/api/auth/me'); + if (response.ok) { + const userData = await response.json(); + if (userData.data && userData.data.admin === true) { + setUser(userData); + console.log('User is admin'); + } else { + console.log('User is not admin'); + setUnauthorized(true); + setTimeout(() => { + window.location.href = '/'; + }, 3000); // Redirect after 3 seconds + } + } else { + console.error('Failed to fetch user:', response.statusText); + setUnauthorized(true); + setTimeout(() => { + window.location.href = '/'; + }, 3000); // Redirect after 3 seconds + } + } catch (error) { + console.error('Error fetching user:', error); + setUnauthorized(true); + setTimeout(() => { + window.location.href = '/'; + }, 3000); // Redirect after 3 seconds + } finally { + setLoading(false); + } + }, []); + + const fetchEmails = async () => { + try { + const data = await emailHandler.getContactStat(); + setEmails(data); + } catch (error) { + console.error('Error fetching emails:', error); + } + }; + + useEffect(() => { + fetchUser(); + }, [fetchUser]); + + useEffect(() => { + if (user) { + fetchEmails(); + } + }, [user]); + + const updateEmails = (emailObj, isAdd) => { + if (isAdd) { + setEmails(prevEmails => [...prevEmails, emailObj]); + } else if (!isAdd) { + const updatedEmails = emails.filter( + existingEmail => existingEmail.email !== emailObj.email + ); + setEmails(updatedEmails); + } + }; + + const sendEmail = async recipient => { + try { + await emailHandler.sendEmail(recipient); + } catch (error) { + console.error('Error sending email:', error); + } + + setEmails(prevEmails => + prevEmails.map(emailObj => + emailObj.email === recipient ? { ...emailObj, sent: true } : emailObj + ) + ); + }; + + // Function to send emails to all recipients + const massSendEmails = async () => { + try { + let emailSent = 0; + // Loop through all emails and send email to each recipient + for (const emailObj of emails) { + // if email has not been sent, send email + if (!emailObj.sent) { + await sendEmail(emailObj.email); + emailSent++; + } + } + alert(`${emailSent} emails have been sent.`); + } catch (error) { + console.error('Error sending emails:', error); + } + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (unauthorized) { + return ( +
+
+ You do not have access to this page. Redirecting... +
+
+ ); + } + + return user ? ( +
+ + +
+ ) : null; +} diff --git a/src/components/component/addEmailModal.jsx b/src/components/component/addEmailModal.jsx new file mode 100644 index 0000000..e42f863 --- /dev/null +++ b/src/components/component/addEmailModal.jsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from 'react'; +import { + DialogTitle, + DialogContent, + Dialog, + DialogOverlay, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import emailHandler from '@/app/admin/emailHandler'; + +// CloseButton component for the close button +const CloseButton = ({ onClick }) => ( + +); + +export function AddEmailModal({ open, onClose, updateEmails }) { + const [email, setEmail] = useState(''); + const [emailsAdded, setEmailsAdded] = useState(1); + + // Reset email state when the modal is opened + useEffect(() => { + if (open) { + setEmail(''); + } + }, [open]); + + const validateEmail = email => { + // Regular expression to validate email format + const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return emailPattern.test(email); + }; + + const handleAddEmail = async () => { + try { + if (!validateEmail(email)) { + alert('Please enter a valid email address'); + return; + } + // Call the addEmailObjects function with an array of length 1 containing the email object + const emailsAdded = await emailHandler.addEmailObjects([ + { email, sent: false }, + ]); + setEmailsAdded(emailsAdded); + setEmail(''); + + // Call the callback function to update the data state in AdminPage + const isAdd = true; + updateEmails({ email, sent: false }, isAdd); + } catch (error) { + console.error('Error adding email objects:', error); + } + }; + + const handleAddAndSendEmail = async () => { + try { + if (!validateEmail(email)) { + alert('Please enter a valid email address'); + return; + } + await emailHandler.addEmailObjects([{ email, sent: true }]); + await emailHandler.sendEmail(email); + alert('Email sent to ' + email); + setEmail(''); + + // Call the callback function to update the data state in AdminPage + const isAdd = true; + updateEmails({ email, sent: true }, isAdd); + } catch (error) { + console.error('Error adding email objects:', error); + } + }; + + return ( + + + +
+ Add Email + {/* Use CloseButton component for the close button */} + +
+
+
+ + setEmail(e.target.value)} + /> +
+
+ + +
+ {/* Display warning/notification field if emailsAdded is less than 1 */} + {emailsAdded < 1 && ( +
+ Email address already exists on the contact list. +
+ )} +
+
+
+ ); +} diff --git a/src/components/component/adminPage.jsx b/src/components/component/adminPage.jsx new file mode 100644 index 0000000..9543ef5 --- /dev/null +++ b/src/components/component/adminPage.jsx @@ -0,0 +1,106 @@ +import { React, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableCell, + TableBody, +} from '@/components/ui/table'; +import { CircleCheckIcon, CircleXIcon } from '@/components/icons'; +import { AddEmailModal } from './addEmailModal'; +import emailHandler from '@/app/admin/emailHandler'; + +const AdminPage = ({ data, sendEmail, massSendEmails, updateEmails }) => { + const [showAddEmailModal, setShowAddEmailModal] = useState(false); + + const handleAddEmail = () => { + setShowAddEmailModal(true); + }; + + const handleDeleteEmail = emailObj => { + // Call the deleteEmail function with the email to delete + emailHandler.deleteEmail(emailObj.email); + + // Remove row from the table + const isAdd = false; + updateEmails(emailObj, isAdd); + }; + + const handleCloseModal = () => { + setShowAddEmailModal(false); + }; + + return ( +
+
+
+

Admin Dashboard

+ +
+
+
+
+ {/* Render the Add Email button */} + + + + + Email Contacted Status + Action + + + + {data.map(({ sent, email }, index) => ( + + +
+ {email} + {sent ? ( + + ) : ( + + )} +
+
+ +
+ + +
+
+
+ ))} +
+
+
+
+ {/* Render the AddEmailModal */} + +
+ ); +}; + +AdminPage.displayName = 'AdminPage'; + +export { AdminPage }; diff --git a/src/components/icons.js b/src/components/icons.js index 038e91b..5e1adc6 100644 --- a/src/components/icons.js +++ b/src/components/icons.js @@ -38,3 +38,42 @@ export function TrashIcon(props) { ); } + +export function CircleCheckIcon(props) { + return ( + + + + + ); +} + +export function CircleXIcon(props) { + return ( + + + + + + ); +} diff --git a/src/components/ui/addJobPostingForm.jsx b/src/components/ui/addJobPostingForm.jsx index 2b5bfae..63fa380 100644 --- a/src/components/ui/addJobPostingForm.jsx +++ b/src/components/ui/addJobPostingForm.jsx @@ -25,6 +25,7 @@ const AddJobPostingForm = ({ onSubmit, email, onClose }) => { site3: false, site4: false, site5: false, + sent: true, }); const sites = ['Indigenous', 'New Comers', 'Site 3', 'Site 4', 'Site 5']; diff --git a/src/components/ui/dialog.jsx b/src/components/ui/dialog.jsx new file mode 100644 index 0000000..aec4811 --- /dev/null +++ b/src/components/ui/dialog.jsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { cn } from '@/libs/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + + + {children} + + + ) +); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/table.jsx b/src/components/ui/table.jsx new file mode 100644 index 0000000..79f8137 --- /dev/null +++ b/src/components/ui/table.jsx @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import { cn } from '@/libs/utils'; + +const Table = React.forwardRef(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( + tr]:last:border-b-0 dark:bg-gray-800/50', + className + )} + {...props} + /> +)); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = 'TableCaption'; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +};