From ffa18fa3cc3062487ae867927bffa51dfb429263 Mon Sep 17 00:00:00 2001 From: Gagah Ghaniswara K Date: Fri, 5 Apr 2024 16:19:52 +0700 Subject: [PATCH] Feat/event invite participants (#203) * feat: create email for sending manual invitation * fix: use proper data * fix: react-email as external packages * feat: invite email component * feat: create endpoint to request sending invitation email * Refactor EventInviteModal component to handle email sending and error handling --- app/(server)/_features/event/service.ts | 36 +-- app/(server)/_shared/mailer/mailer.ts | 14 +- .../api/events/[slugOrId]/invite/route.ts | 105 +++++++++ .../components/event-detail-dashboard.tsx | 13 ++ .../event/components/event-invite-modal.tsx | 219 ++++++++++++++++++ emails/event/EventManualInvitation.tsx | 2 +- next.config.js | 7 + 7 files changed, 363 insertions(+), 33 deletions(-) create mode 100644 app/(server)/api/events/[slugOrId]/invite/route.ts create mode 100644 app/_features/event/components/event-invite-modal.tsx diff --git a/app/(server)/_features/event/service.ts b/app/(server)/_features/event/service.ts index 9397ab70..15e11f0d 100644 --- a/app/(server)/_features/event/service.ts +++ b/app/(server)/_features/event/service.ts @@ -4,9 +4,11 @@ import { generateID } from '@/(server)/_shared/utils/generateid'; import { selectUser } from '../user/schema'; import { SendEventCancelledEmail, + SendEventManualInvitationEmail, SendEventRescheduledEmail, } from '@/(server)/_shared/mailer/mailer'; import { PageMeta } from '@/_shared/types/types'; +import { EventType } from '@/_shared/types/event'; /** * Type used to represent all type of participant in an event @@ -29,32 +31,8 @@ export type Participant = { export interface iEventRepo { addEvent(eventData: insertEvent): Promise; - getEventById(id: number): Promise< - | (selectEvent & { - host: - | { - name: string; - id: number; - pictureUrl: string | null; - } - | null - | undefined; - }) - | undefined - >; - getEventBySlug(slug: string): Promise< - | (selectEvent & { - host: - | { - name: string; - id: number; - pictureUrl: string | null; - } - | null - | undefined; - }) - | undefined - >; + getEventById(id: number): Promise; + getEventBySlug(slug: string): Promise; getEventParticipantsByEventId( eventId: number ): Promise; @@ -90,7 +68,7 @@ export class EventService implements iEventService { } async getEventBySlugOrID(slugOrId: string, userId?: number) { - let event: selectEvent | undefined; + let event: EventType.Event | undefined; if (!isNaN(Number(slugOrId))) { event = await this.repo.getEventById(Number(slugOrId)); @@ -170,4 +148,8 @@ export class EventService implements iEventService { return false; } + + async sendManualEmailInvitation(event: EventType.Event, email: string) { + SendEventManualInvitationEmail(event, email); + } } diff --git a/app/(server)/_shared/mailer/mailer.ts b/app/(server)/_shared/mailer/mailer.ts index 79dc33c3..3a86a464 100644 --- a/app/(server)/_shared/mailer/mailer.ts +++ b/app/(server)/_shared/mailer/mailer.ts @@ -269,9 +269,8 @@ export async function SendEventRescheduledEmail( } export async function SendEventManualInvitationEmail( - email: string, - event?: selectEvent, - host?: selectUser + event: selectEvent, + email: string ) { const mg = new Mailgun(formData); const mailer = mg.client({ @@ -279,12 +278,17 @@ export async function SendEventManualInvitationEmail( username: 'api', }); - const html = render(EventManualInvitation({}), { pretty: true }); + const html = render( + EventManualInvitation({ + event, + }), + { pretty: true } + ); mailer.messages.create(MAILER_DOMAIN, { html: html, from: 'inLive Room Events ', to: email, - subject: `You've been invited to a Webinar`, + subject: `Webinar Invitation: ${event.name}`, }); } diff --git a/app/(server)/api/events/[slugOrId]/invite/route.ts b/app/(server)/api/events/[slugOrId]/invite/route.ts new file mode 100644 index 00000000..0dc08014 --- /dev/null +++ b/app/(server)/api/events/[slugOrId]/invite/route.ts @@ -0,0 +1,105 @@ +import { getCurrentAuthenticated } from '@/(server)/_shared/utils/get-current-authenticated'; +import { eventService } from '@/(server)/api/_index'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const sendInviteEmailSchema = z.object({ + emails: z.array(z.string().email()), +}); + +export async function POST( + request: Request, + { params }: { params: { slugOrId: string } } +) { + const slugOrId = params.slugOrId; + const cookieStore = cookies(); + const requestToken = cookieStore.get('token'); + + if (!requestToken) { + return NextResponse.json( + { + code: 401, + message: 'Please check if token is provided in the cookie', + }, + { status: 401 } + ); + } + + const response = await getCurrentAuthenticated(requestToken?.value || ''); + const user = response.data ? response.data : null; + + if (!user) { + return NextResponse.json( + { + code: 401, + ok: false, + message: + 'User not found, please check if token is provided in the cookie is valid', + }, + { status: 401 } + ); + } + + try { + const event = await eventService.getEventBySlugOrID(slugOrId, user.id); + if (!event) { + return NextResponse.json( + { + code: 404, + ok: false, + message: 'Event not found', + }, + { status: 404 } + ); + } + + if (event.status !== 'published') { + return NextResponse.json( + { + code: 400, + ok: false, + message: 'Event is not published', + }, + { status: 400 } + ); + } + + try { + const reqJSON = await request.json(); + + const emails = sendInviteEmailSchema.parse(reqJSON); + emails.emails.forEach((email) => { + eventService.sendManualEmailInvitation(event, email); + }); + } catch (e) { + return NextResponse.json( + { + code: 400, + ok: false, + message: e, + }, + { status: 400 } + ); + } + + return NextResponse.json( + { + code: 200, + ok: true, + message: 'Emails sent successfully', + }, + { status: 200 } + ); + } catch (e) { + console.log(e); + return NextResponse.json( + { + code: 500, + ok: false, + message: 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/app/_features/event/components/event-detail-dashboard.tsx b/app/_features/event/components/event-detail-dashboard.tsx index 00e78fd1..e6dcdf49 100644 --- a/app/_features/event/components/event-detail-dashboard.tsx +++ b/app/_features/event/components/event-detail-dashboard.tsx @@ -24,6 +24,7 @@ import { useToggle } from '@/_shared/hooks/use-toggle'; import CancelEventModal from './event-cancel-modal'; import type { SVGElementPropsType } from '@/_shared/types/types'; import { useFormattedDateTime } from '@/_shared/hooks/use-formatted-datetime'; +import EventInviteModal from './event-invite-modal'; const APP_ORIGIN = process.env.NEXT_PUBLIC_APP_ORIGIN; @@ -59,6 +60,7 @@ export default function EventDetailDashboard({ return (
+
@@ -162,6 +164,17 @@ export default function EventDetailDashboard({
+
diff --git a/app/_features/event/components/event-invite-modal.tsx b/app/_features/event/components/event-invite-modal.tsx new file mode 100644 index 00000000..a635fb2c --- /dev/null +++ b/app/_features/event/components/event-invite-modal.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { generateID } from '@/(server)/_shared/utils/generateid'; +import XFillIcon from '@/_shared/components/icons/x-fill-icon'; +import { + Button, + Divider, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + useDisclosure, +} from '@nextui-org/react'; +import { useCallback, useEffect, useState } from 'react'; +import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; + +interface FormValues { + emails: { email: string; id: string | undefined }[]; + csvEmails: string; +} + +export default function EventInviteModal({ slug }: { slug: string }) { + const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const methods = useForm({ + defaultValues: { + emails: [{ email: '', id: generateID(6) }], + csvEmails: '', + }, + mode: 'all', + }); + + const { control } = methods; + const { fields, append, remove } = useFieldArray({ + control, + name: 'emails', + }); + + const OnSendEmails = useCallback(async () => { + setIsSubmitting(true); + const emailForm = methods.getValues('emails'); + + emailForm.forEach((email, idx) => { + if (email.email === '') { + remove(idx); + } + }); + + const emails = emailForm + .map((email) => email.email) + .filter((email) => email !== ''); + try { + const response = await fetch(`/api/events/${slug}/invite`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ emails: emails }), + }); + if (response.ok) { + onClose(); + if (errorMessage) setErrorMessage(null); + methods.reset(); + } + } catch (error) { + console.error(error); + setErrorMessage('An error occurred while sending emails'); + } + setIsSubmitting(false); + setErrorMessage('An error occurred while sending emails'); + }, [errorMessage, methods, onClose, remove, slug]); + + const onAddMultitpleEmails = useCallback(() => { + const newEmails = methods.getValues('csvEmails').split(','); + let existingEmails = methods + .getValues('emails') + .map((email) => email.email); + + // Check if existingEmails is empty or contains only one empty string + if (existingEmails.length === 1 && existingEmails[0] === '') { + remove(0); + existingEmails = []; + } + + newEmails.forEach((email) => { + const emailTrimmed = email.trim(); + + if (!existingEmails.includes(emailTrimmed)) { + append({ email: emailTrimmed, id: generateID(6) }); + } + }); + + methods.resetField('csvEmails'); + }, [append, methods, remove]); + + const openModal = useCallback(() => { + onOpen(); + }, [onOpen]); + + const closeModal = useCallback(() => { + methods.reset(); + setErrorMessage(null); + onClose(); + }, [methods, onClose]); + + useEffect(() => { + document.addEventListener('open:event-invite-modal', openModal); + }); + + return ( + +
+ + + +

Invite Participants

+
+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + +
+