From 2750867667d1c0dc6d5d781ff8f74fe7cf5303f4 Mon Sep 17 00:00:00 2001 From: Matthew <92887765+mjanderson1227@users.noreply.github.com> Date: Thu, 11 Jul 2024 22:50:08 -0500 Subject: [PATCH] Added a maximum limit of users who can RSVP * Added default limit for the number of allowed RSVPs in the config. * Added check to prevent RSVP users from going over the allowed limit in the RSVP page. * Added the ability to edit RSVPLimit from the admin dashboard. --- .../src/actions/admin/registration-actions.ts | 13 ++++ .../app/admin/toggles/registration/page.tsx | 17 +++-- apps/web/src/app/rsvp/page.tsx | 22 ++++--- .../admin/toggles/RegistrationSettings.tsx | 27 ++++++++ .../toggles/UpdateItemWithConfirmation.tsx | 66 +++++++++++++++++++ apps/web/src/lib/utils/server/redis.ts | 8 +++ packages/config/hackkit.config.ts | 1 + 7 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/admin/toggles/UpdateItemWithConfirmation.tsx diff --git a/apps/web/src/actions/admin/registration-actions.ts b/apps/web/src/actions/admin/registration-actions.ts index 9388bbba..8a953100 100644 --- a/apps/web/src/actions/admin/registration-actions.ts +++ b/apps/web/src/actions/admin/registration-actions.ts @@ -9,6 +9,10 @@ const defaultRegistrationToggleSchema = z.object({ enabled: z.boolean(), }); +const defaultRSVPLimitSchema = z.object({ + rsvpLimit: z.number() +}); + export const toggleRegistrationEnabled = adminAction( defaultRegistrationToggleSchema, async ({ enabled }, { user, userId }) => { @@ -44,3 +48,12 @@ export const toggleRSVPs = adminAction( return { success: true, statusSet: enabled }; }, ); + +export const setRSVPLimit = adminAction( + defaultRSVPLimitSchema, + async ({ rsvpLimit }) => { + await kv.set("config:registration:maxRSVPs", rsvpLimit); + revalidatePath("/admin/toggles/registration"); + return { success: true, statusSet: rsvpLimit }; + } +); diff --git a/apps/web/src/app/admin/toggles/registration/page.tsx b/apps/web/src/app/admin/toggles/registration/page.tsx index 3825236b..2b990ed6 100644 --- a/apps/web/src/app/admin/toggles/registration/page.tsx +++ b/apps/web/src/app/admin/toggles/registration/page.tsx @@ -1,6 +1,7 @@ import { RegistrationToggles } from "@/components/admin/toggles/RegistrationSettings"; import { kv } from "@vercel/kv"; -import { parseRedisBoolean } from "@/lib/utils/server/redis"; +import { parseRedisBoolean, parseRedisNumber } from "@/lib/utils/server/redis"; +import c from "config"; export default async function Page() { const pipe = kv.pipeline(); @@ -8,14 +9,14 @@ export default async function Page() { pipe.get("config:registration:secretRegistrationEnabled"); // const result = await pipe.exec(); - const [ - defaultRegistrationEnabled, - defaultSecretRegistrationEnabled, - defaultRSVPsEnabled, - ]: (string | null)[] = await kv.mget( + const [defaultRegistrationEnabled, defaultSecretRegistrationEnabled, defaultRSVPsEnabled, defaultRSVPLimit]: ( + | string + | null + )[] = await kv.mget( "config:registration:registrationEnabled", "config:registration:secretRegistrationEnabled", "config:registration:allowRSVPs", + "config:registration:maxRSVPs" ); return ( @@ -38,6 +39,10 @@ export default async function Page() { defaultRSVPsEnabled, true, )} + defaultRSVPLimit={parseRedisNumber( + defaultRSVPLimit, + c.rsvpDefaultLimit + )} /> ); diff --git a/apps/web/src/app/rsvp/page.tsx b/apps/web/src/app/rsvp/page.tsx index 0bd5d2cd..320a7ea4 100644 --- a/apps/web/src/app/rsvp/page.tsx +++ b/apps/web/src/app/rsvp/page.tsx @@ -2,13 +2,13 @@ import ConfirmDialogue from "@/components/rsvp/ConfirmDialogue"; import c from "config"; import { auth } from "@clerk/nextjs"; import { redirect } from "next/navigation"; -import { db } from "db"; +import { count, db } from "db"; import { eq } from "db/drizzle"; import { users } from "db/schema"; import ClientToast from "@/components/shared/ClientToast"; import { SignedOut, RedirectToSignIn } from "@clerk/nextjs"; import { kv } from "@vercel/kv"; -import { parseRedisBoolean } from "@/lib/utils/server/redis"; +import { parseRedisBoolean, parseRedisNumber } from "@/lib/utils/server/redis"; import Link from "next/link"; import { Button } from "@/components/shadcn/ui/button"; import { CheckCircleIcon } from "lucide-react"; @@ -38,15 +38,19 @@ export default async function RsvpPage({ } const rsvpEnabled = await kv.get("config:registration:allowRSVPs"); + const rsvpLimit = parseRedisNumber(await kv.get("config:registration:maxRSVPs"), c.rsvpDefaultLimit); + const rsvpUserCount = await db + .select({ count: count() }) + .from(users) + .where(eq(users.rsvp, true)) + .limit(rsvpLimit) + .then((result) => result[0].count); // TODO: fix type jank here - if ( - parseRedisBoolean( - rsvpEnabled as string | boolean | null | undefined, - true, - ) === true || - user.rsvp === true - ) { + const isRsvpPossible = parseRedisBoolean(rsvpEnabled as string | boolean | null | undefined, true) === true && + rsvpUserCount < rsvpLimit; + + if (isRsvpPossible || user.rsvp === true) { return ( <> diff --git a/apps/web/src/components/admin/toggles/RegistrationSettings.tsx b/apps/web/src/components/admin/toggles/RegistrationSettings.tsx index 180fdd12..55af38bf 100644 --- a/apps/web/src/components/admin/toggles/RegistrationSettings.tsx +++ b/apps/web/src/components/admin/toggles/RegistrationSettings.tsx @@ -11,18 +11,22 @@ import { toggleRegistrationMessageEnabled, toggleSecretRegistrationEnabled, toggleRSVPs, + setRSVPLimit, } from "@/actions/admin/registration-actions"; +import { UpdateItemWithConfirmation } from "./UpdateItemWithConfirmation"; interface RegistrationTogglesProps { defaultRegistrationEnabled: boolean; defaultSecretRegistrationEnabled: boolean; defaultRSVPsEnabled: boolean; + defaultRSVPLimit: number } export function RegistrationToggles({ defaultSecretRegistrationEnabled, defaultRegistrationEnabled, defaultRSVPsEnabled, + defaultRSVPLimit }: RegistrationTogglesProps) { const { execute: executeToggleSecretRegistrationEnabled, @@ -57,6 +61,17 @@ export function RegistrationToggles({ }, ); + const { + execute: executeSetRSVPLimit, + optimisticData: SetRSVPLimitOptimisticData + } = useOptimisticAction( + setRSVPLimit, + { success: true, statusSet: defaultRSVPLimit }, + (_, { rsvpLimit }) => { + return { statusSet: rsvpLimit, success: true } + } + ); + return ( <>
@@ -116,6 +131,18 @@ export function RegistrationToggles({ }} />
+
+

RSVP Limit

+ { + toast.success(`Limit on the number of users who can RSVP changed to ${newLimit}`) + executeSetRSVPLimit({ rsvpLimit: newLimit }) + }} + /> +
diff --git a/apps/web/src/components/admin/toggles/UpdateItemWithConfirmation.tsx b/apps/web/src/components/admin/toggles/UpdateItemWithConfirmation.tsx new file mode 100644 index 00000000..8cd29384 --- /dev/null +++ b/apps/web/src/components/admin/toggles/UpdateItemWithConfirmation.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { Input } from "@/components/shadcn/ui/input"; +import { Button } from "@/components/shadcn/ui/button"; + +interface UpdateItemWithConfirmationBaseProps { + defaultValue: T; + enabled: boolean; + onSubmit: (value: T) => void; +} + +type UpdateItemWithConfirmationProps = + | ({ type: "string" } & UpdateItemWithConfirmationBaseProps) + | ({ type: "number" } & UpdateItemWithConfirmationBaseProps); + +export function UpdateItemWithConfirmation({ + type, + defaultValue, + onSubmit, + enabled, +}: UpdateItemWithConfirmationProps) { + const [valueUpdated, setValueUpdated] = useState(false); + const [value, setValue] = useState(defaultValue.toString()); + + return ( +
+ { + // Ignore the change if the value is a non numeric character. + if (type === "number" && /[^0-9]/.test(updated)) { + setValue(value); + return; + } + + setValue(updated); + + /* Avoid allowing the user to update the default value to itself. + * Also disallow the user from sending a zero length input. */ + setValueUpdated( + updated !== defaultValue.toString() && updated.length !== 0 + ); + }} + /> + +
+ ); +} diff --git a/apps/web/src/lib/utils/server/redis.ts b/apps/web/src/lib/utils/server/redis.ts index a011944f..d25f55c5 100644 --- a/apps/web/src/lib/utils/server/redis.ts +++ b/apps/web/src/lib/utils/server/redis.ts @@ -31,3 +31,11 @@ export function parseRedisBoolean( if (typeof value === "boolean") return value; return defaultValue !== undefined ? defaultValue : false; } + +export function parseRedisNumber(value: string | null, defaultValue: number) { + if (value && !isNaN(parseInt(value))) { + return parseInt(value); + } else { + return defaultValue; + } +} diff --git a/packages/config/hackkit.config.ts b/packages/config/hackkit.config.ts index 07ab1575..72bf4610 100644 --- a/packages/config/hackkit.config.ts +++ b/packages/config/hackkit.config.ts @@ -8,6 +8,7 @@ export default { botName: "HackKit", botParticipantRole: "Participant", hackathonTimezone: "America/Chicago", + rsvpDefaultLimit: 500, localUniversityName: "The University of Texas at San Antonio", localUniversityShortIDName: "ABC123", localUniversityShortIDMaxLength: 6,