diff --git a/amplify/auth/PostConfirmation/handler.ts b/amplify/auth/PostConfirmation/handler.ts index 702bd92f..000352d9 100644 --- a/amplify/auth/PostConfirmation/handler.ts +++ b/amplify/auth/PostConfirmation/handler.ts @@ -57,7 +57,6 @@ export const handler: PostConfirmationTriggerHandler = async (event) => { role: "Participant", id: event.request.userAttributes.sub, email: event.request.userAttributes.email, - checkedIn: false, willEatMeals: false, allergies: "", institution: "", diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 0f6bc665..3f5f2a0b 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -34,13 +34,6 @@ const schema = a completedRegistration: a.boolean(), allergies: a.string(), willEatMeals: a.boolean(), - checkedIn: a - .boolean() - .default(false) - .authorization((allow) => [ - allow.ownerDefinedIn("profileOwner").to(["read"]), - allow.groups(["Admin"]).to(["read", "update", "delete", "create"]), - ]), teamId: a .id() .authorization((allow) => [ @@ -94,6 +87,12 @@ const schema = a members: a.hasMany("User", "teamId"), scores: a.hasMany("Score", "teamId"), teamRooms: a.hasMany("TeamRoom", "teamId"), + devPostLink: a + .string() + .authorization((allow) => [ + allow.groups(["Admin"]).to(["read", "update"]), + allow.authenticated().to(["read", "update"]), + ]), }) .authorization((allow) => [ allow.group("Admin").to(["read", "update", "create", "delete"]), @@ -279,21 +278,6 @@ const schema = a .authorization((allow) => [allow.group("Admin")]) .handler(a.handler.function(ResetHackathon)) .returns(a.ref("StatusCodeFunctionResponse")), - - // Custom resolvers - SetUserAsCheckedIn: a - .mutation() - .arguments({ - userId: a.string().required(), - }) - .returns(a.ref("User")) - .authorization((allow) => [allow.authenticated()]) - .handler( - a.handler.custom({ - dataSource: a.ref("User"), - entry: "./user/SetUserAsCheckedIn.js", - }), - ), }) .authorization((allow) => [ diff --git a/amplify/data/user/SetUserAsCheckedIn.js b/amplify/data/user/SetUserAsCheckedIn.js deleted file mode 100644 index e1b46d2c..00000000 --- a/amplify/data/user/SetUserAsCheckedIn.js +++ /dev/null @@ -1,14 +0,0 @@ -export function request(ctx) { - return { - operation: "UpdateItem", - key: util.dynamodb.toMapValues({ id: ctx.args.userId }), - update: { - expression: "SET checkedIn = :checkedIn", - expressionValues: { ":checkedIn": { BOOL: true } }, - }, - }; -} - -export function response(ctx) { - return ctx.result; -} diff --git a/amplify/function/BusinessLogic/CreateTeamWithCode/handler.ts b/amplify/function/BusinessLogic/CreateTeamWithCode/handler.ts index 75c916bd..a372588c 100644 --- a/amplify/function/BusinessLogic/CreateTeamWithCode/handler.ts +++ b/amplify/function/BusinessLogic/CreateTeamWithCode/handler.ts @@ -2,6 +2,7 @@ import { Amplify } from "aws-amplify"; import { generateClient } from "aws-amplify/data"; import type { AppSyncIdentityCognito } from "aws-lambda"; import type { Schema } from "../../../data/resource"; +import { tryCatch } from "../utils/try-catch"; import { createTeam, updateUser } from "./graphql/mutations"; import { getTeam } from "./graphql/queries"; @@ -37,101 +38,106 @@ const client = generateClient({ authMode: "iam", }); +const createNewTeam = (teamName: string, teamId: string) => + client.graphql({ + query: createTeam, + variables: { + input: { + name: teamName, + id: teamId, + }, + }, + }); +const updateUserTeam = (id: string, teamId: string) => + client.graphql({ + query: updateUser, + variables: { + input: { + id, + teamId, + }, + }, + }); +const generateTeamId = () => + Array.from(Array(4), () => + Math.floor(Math.random() * 36) + .toString(36) + .toUpperCase(), + ).join(""); +const getTeamFromId = (teamId: string) => + client.graphql({ + query: getTeam, + variables: { + id: teamId, + }, + }); export const handler: Schema["CreateTeamWithCode"]["functionHandler"] = async ( event, ) => { - let team = null; - let teamId: string | null = null; - try { - do { - teamId = Array.from(Array(4), () => - Math.floor(Math.random() * 36) - .toString(36) - .toUpperCase(), - ).join(""); + const { + arguments: { addCallerToTeam, teamName }, + } = event; - team = ( - await client.graphql({ - query: getTeam, - variables: { - id: teamId, - }, - }) - ).data.getTeam; - } while (team != null); + let teamId = generateTeamId(); + let { error: teamIdTaken } = await tryCatch(getTeamFromId(teamId)); + let attempts = 0; + const MAX_ATTEMPTS = 100; // Define a maximum number of attempts - const teamCreation = await client - .graphql({ - query: createTeam, - variables: { - input: { - name: event.arguments.teamName, - id: teamId, - }, - }, - }) - .catch(() => { - throw new Error( - JSON.stringify({ - body: { value: `Error creating team` }, - statusCode: 500, - headers: { "Content-Type": "application/json" }, - }), - ); - }); + while (teamIdTaken && attempts < MAX_ATTEMPTS) { + teamId = generateTeamId(); + ({ error: teamIdTaken } = await tryCatch(getTeamFromId(teamId))); + attempts++; + } - if (teamCreation) { - if (event.arguments.addCallerToTeam) { - return await client - .graphql({ - query: updateUser, - variables: { - input: { - id: (event.identity as AppSyncIdentityCognito).sub, - teamId: teamId, - }, - }, - }) - .then(() => { - return { - body: { value: teamId }, - statusCode: 200, - headers: { "Content-Type": "application/json" }, - }; - }) - .catch(() => { - throw new Error( - JSON.stringify({ - body: { value: `Error updating user (team was created)` }, - statusCode: 500, - headers: { "Content-Type": "application/json" }, - }), - ); - }); - } else { - return { - body: { value: teamId }, - statusCode: 200, - headers: { "Content-Type": "application/json" }, - }; - } - } else { - throw new Error( - JSON.stringify({ - body: { value: `Error creating team` }, - statusCode: 500, - headers: { "Content-Type": "application/json" }, - }), - ); - } - } catch (error) { - console.error(error); + if (teamIdTaken) { + // Handle the case where a unique team ID could not be generated + throw new Error( + JSON.stringify({ + body: { + value: `Failed to generate a unique team ID after ${MAX_ATTEMPTS} attempts.`, + }, + statusCode: 500, + headers: { "Content-Type": "application/json" }, + }), + ); + } + const { error: createTeamError } = await tryCatch( + createNewTeam(teamName, teamId), + ); + if (createTeamError) { throw new Error( JSON.stringify({ - body: { value: `Unhandled Internal Server Error` }, + body: { value: `Error creating team` }, statusCode: 500, headers: { "Content-Type": "application/json" }, }), ); } + if (!addCallerToTeam) { + return { + body: { value: teamId }, + statusCode: 200, + headers: { "Content-Type": "application/json" }, + }; + } + const { error: updateUserError, data: updateUserSuccess } = await tryCatch( + updateUserTeam((event.identity as AppSyncIdentityCognito).sub, teamId), + ); + if (updateUserSuccess) { + return { + body: { value: teamId }, + statusCode: 200, + headers: { "Content-Type": "application/json" }, + }; + } + + throw new Error( + JSON.stringify({ + body: { + value: `Error updating user ( ${(event.identity as AppSyncIdentityCognito).sub}) (team was created) ${updateUserError}`, + }, + statusCode: 500, + headers: { "Content-Type": "application/json" }, + }), + ); }; diff --git a/amplify/function/BusinessLogic/utils/try-catch.ts b/amplify/function/BusinessLogic/utils/try-catch.ts new file mode 100644 index 00000000..6d5f46ce --- /dev/null +++ b/amplify/function/BusinessLogic/utils/try-catch.ts @@ -0,0 +1,24 @@ +// Types for the result object with discriminated union +type Success = { + data: T; + error: null; +}; + +type Failure = { + data: null; + error: E; +}; + +type Result = Success | Failure; + +// Main wrapper function +export async function tryCatch( + promise: Promise, +): Promise> { + try { + const data = await promise; + return { data, error: null }; + } catch (error) { + return { data: null, error: error as E }; + } +} diff --git a/src/app/admin/teams/TeamTableSetup.tsx b/src/app/admin/teams/TeamTableSetup.tsx index 0e4ff409..88813abf 100644 --- a/src/app/admin/teams/TeamTableSetup.tsx +++ b/src/app/admin/teams/TeamTableSetup.tsx @@ -47,14 +47,6 @@ export const teamColumns = [ filterFn: "includesString", sortingFn: "alphanumeric", }), - columnHelper.accessor("members", { - cell: (info) => - info.getValue().every((member) => member.checkedIn) - ? "Checked In" - : "Not Checked In", - header: "Check-in Status", - sortingFn: "basic", - }), columnHelper.accessor("approved", { cell: ({ getValue, diff --git a/src/app/admin/teams/components/TeamsTablePage.tsx b/src/app/admin/teams/components/TeamsTablePage.tsx index 9c554cf3..5153b7a2 100644 --- a/src/app/admin/teams/components/TeamsTablePage.tsx +++ b/src/app/admin/teams/components/TeamsTablePage.tsx @@ -1,26 +1,20 @@ -import type { Schema } from "@/amplify/data/resource"; import client from "@/components/_Amplify/AmplifyBackendClient"; import TeamsTable from "./TeamsTable"; -type Members = Pick< - Schema["User"]["type"], - "id" | "firstName" | "lastName" | "checkedIn" ->; -export type Team = Pick & { - members: Members[]; -}; +const getTeams = client.models.Team.list({ + selectionSet: [ + "name", + "approved", + "id", + "devPostLink", + "members.id", + "members.firstName", + "members.lastName", + ], +}); +export type Team = Awaited["data"][number]; export default async function TeamsTablePage() { - const { data: teams } = await client.models.Team.list({ - selectionSet: [ - "name", - "approved", - "id", - "members.id", - "members.firstName", - "members.lastName", - "members.checkedIn", - ], - }); + const { data: teams } = await getTeams; if (!teams || !Array.isArray(teams)) return "No teams were found"; return ; } diff --git a/src/app/admin/teams/components/ViewButton.tsx b/src/app/admin/teams/components/ViewButton.tsx index c886583a..d8ae999b 100644 --- a/src/app/admin/teams/components/ViewButton.tsx +++ b/src/app/admin/teams/components/ViewButton.tsx @@ -31,9 +31,7 @@ export default function ViewButton({ team }: { team: Team }) { {`${member.firstName} ${member.lastName}`} - - {member.checkedIn ? "Checked In" : "Not Checked In"} - + {member.id} ))} diff --git a/src/app/participant/check-in/page.tsx b/src/app/participant/check-in/page.tsx deleted file mode 100644 index b5d9a139..00000000 --- a/src/app/participant/check-in/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { generateClient } from "aws-amplify/api"; -import Image from "next/image"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { type Schema } from "@/amplify/data/resource"; -import { useUser } from "@/components/contexts/UserContext"; - -const check_mark_icon = "/svgs/checkin/check_mark.svg"; -const cross_icon = "/svgs/checkin/circle_cross.svg"; - -const CHECKIN_STATUS_TILE_STLYES = - "my-20 flex w-4/5 max-w-[1000px] flex-col items-center rounded-xl border-2 border-dark-pink bg-white p-10 shadow-[15px_15px_0px_0px_dark-pink]"; -const CHECKIN_STATUS_HEADER_STYLES = "mb-2 text-2xl font-bold text-dark-pink"; -const CHECKIN_STATUS_TEXT_STYLES = - "mb-6 max-w-[450px] text-center text-lg text-black"; -const CHECKIN_STATUS_BUTTON_STYLES = - "rounded-xl bg-dark-pink p-4 font-bold hover:bg-pastel-pink"; - -enum CheckInStatus { - Loading = "loading", - Error = "error", - Success = "success", -} - -const CheckInPage = () => { - const client = generateClient(); - const [status, setStatus] = useState(CheckInStatus.Loading); - const { currentUser } = useUser(); - - const handleCheckIn = async () => { - try { - const result = await client.mutations.SetUserAsCheckedIn({ - userId: currentUser.username, - }); - - console.log("User checked in:", result); - setStatus(CheckInStatus.Success); - } catch (error) { - console.error("Error checking in:", error); - setStatus(CheckInStatus.Error); - } - }; - - useEffect(() => { - const checkIn = async () => { - await handleCheckIn(); - }; - if (currentUser.username) { - checkIn(); - } - }, [currentUser.username]); - - return ( -
- {status === CheckInStatus.Loading && ( -

- Checking you in... -

- )} - {status === CheckInStatus.Error && ( -
- X icon -

Failed to Check-in

-

- We apologize for the inconvenience. Please try checking in again. -

- - Go To Home - -
- )} - {status === CheckInStatus.Success && ( -
- Check mark icon -

- You're Checked In! -

-

- Thanks for checking in to Hack the Change 2025! Click the button - below to return to your profile. -

- - Go To Profile - -
- )} -
- ); -}; - -export default CheckInPage; diff --git a/src/app/participant/page.tsx b/src/app/participant/page.tsx index 02998236..0c64d1d8 100644 --- a/src/app/participant/page.tsx +++ b/src/app/participant/page.tsx @@ -28,7 +28,6 @@ export default function page() {
- {/* */}
diff --git a/src/app/register/team/(pending)/new/page.tsx b/src/app/register/team/(pending)/new/page.tsx index ab68277b..d7fc0b64 100644 --- a/src/app/register/team/(pending)/new/page.tsx +++ b/src/app/register/team/(pending)/new/page.tsx @@ -8,49 +8,52 @@ import { client } from "@/app/QueryProvider"; import { useUser } from "@/components/contexts/UserContext"; import PurpleButton from "@/components/PurpleButton"; import { Underline } from "@/utils/text-utils"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; export default function page() { + const queryClient = useQueryClient(); const [teamName, setTeamName] = useState(""); const router = useRouter(); - const user = useUser(); - const toastRef = useRef(""); + const { + currentUser: { teamId, id }, + revalidateUser, + } = useUser(); const teamMutation = useMutation({ - mutationFn: async (input: string) => { - if (user.currentUser.teamId) { - throw new Error("User already has a team"); - } + mutationKey: ["CreateTeam"], + mutationFn: async (teamName: string) => { + if (teamId) throw new Error("User already has a team"); + const toastObj = toast.loading("Creating team..."); const res = await client.mutations.CreateTeamWithCode({ - teamName: input, - addCallerToTeam: true, + teamName, + addCallerToTeam: false, // broken in lambda function. }); - if (res.errors) throw new Error(res.errors[0].message); - return res.data; + toast.dismiss(toastObj); + if (!res.errors && res.data?.body) { + const teamId = JSON.parse(res.data.body!.toString()).value; + await client.models.User.update({ + id, + teamId, + }); + revalidateUser(); + return res.data; + } + throw new Error(res.errors?.[0]?.message); }, onSuccess: (data) => { - if (data?.statusCode === 200) { - toast.update(toastRef.current, { - render: "Team created successfully", + if (data.statusCode === 200) { + toast.success("Team created successfully", { type: "success", isLoading: false, autoClose: 3000, }); - const teamID = JSON.parse(data.body?.toString() || "").value; + queryClient.invalidateQueries({ queryKey: ["User"] }); + const teamID = JSON.parse(data.body!.toString()).value; router.push(`/register/team/${teamID}`); } }, - onError: (error) => { - toast.update(toastRef.current, { - render: error.message, - type: "error", - isLoading: false, - autoClose: 3000, - }); - }, }); async function handleFormSubmit(e: React.FormEvent) { e.preventDefault(); - toastRef.current = toast.loading("Creating team..."); teamMutation.mutate(teamName); } return ( @@ -77,8 +80,11 @@ export default function page() { />
- - Back + + Back {teamMutation.isPending ? "Registering..." : "Register"} diff --git a/src/components/Dashboard/DevPostLinkUpload.tsx b/src/components/Dashboard/DevPostLinkUpload.tsx new file mode 100644 index 00000000..1e31f6d0 --- /dev/null +++ b/src/components/Dashboard/DevPostLinkUpload.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { FaCircleArrowRight } from "react-icons/fa6"; +import { toast } from "react-toastify"; +import { client } from "@/app/QueryProvider"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useUser } from "../contexts/UserContext"; + +export default function DevPostLinkUpload() { + const { + currentUser: { team, id: userId }, + } = useUser(); + const { data: userTeam } = useQuery({ + queryKey: ["Team", userId], + queryFn: async () => { + const userTeam = await team(); + if (!userTeam || userTeam.errors) + throw new Error( + userTeam?.errors?.[0]?.message ?? "Error fetching team data", + ); + return userTeam.data; + }, + }); + const [devPostLink, setDevPostLink] = useState(""); + const devPostMutation = useMutation({ + mutationKey: ["DevPostLink"], + mutationFn: async () => { + if (!devPostLink || !userTeam) return; + const { data, errors } = await client.models.Team.update({ + id: userTeam.id, + devPostLink, + }); + if (errors) { + toast.error(errors[0].message ?? "Error updating DevPost link"); + throw new Error(errors[0].message); + } + return data; + }, + onSuccess: () => { + toast.success("DevPost link updated successfully!"); + }, + onError: () => { + toast.error("Error updating DevPost link. Please try again."); + }, + }); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + devPostMutation.mutate(); + }; + return ( +
+ setDevPostLink(e.target.value)} + value={devPostLink} + name={devPostLink} + /> + + +
+ ); +} diff --git a/src/components/Dashboard/SubmissionDueClock.tsx b/src/components/Dashboard/SubmissionDueClock.tsx index a3ac7480..31f35ce6 100644 --- a/src/components/Dashboard/SubmissionDueClock.tsx +++ b/src/components/Dashboard/SubmissionDueClock.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { hackathonTimeRemaining } from "@/utils/date-utils"; import CountdownTimer from "../LandingPage/CountdownTimer"; import Card from "./Card"; +import DevPostLinkUpload from "./DevPostLinkUpload"; export default function SubmissionDueClock({ submissionTime, @@ -50,6 +51,7 @@ export default function SubmissionDueClock({ className="w-32 bg-dark-green md:w-52 lg:w-52" />
+ ); } diff --git a/src/components/PurpleButton.tsx b/src/components/PurpleButton.tsx index 5b3a3bd8..aa24a519 100644 --- a/src/components/PurpleButton.tsx +++ b/src/components/PurpleButton.tsx @@ -6,15 +6,18 @@ export default function PurpleButton({ type, onClick, className, + tabIndex, }: { children: React.ReactNode; disabled?: boolean; type?: HTMLButtonElement["type"]; onClick?: () => void; className?: string; + tabIndex?: number; }) { return ( - ) : ( -
-
- Right Squiggly SVG{" "} - Right Squiggly SVG{" "} - Right Squiggly SVG{" "} -
-
- {/* */} -
-

My Details

- -
- {data && ( - - )} -
-
- )} - + + + ); -}; -export default UserProfile; +} diff --git a/src/components/contexts/Provider.tsx b/src/components/contexts/Provider.tsx index d69d9763..fe5622c2 100644 --- a/src/components/contexts/Provider.tsx +++ b/src/components/contexts/Provider.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { toast } from "react-toastify"; import { Authenticator } from "@aws-amplify/ui-react"; import { @@ -15,39 +14,34 @@ export default function Provider({ }: { children: React.ReactNode | React.ReactNode[]; }) { - const [queryClient] = React.useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60 * 5, - refetchOnWindowFocus: true, - initialDataUpdatedAt: 0, - }, - }, - queryCache: new QueryCache({ - onError: (error) => { - console.error("Query Boundary Caught:", error); - // toast.error(`Error loading: ${query.queryKey[0]}`); - }, - // onSuccess(data, query) { - // toast.success(`${query.queryKey[0]} loaded`); - // }, - }), - mutationCache: new MutationCache({ - onError: (error, variables, context, mutation) => { - console.error("Mutation Boundary Caught:", error); - toast.error( - `Error processing: ${mutation.options?.mutationKey?.[0]}`, - ); - }, - // onSuccess(data, variables, context, mutation) { - // console.log(data, variables, context, mutation); - // toast.success(`${mutation.options?.mutationKey?.[0]} updated`); - // }, - }), - }), - ); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: true, + initialDataUpdatedAt: 0, + }, + }, + queryCache: new QueryCache({ + onError: (error, query) => { + console.error("Query Boundary Caught:", error); + toast.error(`Error loading: ${query.queryKey[0]}`); + }, + // onSuccess(data, query) { + // toast.success(`${query.queryKey[0]} loaded`); + // }, + }), + mutationCache: new MutationCache({ + onError: (error, _, __, mutation) => { + console.error("Mutation Boundary Caught:", error); + toast.error(`Error processing: ${mutation.options?.mutationKey?.[0]}`); + }, + // onSuccess(data, variables, context, mutation) { + // console.log(data, variables, context, mutation); + // toast.success(`${mutation.options?.mutationKey?.[0]} updated`); + // }, + }), + }); return ( diff --git a/src/components/contexts/UserContext.tsx b/src/components/contexts/UserContext.tsx index dbbc83a7..3d288eeb 100644 --- a/src/components/contexts/UserContext.tsx +++ b/src/components/contexts/UserContext.tsx @@ -18,7 +18,7 @@ export enum UserType { Guest = "Guest", } -type IUser = Schema["User"]["type"] & { +export type IUser = Schema["User"]["type"] & { type: UserType; username: string; }; diff --git a/src/components/judging/ScoresTable.tsx b/src/components/judging/ScoresTable.tsx index facde86a..f4408219 100644 --- a/src/components/judging/ScoresTable.tsx +++ b/src/components/judging/ScoresTable.tsx @@ -265,7 +265,7 @@ export default function JudgingTable(props: JudgingTableProps) { scoreObject[columnId] )} Edit