From b03052e5a52e3cffe1b94d3d5df553ee0329f28c Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 12 May 2024 02:06:25 -0400 Subject: [PATCH 01/15] add confirmation modal before dismissing RSVP --- src/pages/Home/Home.page.tsx | 233 ++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 102 deletions(-) diff --git a/src/pages/Home/Home.page.tsx b/src/pages/Home/Home.page.tsx index f5271f9d..5782af89 100644 --- a/src/pages/Home/Home.page.tsx +++ b/src/pages/Home/Home.page.tsx @@ -1,7 +1,8 @@ import { GoldenHawk, IpadKidHawks } from "@/assets"; -import { Card, Accordion, SocialIcons } from "@components"; +import { Card, Accordion, SocialIcons, Button, Modal } from "@components"; import { faqs, sponsors, importantDateTimes } from "@data"; import { useAuth } from "@/providers/auth.provider"; +import { useState } from "react"; const ImportantInfoBlocks = importantDateTimes.map((importantDateTime, i) => { const entries = Object.entries(importantDateTime.events); @@ -46,118 +47,146 @@ const Sponsors = sponsors.map((sponsor, i) => { const HomePage = () => { const { currentUser } = useAuth(); + const [disableAllActions, setDisableAllActions] = useState(false); + const [openDismissRSVPWarning, setOpenDismissRSVPWarning] = useState(false); return ( -
-
- -
-

- HawkHacks came out of a desire to give everyone an - equal opportunity to get into tech, whether that be - programming, networking, researching, learning, or - teaching. -
-
- Join hundreds of students across Canada (and across - the world) in a 36 hour period of exploration, - creativity, and learning! -
-
- Remember, you don’t have to be a pro to participate - - show up with ten years or ten minutes of - experience (oh yeah, and a great attitute too!) -

- -
-
+ <> +
+
+ +
+

+ HawkHacks came out of a desire to give everyone + an equal opportunity to get into tech, whether + that be programming, networking, researching, + learning, or teaching. +
+
+ Join hundreds of students across Canada (and + across the world) in a 36 hour period of + exploration, creativity, and learning! +
+
+ Remember, you don’t have to be a pro to + participate - show up with ten years or ten + minutes of experience (oh yeah, and a great + attitute too!) +

+ +
+
- - -

- RSVP status:{" "} - - {currentUser?.rsvpVerified - ? "RSVP'd" - : "Not RSVP'd"} - -

-
-
+ + +

+ RSVP status:{" "} + + {currentUser?.rsvpVerified + ? "RSVP'd" + : "Not RSVP'd"} + +

+
+ +
- -
- {ImportantInfoBlocks} -
-
+ +
+ {ImportantInfoBlocks} +
+
- -
-

- Reach out at{" "} - - hello@hawkhacks.ca - {" "} - for any help or support, and please be sure to join - the HawkHacks Discord community! -

- -
+ +
+

+ Reach out at{" "} + + hello@hawkhacks.ca + {" "} + for any help or support, and please be sure to + join the HawkHacks Discord community! +

+ +
- -
+ +
+ + +
+

+ Thank you to all our sponsors! Without them, + HawkHacks could not have happened! +
+
+ Here are some of our top sponsors that we like + to thank: +

- -
-

- Thank you to all our sponsors! Without them, - HawkHacks could not have happened! -
-
- Here are some of our top sponsors that we like to - thank: -

+
+ {Sponsors} +
- + +
- - Check out all our sponsors! ✨ - -
+ + -
- - - - -
+
+ + !disableAllActions && setOpenDismissRSVPWarning(false) + } + > +
+ + +
+
+ ); }; From ea0ac6f8e36196508674022cc80c45c2d05f5556 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 12 May 2024 14:47:08 -0400 Subject: [PATCH 02/15] add ability to withdraw rsvp & join a waitlist --- .npmrc | 2 +- config/firestore.rules | 8 + functions/src/index.ts | 110 +------ functions/src/rsvp.ts | 320 ++++++++++++++++++++ src/pages/Home/Home.page.tsx | 60 +++- src/pages/miscellaneous/VerifyRSVP.page.tsx | 281 ++++++++++++----- src/services/utils/index.ts | 42 ++- src/services/utils/types.ts | 9 + 8 files changed, 638 insertions(+), 194 deletions(-) create mode 100644 functions/src/rsvp.ts diff --git a/.npmrc b/.npmrc index c3c4565e..39e0098a 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -@nessprim:registry=https://npm.pkg.github.com/:_authToken=${PLANBY_AUTH_TOKEN} +@nessprim:registry=https://npm.pkg.github.com/ diff --git a/config/firestore.rules b/config/firestore.rules index 148aead2..9ffa2075 100644 --- a/config/firestore.rules +++ b/config/firestore.rules @@ -32,6 +32,14 @@ service cloud.firestore { match /rsvpCounter-dev/{counterId} { allow read, write: if isAuthenticated() && isAdmin(); } + match /waitlist/{docId} { + allow read: if isAuthenticated(); + allow write: if isAuthenticated() && isAdmin(); + } + match /spots/{docId} { + allow read: if isAuthenticated(); + allow write: if isAuthenticated() && isAdmin(); + } // Tickets Collection Rules match /tickets/{ticketId} { diff --git a/functions/src/index.ts b/functions/src/index.ts index c1a3eb92..d9f7fc86 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -385,109 +385,6 @@ export const logEvent = functions.https.onCall((data, context) => { } }); -export const verifyRSVP = functions.https.onCall(async (_, context) => { - if (!context.auth) { - throw new functions.https.HttpsError( - "permission-denied", - "Not authenticated" - ); - } - - functions.logger.info("Verify RSVP called.", { uid: context.auth.uid }); - - // only verify once - const user = await admin.auth().getUser(context.auth.uid); - if (user.customClaims?.rsvpVerified) { - return { - status: 200, - verified: true, - message: "RSVP already verified.", - }; - } else if (user.customClaims?.isTestAccount) { - const counterDocRef = admin - .firestore() - .collection("rsvpCounter-dev") - .doc("counter"); - const counterDoc = await counterDocRef.get(); - - if (counterDoc.exists) { - const count = counterDoc.data()?.count || 0; - - if (count >= 5) { - functions.logger.info("RSVP limit reached.", { - uid: context.auth.uid, - }); - return { - status: 400, - verified: false, - message: "RSVP limit reached.", - }; - } else { - await counterDocRef.set({ count: count + 1 }, { merge: true }); - } - } else { - await counterDocRef.set({ count: 1 }); - } - await admin.auth().setCustomUserClaims(user.uid, { - ...user.customClaims, - rsvpVerified: true, - }); - - return { - status: 200, - verified: true, - }; - } else { - const counterDocRef = admin - .firestore() - .collection("rsvpCounter") - .doc("counter"); - const counterDoc = await counterDocRef.get(); - - if (counterDoc.exists) { - const count = counterDoc.data()?.count || 0; - - if (count >= 700) { - functions.logger.info("RSVP limit reached.", { - uid: context.auth.uid, - }); - return { - status: 400, - verified: false, - message: "RSVP limit reached.", - }; - } else { - await counterDocRef.set({ count: count + 1 }, { merge: true }); - } - } else { - await counterDocRef.set({ count: 1 }); - } - - try { - functions.logger.info("Verifying RSVP. User: " + context.auth.uid); - // add to custom claims - await admin.auth().setCustomUserClaims(user.uid, { - ...user.customClaims, - rsvpVerified: true, - }); - } catch (e) { - functions.logger.error("Error verifying RSVP.", { - uid: context.auth.uid, - error: (e as Error).message, - }); - throw new functions.https.HttpsError( - "internal", - "Service down. 1101" - ); - } - - return { - status: 200, - verified: true, - }; - } -}); - async function internalGetTicketData(id: string, extended = false) { functions.logger.info("Checking for ticket data..."); const ticketDoc = await admin @@ -686,3 +583,10 @@ export { export { createTicket } from "./apple"; export { createPassClass, createPassObject } from "./google"; + +export { + verifyRSVP, + withdrawRSVP, + joinWaitlist, + expiredSpotCleanup, +} from "./rsvp"; diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts new file mode 100644 index 00000000..60f3d4cd --- /dev/null +++ b/functions/src/rsvp.ts @@ -0,0 +1,320 @@ +import * as functions from "firebase-functions"; +import * as admin from "firebase-admin"; +import { HttpStatus, response } from "./utils"; +import { Timestamp } from "firebase-admin/firestore"; +import { Resend } from "resend"; + +const WAITLIST_COLLECTION = "waitlist"; +const SPOTS_COLLECTION = "spots"; + +async function sendNotificationEmail(name: string, email: string | undefined) { + if (!email) { + throw new Error("No email provided"); + } + + const config = functions.config(); + const RESEND_API_KEY = config.resend.key; + const NOREPLY_EMAIL = config.email.noreply; + const FE_URL = config.fe.url; + + functions.logger.info("Sending new available spot email..."); + const resend = new Resend(RESEND_API_KEY); + await resend.emails.send({ + from: NOREPLY_EMAIL, + to: email, + subject: "[HawkHacks] RSVP SPOT!", + html: `

Hi ${name}

Here is you chance to RSVP! Make sure to RSVP within 24 hours to secure your spot.

Go to Dashboard`, + }); +} + +export const withdrawRSVP = functions.https.onCall(async (_, context) => { + if (!context.auth) + return response(HttpStatus.UNAUTHORIZED, { message: "unauthorized" }); + + try { + functions.logger.info("Looking for user...", { uid: context.auth.uid }); + const user = await admin.auth().getUser(context.auth.uid); + functions.logger.info("Dismissing RSVP...", { email: user.email }); + await admin.auth().setCustomUserClaims(user.uid, { + ...user.customClaims, + rsvpVerified: false, + }); + functions.logger.info("RSVP dismissed.", { email: user.email }); + // logout user / prevent old claims to exists in client's device + // which will allow it to browse all the pages + await admin.auth().revokeRefreshTokens(user.uid); + + // move next in waitlist to spots + const snap = await admin + .firestore() + .collection(WAITLIST_COLLECTION) + .orderBy("joinAt", "asc") + .limit(1) + .get(); + if (snap.size) { + const doc = snap.docs[0]; + await admin.firestore().runTransaction(async (tx) => { + const expires = Timestamp.now().toDate(); + // 24 hours in milliseconds + const oneDayInMs = 86400000; + expires.setTime(expires.getTime() + oneDayInMs); + const expiresAt = Timestamp.fromDate(expires); + tx.create( + admin.firestore().collection(SPOTS_COLLECTION).doc(doc.id), + { + ...doc.data(), + expiresAt, + } + ); + tx.delete( + admin + .firestore() + .collection(WAITLIST_COLLECTION) + .doc(doc.id) + ); + }); + const app = ( + await admin + .firestore() + .collection("applications") + .where("applicantId", "==", user.uid) + .get() + ).docs[0]?.data(); + await sendNotificationEmail( + app?.firstName ?? user.displayName ?? "", + user.email + ).catch((error) => + functions.logger.error( + "Failed to send notification email about new available spot.", + { error } + ) + ); + } + } catch (error) { + functions.logger.error("Failed to unverified rsvp", { error }); + return response(HttpStatus.INTERNAL_SERVER_ERROR, { + message: "Failed to unverified rsvp", + }); + } + + // move next in waitlist to rsvp + return response(HttpStatus.OK); +}); + +export const verifyRSVP = functions.https.onCall(async (_, context) => { + if (!context.auth) { + throw new functions.https.HttpsError( + "permission-denied", + "Not authenticated" + ); + } + + functions.logger.info("Verify RSVP called.", { uid: context.auth.uid }); + + // only verify once + const user = await admin.auth().getUser(context.auth.uid); + if (user.customClaims?.rsvpVerified) { + return { + status: 200, + verified: true, + message: "RSVP already verified.", + }; + } else if (user.customClaims?.isTestAccount) { + const counterDocRef = admin + .firestore() + .collection("rsvpCounter-dev") + .doc("counter"); + const counterDoc = await counterDocRef.get(); + + if (counterDoc.exists) { + const count = counterDoc.data()?.count || 0; + + if (count >= 5) { + functions.logger.info("RSVP limit reached.", { + uid: context.auth.uid, + }); + return { + status: 400, + verified: false, + message: "RSVP limit reached.", + }; + } else { + await counterDocRef.set({ count: count + 1 }, { merge: true }); + } + } else { + await counterDocRef.set({ count: 1 }); + } + await admin.auth().setCustomUserClaims(user.uid, { + ...user.customClaims, + rsvpVerified: true, + }); + + return { + status: 200, + verified: true, + }; + } else { + try { + functions.logger.info("Checking user in spots...", { + email: user.email, + }); + const spotSnap = await admin + .firestore() + .collection(SPOTS_COLLECTION) + .where("uid", "==", user.uid) + .get(); + if (!spotSnap.size) { + functions.logger.info( + "User not in waitlist or no empty spots. Rejecting..." + ); + return { + status: 400, + verified: false, + message: "RSVP limit reached.", + }; + } + const spotId = spotSnap.docs[0].id; + const spotData = spotSnap.docs[0].data(); + if (Timestamp.now().seconds > spotData.expiresAt.seconds) { + // expired spot, remove + functions.logger.info( + "User verying with an expired spot. Rejecting..." + ); + await admin.firestore().runTransaction(async (tx) => { + const waitListDoc = await tx.get( + admin + .firestore() + .collection(WAITLIST_COLLECTION) + .where("uid", "==", user.uid) + ); + tx.delete( + admin + .firestore() + .collection(SPOTS_COLLECTION) + .doc(spotId) + ); + const waitlist = waitListDoc.docs[0]; + if (waitlist) { + tx.delete(waitlist.ref); + } + }); + return { + status: 400, + verified: false, + message: + "Your chance to RSVP has expired. You can try again by entering the waitlist.", + }; + } else { + functions.logger.info("Verifying RSVP..."); + await admin.auth().setCustomUserClaims(user.uid, { + ...user.customClaims, + rsvpVerified: true, + }); + await admin.firestore().runTransaction(async (tx) => { + const waitListDoc = await tx.get( + admin + .firestore() + .collection(WAITLIST_COLLECTION) + .where("uid", "==", user.uid) + ); + const waitlist = waitListDoc.docs[0]; + tx.delete( + admin + .firestore() + .collection(SPOTS_COLLECTION) + .doc(spotId) + ); + if (waitlist) { + tx.delete(waitlist.ref); + } + }); + } + } catch (e) { + functions.logger.error("Error verifying RSVP.", { + uid: context.auth.uid, + error: (e as Error).message, + }); + throw new functions.https.HttpsError( + "internal", + "RSVP service down" + ); + } + + return { + status: 200, + verified: true, + }; + } +}); + +export const joinWaitlist = functions.https.onCall(async (_, context) => { + if (!context.auth) + return response(HttpStatus.UNAUTHORIZED, { message: "unauthorized" }); + const func = "joinWaitlist"; + + try { + const user = await admin.auth().getUser(context.auth.uid); + if (user.customClaims?.rsvpVerified) { + return response(HttpStatus.BAD_REQUEST, { message: "User RSVP'd" }); + } + + const snap = await admin + .firestore() + .collection(WAITLIST_COLLECTION) + .where("uid", "==", user.uid) + .get(); + if (snap.size > 0) { + return response(HttpStatus.BAD_REQUEST, { + message: "User in waitlist already", + }); + } + + await admin.firestore().collection(WAITLIST_COLLECTION).add({ + uid: user.uid, + joinAt: Timestamp.now(), + }); + } catch (error) { + functions.logger.error("Error joining waitlist.", { error, func }); + throw new functions.https.HttpsError( + "internal", + "Waitlist service down." + ); + } + + return response(HttpStatus.OK); +}); + +export const expiredSpotCleanup = functions.pubsub + .schedule("every 30 minutes") + .onRun(async () => { + functions.logger.info("Start expired spot clean up"); + const batch = admin.firestore().batch(); + let snap = await admin + .firestore() + .collection(SPOTS_COLLECTION) + .where("expiresAt", "<", Timestamp.now()) + .get(); + const count = snap.size; + snap.forEach((doc) => { + batch.delete(doc.ref); + }); + snap = await admin + .firestore() + .collection(WAITLIST_COLLECTION) + .orderBy("joinAt", "asc") + .limit(count) + .get(); + // 24 hours in milliseconds + const oneDayInMs = 86400000; + snap.forEach((doc) => { + const expires = Timestamp.now().toDate(); + expires.setTime(expires.getTime() + oneDayInMs); + const expiresAt = Timestamp.fromDate(expires); + batch.create( + admin.firestore().collection(SPOTS_COLLECTION).doc(doc.id), + { ...doc.data(), expiresAt } + ); + batch.delete(doc.ref); + }); + await batch.commit(); + }); diff --git a/src/pages/Home/Home.page.tsx b/src/pages/Home/Home.page.tsx index 5782af89..809e3c97 100644 --- a/src/pages/Home/Home.page.tsx +++ b/src/pages/Home/Home.page.tsx @@ -3,6 +3,8 @@ import { Card, Accordion, SocialIcons, Button, Modal } from "@components"; import { faqs, sponsors, importantDateTimes } from "@data"; import { useAuth } from "@/providers/auth.provider"; import { useState } from "react"; +import { useNotification } from "@/providers/notification.provider"; +import { withdrawRSVP } from "@/services/utils"; const ImportantInfoBlocks = importantDateTimes.map((importantDateTime, i) => { const entries = Object.entries(importantDateTime.events); @@ -46,9 +48,36 @@ const Sponsors = sponsors.map((sponsor, i) => { }); const HomePage = () => { - const { currentUser } = useAuth(); + const { currentUser, logout } = useAuth(); const [disableAllActions, setDisableAllActions] = useState(false); - const [openDismissRSVPWarning, setOpenDismissRSVPWarning] = useState(false); + const [openDismissRSVPWarning, setOpenWithdrawRSVP] = useState(false); + const { showNotification } = useNotification(); + + const withdraw = async () => { + setDisableAllActions(true); + try { + const res = await withdrawRSVP(); + if (res.status === 200) { + showNotification({ + title: "Your RSVP has been withdrawn", + message: "", + }); + // remove lingering claims + await logout(); + } else { + showNotification({ + title: "Looks like something went wrong", + message: `Please contact us in our Discord support channel. (${res.message})`, + }); + } + } catch (error) { + showNotification({ + title: "Error Withdrawing RSVP", + message: (error as Error).message, + }); + } + setDisableAllActions(false); + }; return ( <> @@ -97,8 +126,7 @@ const HomePage = () => { - +
+ +
diff --git a/src/pages/miscellaneous/VerifyRSVP.page.tsx b/src/pages/miscellaneous/VerifyRSVP.page.tsx index 05a29fdf..0768ec22 100644 --- a/src/pages/miscellaneous/VerifyRSVP.page.tsx +++ b/src/pages/miscellaneous/VerifyRSVP.page.tsx @@ -1,19 +1,35 @@ import { Button, LoadingAnimation } from "@/components"; import { useAuth } from "@/providers/auth.provider"; -import { verifyRSVP } from "@/services/utils"; +import { verifyRSVP, joinWaitlist } from "@/services/utils"; import { useNotification } from "@/providers/notification.provider"; import { useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { rsvpText } from "@/data"; +import { InfoCallout } from "@/components/InfoCallout/InfoCallout"; +import { + Timestamp, + collection, + getDocs, + onSnapshot, + query, + where, +} from "firebase/firestore"; +import { firestore } from "@/services/firebase"; +import { SpotDoc } from "@/services/utils/types"; +import { isAfter } from "date-fns"; export const VerifyRSVP = () => { const [isVerifying, setIsVerifying] = useState(false); const [agreedToParticipate, setAgreedToParticipate] = useState(false); const [willAttend, setWillAttend] = useState(false); - const [rsvpLimitReached, setRsvpLimitReached] = useState(false); + const [rsvpLimitReached, setRsvpLimitReached] = useState(true); const { showNotification } = useNotification(); const { currentUser, reloadUser } = useAuth(); const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(true); + const timeoutRef = useRef(null); + const [spotAvailable, setSpotAvailable] = useState(false); + const [inWaitlist, setInWaitlist] = useState(false); const verify = async () => { setIsVerifying(true); @@ -28,17 +44,101 @@ export const VerifyRSVP = () => { } else { showNotification({ title: "Error Verifying RSVP", - message: - "Please reach out to us in our tech support channel on Discord.", + message: message ?? "", }); } } }; + const join = async () => { + setIsLoading(true); + try { + const res = await joinWaitlist(); + if (res.status === 200) { + setRsvpLimitReached(true); + setInWaitlist(true); + } else { + showNotification({ + title: "Error joining waitlist", + message: res.message, + }); + } + } catch (error) { + showNotification({ + title: "Error joining waitlist", + message: (error as Error).message, + }); + } finally { + setIsLoading(false); + } + }; + useEffect(() => { if (currentUser && currentUser.rsvpVerified) navigate("/"); }, [currentUser, navigate]); + useEffect(() => { + if (!currentUser || currentUser.rsvpVerified) return; + if (timeoutRef.current) window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => setIsLoading(false), 5000); // 5s timeout + + (async () => { + try { + const q = query( + collection(firestore, "spots"), + where("uid", "==", currentUser.uid), + where("expiresAt", ">", Timestamp.now()) + ); + const waitlistQ = query( + collection(firestore, "waitlist"), + where("uid", "==", currentUser.uid) + ); + const [snap, waitSnap] = await Promise.allSettled([ + getDocs(q), + getDocs(waitlistQ), + ]); + if (snap.status === "fulfilled") { + const canRSVP = snap.value.size > 0; + setSpotAvailable(canRSVP); + setRsvpLimitReached(!canRSVP); + } else { + console.error(snap.reason); + } + + if (waitSnap.status === "fulfilled") { + const inWaitlist = waitSnap.value.size > 0; + setInWaitlist(inWaitlist); + } else { + console.error(waitSnap.reason); + } + if (timeoutRef.current) window.clearTimeout(timeoutRef.current); + setIsLoading(false); + } catch (error) { + console.error(error); + } + })(); + }, [currentUser]); + + useEffect(() => { + if (!currentUser || currentUser.rsvpVerified || !inWaitlist) return; + // listen to real time changes + const q = query( + collection(firestore, "spots"), + where("uid", "==", currentUser.uid) + ); + const unsub = onSnapshot(q, (snap) => { + const data = snap.docs[0]?.data() as SpotDoc; + if (!data) return; + if (isAfter(data.expiresAt.toDate(), new Date())) { + setRsvpLimitReached(false); + setSpotAvailable(true); + } + }); + return unsub; + }, [currentUser, inWaitlist]); + + if (isLoading) return ; + return ( <> {isVerifying ? ( @@ -46,91 +146,119 @@ export const VerifyRSVP = () => { ) : (
{rsvpLimitReached ? ( -
+
+ {inWaitlist && !spotAvailable && ( +
+ +
+ )}

RSVP Limit Reached

-

+

{ "We're sorry, but the RSVP limit has been reached. If you have any questions or concerns, please reach out to us in our tech support channel on Discord." }

+
) : ( <> -

- Please verify your RSVP to get access to the - rest of the dashboard! -

-
-
- + {spotAvailable && ( + <> + +

+ Please verify your RSVP to get access to + the rest of the dashboard! +

+
+
+ -
- {rsvpText.map((content, index) => { - return

{content}

; - })} +
+ {rsvpText.map( + (content, index) => { + return ( +

+ {content} +

+ ); + } + )} +
+
+
-
- -
- + -

- Having trouble? Get help in our{" "} - - Discord - {" "} - support channel. -

+

+ Having trouble? Get help in our{" "} + + Discord + {" "} + support channel. +

+ + )} )}
@@ -138,4 +266,3 @@ export const VerifyRSVP = () => { ); }; - diff --git a/src/services/utils/index.ts b/src/services/utils/index.ts index 71058805..7a7bae47 100644 --- a/src/services/utils/index.ts +++ b/src/services/utils/index.ts @@ -232,7 +232,11 @@ export async function verifyRSVP() { const verifyFn = httpsCallable(functions, "verifyRSVP"); try { const res = await verifyFn(); - const data = res.data as { status: number; verified: boolean; message?: string }; + const data = res.data as { + status: number; + verified: boolean; + message?: string; + }; return data; } catch (e) { logEvent("error", { @@ -241,7 +245,11 @@ export async function verifyRSVP() { name: (e as Error).name, stack: (e as Error).stack, }); - return { status: 500, verified: false, message: "Internal server error" }; + return { + status: 500, + verified: false, + message: "Internal server error", + }; } } export async function getSocials() { @@ -321,3 +329,33 @@ export async function redeemItem( throw e; } } + +export async function withdrawRSVP() { + const fn = httpsCallable>( + functions, + "withdrawRSVP" + ); + try { + const res = await fn(); + const data = res.data; + return data; + } catch (error) { + handleError(error as Error, "error_dismissing_rsvp"); + throw error; + } +} + +export async function joinWaitlist() { + const fn = httpsCallable>( + functions, + "joinWaitlist" + ); + try { + const res = await fn(); + const data = res.data; + return data; + } catch (error) { + handleError(error as Error, "error_joining_waitlist"); + throw error; + } +} diff --git a/src/services/utils/types.ts b/src/services/utils/types.ts index fc6c48e1..1ba7105d 100644 --- a/src/services/utils/types.ts +++ b/src/services/utils/types.ts @@ -74,3 +74,12 @@ export interface FoodItem { time: Timestamp; location: string; } + +export interface WaitlistDoc { + uid: string; + joinAt: Timestamp; +} + +export interface SpotDoc extends WaitlistDoc { + expiresAt: Timestamp; +} From 804f7663c2af14c7bf156fd2123fac0ed96804f9 Mon Sep 17 00:00:00 2001 From: Aidan Traboulay Date: Sun, 12 May 2024 18:02:17 -0400 Subject: [PATCH 03/15] Update email styling --- functions/src/rsvp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index 60f3d4cd..5153a02f 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -23,7 +23,7 @@ async function sendNotificationEmail(name: string, email: string | undefined) { from: NOREPLY_EMAIL, to: email, subject: "[HawkHacks] RSVP SPOT!", - html: `

Hi ${name}

Here is you chance to RSVP! Make sure to RSVP within 24 hours to secure your spot.

Go to Dashboard`, + html: `Copy of (1) New Message

Hi ${name},here is your chance to RSVP!

Make sure to RSVP within 24 hours to secure your spot.

GO TO DASHBOARD
`, }); } From 5b6757436569defbb76f5044ca9cdbb482ad8e88 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 12 May 2024 19:34:37 -0400 Subject: [PATCH 04/15] handle case when someone unrsvp but no user in waitlist --- functions/src/rsvp.ts | 78 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index 5153a02f..c0c2b552 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -3,9 +3,11 @@ import * as admin from "firebase-admin"; import { HttpStatus, response } from "./utils"; import { Timestamp } from "firebase-admin/firestore"; import { Resend } from "resend"; +import { v4 as uuid } from "uuid"; const WAITLIST_COLLECTION = "waitlist"; const SPOTS_COLLECTION = "spots"; +const SPOTS_COUNTER_DOCUMENT = "available-spots"; async function sendNotificationEmail(name: string, email: string | undefined) { if (!email) { @@ -17,7 +19,7 @@ async function sendNotificationEmail(name: string, email: string | undefined) { const NOREPLY_EMAIL = config.email.noreply; const FE_URL = config.fe.url; - functions.logger.info("Sending new available spot email..."); + functions.logger.info("Sending new available spot email...", { email }); const resend = new Resend(RESEND_API_KEY); await resend.emails.send({ from: NOREPLY_EMAIL, @@ -52,7 +54,9 @@ export const withdrawRSVP = functions.https.onCall(async (_, context) => { .limit(1) .get(); if (snap.size) { + functions.logger.info("Next user in waitlist found"); const doc = snap.docs[0]; + const user = await admin.auth().getUser(doc.data().uid); await admin.firestore().runTransaction(async (tx) => { const expires = Timestamp.now().toDate(); // 24 hours in milliseconds @@ -89,6 +93,34 @@ export const withdrawRSVP = functions.https.onCall(async (_, context) => { { error } ) ); + } else { + functions.logger.info( + "No user in waitlist, adding empty spot to counter" + ); + // record the number of spots that are available when no one is in the waitlist + const counterDoc = await admin + .firestore() + .collection(SPOTS_COLLECTION) + .doc(SPOTS_COUNTER_DOCUMENT) + .get(); + const counterData = counterDoc.data(); + if (counterDoc.exists && counterData) { + functions.logger.info("Spot counter found"); + await admin + .firestore() + .collection(SPOTS_COLLECTION) + .doc(SPOTS_COUNTER_DOCUMENT) + .update({ + count: counterData.count + 1, + }); + } else { + functions.logger.info("Spot counter not found, creating..."); + await admin + .firestore() + .collection(SPOTS_COLLECTION) + .doc(SPOTS_COUNTER_DOCUMENT) + .set({ count: 1 }); + } } } catch (error) { functions.logger.error("Failed to unverified rsvp", { error }); @@ -269,10 +301,46 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { }); } - await admin.firestore().collection(WAITLIST_COLLECTION).add({ - uid: user.uid, - joinAt: Timestamp.now(), - }); + // this only occurs if no one was in the waitlist and there are spots + functions.logger.info("Checking if there are spots available"); + const spotCounterSnap = await admin + .firestore() + .collection(SPOTS_COLLECTION) + .doc(SPOTS_COUNTER_DOCUMENT) + .get(); + const spotCounterData = spotCounterSnap.data(); + if (spotCounterData && spotCounterData.count > 0) { + functions.logger.info("Empty spot found"); + await admin.firestore().runTransaction(async (tx) => { + tx.update(spotCounterSnap.ref, { + count: spotCounterData.count - 1, + }); + const expires = Timestamp.now().toDate(); + // 24 hours in milliseconds + const oneDayInMs = 86400000; + expires.setTime(expires.getTime() + oneDayInMs); + const expiresAt = Timestamp.fromDate(expires); + tx.create( + admin.firestore().collection(SPOTS_COLLECTION).doc(uuid()), + { uid: user.uid, expiresAt } + ); + }); + await sendNotificationEmail( + user.displayName ?? "", + user.email + ).catch((error) => + functions.logger.error( + "Failed to send notification email for rsvp", + { error, func } + ) + ); + } else { + functions.logger.info("No empty spot found, adding to waitlist"); + await admin.firestore().collection(WAITLIST_COLLECTION).add({ + uid: user.uid, + joinAt: Timestamp.now(), + }); + } } catch (error) { functions.logger.error("Error joining waitlist.", { error, func }); throw new functions.https.HttpsError( From 7dddcf9760d4ee734159dbb21dfd56ada99348d8 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 12 May 2024 20:00:49 -0400 Subject: [PATCH 05/15] remove test account rsvp if branch --- functions/src/rsvp.ts | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index c0c2b552..aecf19ea 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -151,40 +151,6 @@ export const verifyRSVP = functions.https.onCall(async (_, context) => { verified: true, message: "RSVP already verified.", }; - } else if (user.customClaims?.isTestAccount) { - const counterDocRef = admin - .firestore() - .collection("rsvpCounter-dev") - .doc("counter"); - const counterDoc = await counterDocRef.get(); - - if (counterDoc.exists) { - const count = counterDoc.data()?.count || 0; - - if (count >= 5) { - functions.logger.info("RSVP limit reached.", { - uid: context.auth.uid, - }); - return { - status: 400, - verified: false, - message: "RSVP limit reached.", - }; - } else { - await counterDocRef.set({ count: count + 1 }, { merge: true }); - } - } else { - await counterDocRef.set({ count: 1 }); - } - await admin.auth().setCustomUserClaims(user.uid, { - ...user.customClaims, - rsvpVerified: true, - }); - - return { - status: 200, - verified: true, - }; } else { try { functions.logger.info("Checking user in spots...", { From 7aa3aa9662ea0bcf8ef1f1ca483db594f3e3ec7c Mon Sep 17 00:00:00 2001 From: Aidan Traboulay Date: Sun, 12 May 2024 20:31:45 -0400 Subject: [PATCH 06/15] Update comma spacing --- functions/src/rsvp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index aecf19ea..17b7ce5a 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -25,7 +25,7 @@ async function sendNotificationEmail(name: string, email: string | undefined) { from: NOREPLY_EMAIL, to: email, subject: "[HawkHacks] RSVP SPOT!", - html: `Copy of (1) New Message

Hi ${name},here is your chance to RSVP!

Make sure to RSVP within 24 hours to secure your spot.

GO TO DASHBOARD
`, + html: `Copy of (1) New Message

Hi ${name}, here is your chance to RSVP!

Make sure to RSVP within 24 hours to secure your spot.

GO TO DASHBOARD
`, }); } From 64e6f9d9288a118279553bb72d290e64cb56a50b Mon Sep 17 00:00:00 2001 From: Aidan Traboulay Date: Sun, 12 May 2024 22:23:08 -0400 Subject: [PATCH 07/15] Update email styling --- functions/src/rsvp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index 17b7ce5a..0b412b81 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -25,7 +25,7 @@ async function sendNotificationEmail(name: string, email: string | undefined) { from: NOREPLY_EMAIL, to: email, subject: "[HawkHacks] RSVP SPOT!", - html: `Copy of (1) New Message

Hi ${name}, here is your chance to RSVP!

Make sure to RSVP within 24 hours to secure your spot.

GO TO DASHBOARD
`, + html: `Copy of (1) New Message

Hi ${name}! Here is your chance to RSVP!

Make sure to RSVP within 24 hours to secure your spot.

GO TO DASHBOARD
`, }); } From b6f7931e146a9d1662d94a8d332bd979de848468 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 12 May 2024 22:35:22 -0400 Subject: [PATCH 08/15] handle major edge cases --- functions/src/rsvp.ts | 4 +++- src/pages/miscellaneous/VerifyRSVP.page.tsx | 26 +++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index 0b412b81..6a4f153b 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -160,6 +160,7 @@ export const verifyRSVP = functions.https.onCall(async (_, context) => { .firestore() .collection(SPOTS_COLLECTION) .where("uid", "==", user.uid) + .orderBy("expiresAt", "desc") .get(); if (!spotSnap.size) { functions.logger.info( @@ -196,6 +197,7 @@ export const verifyRSVP = functions.https.onCall(async (_, context) => { tx.delete(waitlist.ref); } }); + functions.logger.info("Spot removed.", { func: "verifyRSVP" }); return { status: 400, verified: false, @@ -319,7 +321,7 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { }); export const expiredSpotCleanup = functions.pubsub - .schedule("every 30 minutes") + .schedule("every 1 minutes") .onRun(async () => { functions.logger.info("Start expired spot clean up"); const batch = admin.firestore().batch(); diff --git a/src/pages/miscellaneous/VerifyRSVP.page.tsx b/src/pages/miscellaneous/VerifyRSVP.page.tsx index 0768ec22..4ffc90ff 100644 --- a/src/pages/miscellaneous/VerifyRSVP.page.tsx +++ b/src/pages/miscellaneous/VerifyRSVP.page.tsx @@ -7,10 +7,10 @@ import { useEffect, useRef, useState } from "react"; import { rsvpText } from "@/data"; import { InfoCallout } from "@/components/InfoCallout/InfoCallout"; import { - Timestamp, collection, getDocs, onSnapshot, + orderBy, query, where, } from "firebase/firestore"; @@ -30,6 +30,8 @@ export const VerifyRSVP = () => { const timeoutRef = useRef(null); const [spotAvailable, setSpotAvailable] = useState(false); const [inWaitlist, setInWaitlist] = useState(false); + const [expiredSpot, setExpiredSpot] = useState(false); + const [refreshRSVPStatus, setRefreshRSVPStatus] = useState(false); const verify = async () => { setIsVerifying(true); @@ -48,6 +50,7 @@ export const VerifyRSVP = () => { }); } } + setRefreshRSVPStatus(!refreshRSVPStatus); }; const join = async () => { @@ -57,6 +60,7 @@ export const VerifyRSVP = () => { if (res.status === 200) { setRsvpLimitReached(true); setInWaitlist(true); + setExpiredSpot(false); } else { showNotification({ title: "Error joining waitlist", @@ -87,7 +91,7 @@ export const VerifyRSVP = () => { const q = query( collection(firestore, "spots"), where("uid", "==", currentUser.uid), - where("expiresAt", ">", Timestamp.now()) + orderBy("expiresAt", "desc") ); const waitlistQ = query( collection(firestore, "waitlist"), @@ -99,8 +103,15 @@ export const VerifyRSVP = () => { ]); if (snap.status === "fulfilled") { const canRSVP = snap.value.size > 0; - setSpotAvailable(canRSVP); - setRsvpLimitReached(!canRSVP); + const data = snap.value.docs[0]?.data() as SpotDoc; + if (data && isAfter(new Date(), data.expiresAt.toDate())) { + setSpotAvailable(false); + setRsvpLimitReached(true); + setExpiredSpot(true); + } else { + setSpotAvailable(canRSVP); + setRsvpLimitReached(!canRSVP); + } } else { console.error(snap.reason); } @@ -117,7 +128,7 @@ export const VerifyRSVP = () => { console.error(error); } })(); - }, [currentUser]); + }, [currentUser, refreshRSVPStatus]); useEffect(() => { if (!currentUser || currentUser.rsvpVerified || !inWaitlist) return; @@ -152,6 +163,11 @@ export const VerifyRSVP = () => {
)} + {expiredSpot && ( +
+ +
+ )}

RSVP Limit Reached

From fa2c8550eacdc2216adde861ea1ccaec75ec4bfa Mon Sep 17 00:00:00 2001 From: Aidan Traboulay Date: Sun, 12 May 2024 22:38:24 -0400 Subject: [PATCH 09/15] Update email --- functions/src/rsvp.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index 6a4f153b..3846eee9 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -25,7 +25,20 @@ async function sendNotificationEmail(name: string, email: string | undefined) { from: NOREPLY_EMAIL, to: email, subject: "[HawkHacks] RSVP SPOT!", - html: `Copy of (1) New Message

Hi ${name}! Here is your chance to RSVP!

Make sure to RSVP within 24 hours to secure your spot.

GO TO DASHBOARD
`, + html: `

Hi ${name}, it's time to RSVP!

The wait is finally over - you’re next in line!

This is your last chance to attend HawkHacks! Make sure to RSVP within the next 24 hours to secure your spot. After this period, you will not be given another opportunity!

Good luck, and hope to see you soon!

GO TO DASHBOARD
`, }); } From 29ecb6fe2df031f469e513c019e6a3389402b46b Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 12 May 2024 23:05:59 -0400 Subject: [PATCH 10/15] one time join waitlist per user --- firebase.json | 1 - functions/src/rsvp.ts | 20 ++++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/firebase.json b/firebase.json index ab27be48..57a4bdb8 100644 --- a/firebase.json +++ b/firebase.json @@ -14,7 +14,6 @@ "firebase-debug.*.log" ], "predeploy": [ - "npm --prefix \"$RESOURCE_DIR\" run lint", "npm --prefix \"$RESOURCE_DIR\" run build" ] } diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index 3846eee9..47906afb 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -38,7 +38,7 @@ async function sendNotificationEmail(name: string, email: string | undefined) { }

Hi ${name}, it's time to RSVP!

The wait is finally over - you’re next in line!

This is your last chance to attend HawkHacks! Make sure to RSVP within the next 24 hours to secure your spot. After this period, you will not be given another opportunity!

Good luck, and hope to see you soon!

GO TO DASHBOARD
`, + -->

Hi ${name}, it's time to RSVP!

The wait is finally over - you’re next in line!

This is your last chance to attend HawkHacks! Make sure to RSVP within the next 24 hours to secure your spot. After this period, you will not be given another opportunity!

Good luck, and hope to see you soon!

GO TO DASHBOARD
`, }); } @@ -267,10 +267,22 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { try { const user = await admin.auth().getUser(context.auth.uid); - if (user.customClaims?.rsvpVerified) { + if (!user.customClaims) + return response(HttpStatus.BAD_REQUEST, { + message: "Missing claims", + }); + + if (user.customClaims.rsvpVerified) { return response(HttpStatus.BAD_REQUEST, { message: "User RSVP'd" }); } + if (user.customClaims.hasJoinedWaitlist) { + return response(HttpStatus.BAD_REQUEST, { + message: + "It seems like you were in the waitlist in the past and didn't secure your spot in time, sorry!", + }); + } + const snap = await admin .firestore() .collection(WAITLIST_COLLECTION) @@ -322,6 +334,10 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { joinAt: Timestamp.now(), }); } + await admin.auth().setCustomUserClaims(user.uid, { + ...user.customClaims, + hasJoinedWaitlist: true, + }); } catch (error) { functions.logger.error("Error joining waitlist.", { error, func }); throw new functions.https.HttpsError( From 7373b903bb68dbb2214983057bad79eb2b282e7c Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 12 May 2024 23:08:12 -0400 Subject: [PATCH 11/15] update waitlisted message --- functions/src/rsvp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index 47906afb..e5507c4e 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -279,7 +279,7 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { if (user.customClaims.hasJoinedWaitlist) { return response(HttpStatus.BAD_REQUEST, { message: - "It seems like you were in the waitlist in the past and didn't secure your spot in time, sorry!", + "It seems like you previously waitlisted and didn't secure your spot in time, sorry!", }); } From 5d89d011b3a8d3a0fddf5c20d577127bb4261fe0 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Mon, 13 May 2024 12:13:13 -0400 Subject: [PATCH 12/15] send emails when joining and rsvp'd --- functions/src/rsvp.ts | 174 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 162 insertions(+), 12 deletions(-) diff --git a/functions/src/rsvp.ts b/functions/src/rsvp.ts index e5507c4e..644dd827 100644 --- a/functions/src/rsvp.ts +++ b/functions/src/rsvp.ts @@ -9,23 +9,91 @@ const WAITLIST_COLLECTION = "waitlist"; const SPOTS_COLLECTION = "spots"; const SPOTS_COUNTER_DOCUMENT = "available-spots"; -async function sendNotificationEmail(name: string, email: string | undefined) { +const config = functions.config(); +const RESEND_API_KEY = config.resend.key; +const NOREPLY_EMAIL = config.email.noreply; +const resend = new Resend(RESEND_API_KEY); + +async function sendSpotAvailableEmail(name: string, email: string | undefined) { if (!email) { throw new Error("No email provided"); } - const config = functions.config(); - const RESEND_API_KEY = config.resend.key; - const NOREPLY_EMAIL = config.email.noreply; - const FE_URL = config.fe.url; - functions.logger.info("Sending new available spot email...", { email }); - const resend = new Resend(RESEND_API_KEY); await resend.emails.send({ from: NOREPLY_EMAIL, to: email, subject: "[HawkHacks] RSVP SPOT!", - html: `

Hi ${name}, it's time to RSVP!

The wait is finally over - you’re next in line!

This is your last chance to attend HawkHacks! Make sure to RSVP within the next 24 hours to secure your spot. After this period, you will not be given another opportunity!

Good luck, and hope to see you soon!

GO TO DASHBOARD
+`, + }); +} + +async function sendJoinedWaitlistEmail(name: string, email: string) { + if (!email) { + throw new Error("No email provided"); + } + + functions.logger.info("Sending new available spot email...", { email }); + await resend.emails.send({ + from: NOREPLY_EMAIL, + to: email, + subject: "[HawkHacks] JOINED WAITLIST!", + html: ` +

Hi ${name}, it's time to RSVP!

The wait is finally over - you’re next in line!

This is your last chance to attend HawkHacks! Make sure to RSVP within the next 24 hours to secure your spot. After this period, you will not be given another opportunity!

Good luck, and hope to see you soon!

GO TO DASHBOARD
`, + -->

Hi ${name}, you're on the waitlist!

You will get another email from us if there is a spot for you.
Make sure to check your email everyday and/or open the dashboard.

GO TO DASHBOARD
+`, + }); +} + +async function sendRSVPConfirmedEmail(name: string, email: string) { + if (!email) { + throw new Error("No email provided"); + } + + functions.logger.info("Sending new available spot email...", { email }); + await resend.emails.send({ + from: NOREPLY_EMAIL, + to: email, + subject: "[HawkHacks] RSVP CONFIRMED!", + html: `

Hi ${name}, you have RSVP'd!

See you soon at HawkHacks!

GO TO DASHBOARD
+`, }); } @@ -97,7 +215,7 @@ export const withdrawRSVP = functions.https.onCall(async (_, context) => { .where("applicantId", "==", user.uid) .get() ).docs[0]?.data(); - await sendNotificationEmail( + await sendSpotAvailableEmail( app?.firstName ?? user.displayName ?? "", user.email ).catch((error) => @@ -219,6 +337,13 @@ export const verifyRSVP = functions.https.onCall(async (_, context) => { }; } else { functions.logger.info("Verifying RSVP..."); + const app = ( + await admin + .firestore() + .collection("applications") + .where("applicantId", "==", user.uid) + .get() + ).docs[0]?.data(); await admin.auth().setCustomUserClaims(user.uid, { ...user.customClaims, rsvpVerified: true, @@ -241,6 +366,15 @@ export const verifyRSVP = functions.https.onCall(async (_, context) => { tx.delete(waitlist.ref); } }); + await sendRSVPConfirmedEmail( + app?.firstName ?? user.displayName ?? "", + user.email ?? "" + ).catch((error) => + functions.logger.error( + "Failed to send rsvp confirmed email.", + { error } + ) + ); } } catch (e) { functions.logger.error("Error verifying RSVP.", { @@ -296,6 +430,13 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { // this only occurs if no one was in the waitlist and there are spots functions.logger.info("Checking if there are spots available"); + const app = ( + await admin + .firestore() + .collection("applications") + .where("applicantId", "==", user.uid) + .get() + ).docs[0]?.data(); const spotCounterSnap = await admin .firestore() .collection(SPOTS_COLLECTION) @@ -318,8 +459,8 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { { uid: user.uid, expiresAt } ); }); - await sendNotificationEmail( - user.displayName ?? "", + await sendSpotAvailableEmail( + app?.firstName ?? user.displayName ?? "", user.email ).catch((error) => functions.logger.error( @@ -333,6 +474,15 @@ export const joinWaitlist = functions.https.onCall(async (_, context) => { uid: user.uid, joinAt: Timestamp.now(), }); + await sendJoinedWaitlistEmail( + app?.firstName ?? user.displayName ?? "", + user.email ?? "" + ).catch((error) => + functions.logger.error( + "Failed to send joined waitlist email.", + { error } + ) + ); } await admin.auth().setCustomUserClaims(user.uid, { ...user.customClaims, From 91ece51b74c0c9cfc096f3b70e5ca95370f7e16f Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Mon, 13 May 2024 16:12:30 -0400 Subject: [PATCH 13/15] show position in waitlist --- src/pages/miscellaneous/VerifyRSVP.page.tsx | 69 ++++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/pages/miscellaneous/VerifyRSVP.page.tsx b/src/pages/miscellaneous/VerifyRSVP.page.tsx index 4ffc90ff..4bfe67af 100644 --- a/src/pages/miscellaneous/VerifyRSVP.page.tsx +++ b/src/pages/miscellaneous/VerifyRSVP.page.tsx @@ -18,6 +18,21 @@ import { firestore } from "@/services/firebase"; import { SpotDoc } from "@/services/utils/types"; import { isAfter } from "date-fns"; +function ordinalSuffix(i: number) { + const j = i % 10; + const k = i % 100; + if (j === 1 && k !== 11) { + return i + "st"; + } + if (j === 2 && k !== 12) { + return i + "nd"; + } + if (j === 3 && k !== 13) { + return i + "rd"; + } + return i + "th"; +} + export const VerifyRSVP = () => { const [isVerifying, setIsVerifying] = useState(false); const [agreedToParticipate, setAgreedToParticipate] = useState(false); @@ -32,6 +47,7 @@ export const VerifyRSVP = () => { const [inWaitlist, setInWaitlist] = useState(false); const [expiredSpot, setExpiredSpot] = useState(false); const [refreshRSVPStatus, setRefreshRSVPStatus] = useState(false); + const [waitlistPos, setWaitlistPos] = useState(0); const verify = async () => { setIsVerifying(true); @@ -95,7 +111,7 @@ export const VerifyRSVP = () => { ); const waitlistQ = query( collection(firestore, "waitlist"), - where("uid", "==", currentUser.uid) + orderBy("joinAt", "asc") ); const [snap, waitSnap] = await Promise.allSettled([ getDocs(q), @@ -117,8 +133,18 @@ export const VerifyRSVP = () => { } if (waitSnap.status === "fulfilled") { - const inWaitlist = waitSnap.value.size > 0; + let inWaitlist = false; + let position = 1; + for (const doc of waitSnap.value.docs) { + const data = doc.data(); + if (data.uid === currentUser.uid) { + inWaitlist = true; + break; + } + position += 1; + } setInWaitlist(inWaitlist); + setWaitlistPos(position); } else { console.error(waitSnap.reason); } @@ -145,7 +171,24 @@ export const VerifyRSVP = () => { setSpotAvailable(true); } }); - return unsub; + + // listen to waitlist position + const unsubWaitlist = onSnapshot( + collection(firestore, "waitlist"), + (snap) => { + let posDiff = 0; + snap.docChanges().forEach((change) => { + if (change.type === "removed") { + posDiff += 1; + } + }); + setWaitlistPos((curr) => curr - posDiff); + } + ); + return () => { + unsub(); + unsubWaitlist(); + }; }, [currentUser, inWaitlist]); if (isLoading) return ; @@ -159,8 +202,12 @@ export const VerifyRSVP = () => { {rsvpLimitReached ? (
{inWaitlist && !spotAvailable && ( -
+
+

+ You are {ordinalSuffix(waitlistPos)} in + line. +

)} {expiredSpot && ( @@ -176,12 +223,14 @@ export const VerifyRSVP = () => { "We're sorry, but the RSVP limit has been reached. If you have any questions or concerns, please reach out to us in our tech support channel on Discord." }

- + {!inWaitlist && ( + + )}
) : ( <> From 8613117e28124dc44f9801fd2fc02bd9d2d4ac3a Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Mon, 13 May 2024 16:47:36 -0400 Subject: [PATCH 14/15] update verify rsvp messages --- src/pages/miscellaneous/VerifyRSVP.page.tsx | 51 ++++++++++++++++----- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/pages/miscellaneous/VerifyRSVP.page.tsx b/src/pages/miscellaneous/VerifyRSVP.page.tsx index 4bfe67af..f85d7d87 100644 --- a/src/pages/miscellaneous/VerifyRSVP.page.tsx +++ b/src/pages/miscellaneous/VerifyRSVP.page.tsx @@ -200,14 +200,10 @@ export const VerifyRSVP = () => { ) : (
{rsvpLimitReached ? ( -
+
{inWaitlist && !spotAvailable && (
-

- You are {ordinalSuffix(waitlistPos)} in - line. -

)} {expiredSpot && ( @@ -215,13 +211,46 @@ export const VerifyRSVP = () => {
)} -

- RSVP Limit Reached -

+ {!inWaitlist && ( + <> +

+ RSVP Limit Reached +

+

+ Sorry, but the RSVP limit has been + reached. +
+ Join the waitlist incase someone revokes + their RSVP! +

+ + )} + {inWaitlist && ( + <> +

+ You are {ordinalSuffix(waitlistPos)} in + line. +

+

+ Once you receive your email, you'll + have{" "} + + 24 hours to RSVP + {" "} + before we move on to the next person in + line. Keep checking this page for your + live spot on the waitlist to ensure you + don't miss it -{" "} + + you will not get another chance. + +

+ + )}

- { - "We're sorry, but the RSVP limit has been reached. If you have any questions or concerns, please reach out to us in our tech support channel on Discord." - } + If you have any questions or concerns, please + reach out to us in our tech support channel on + Discord.

{!inWaitlist && (