diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 2a8dc92f..266c6a86 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -152,6 +152,8 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.With(ensureEventStaff).Get("/users/{userId}", api.Handlers.Event.GetUserForEvent) // Get user ID by RFID r.With(ensureEventStaff).Get("/users/by-rfid/{rfid}", api.Handlers.Event.GetUserByRFID) + // Is the user checked in + r.With(ensureEventStaff).Get("/users/{userId}/checked-in-status", api.Handlers.Event.GetCheckedInStatusByIds) // Admin-only r.With(ensureEventAdmin).Post("/queue-confirmation-email", api.Handlers.Email.QueueConfirmationEmail) @@ -225,7 +227,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // Update and delete specific redeemable r.Route("/{redeemableId}", func(r chi.Router) { r.Patch("/", api.Handlers.Redeemables.UpdateRedeemable) - r.Delete("/", api.Handlers.Redeemables.DeleteRedeemable) + r.With(ensureEventAdmin).Delete("/", api.Handlers.Redeemables.DeleteRedeemable) r.Route("/users/{userId}", func(r chi.Router) { r.Post("/", api.Handlers.Redeemables.RedeemRedeemable) diff --git a/apps/api/internal/api/handlers/events.go b/apps/api/internal/api/handlers/events.go index c91d2ac9..6a4a52c2 100644 --- a/apps/api/internal/api/handlers/events.go +++ b/apps/api/internal/api/handlers/events.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "time" "github.com/go-chi/chi/v5" @@ -865,4 +866,40 @@ func (h *EventHandler) GetUserByRFID(w http.ResponseWriter, r *http.Request) { // Return just the user ID as a simple JSON object res.Send(w, http.StatusOK, map[string]string{"user_id": user.ID.String()}) -} \ No newline at end of file +} + +// Get User by RFID +// +// @Summary Retrieves a user's ID by their RFID +// @Description Looks up a user's ID by their RFID code for a specific event. Returns the user ID which can be used for other operations. +// @Tags Event +// @Produce json +// @Param eventId path string true "Event ID" Format(uuid) +// @Param rfid path string true "RFID code (10 digits)" +// @Success 200 {object} map[string]string "OK - Returns user ID" +// @Failure 400 {object} response.ErrorResponse "Bad request/Malformed request." +// @Failure 404 {object} response.ErrorResponse "User not found with the provided RFID" +// @Failure 500 {object} response.ErrorResponse "Server Error: error getting user by RFID" +// @Router /events/{eventId}/users/by-rfid/{rfid} [get] +func (h *EventHandler) GetCheckedInStatusByIds(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + userId, err := web.PathParamToUUID(r, "userId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) + return + } + result, err := h.eventService.GetCheckedInStatusByIds(r.Context(), userId, eventId) + if err != nil { + res.SendError(w, http.StatusNotFound, res.NewError("error", "Something went wrong internally.")) + return + } + // Return just the checked in status as a simple JSON object + res.Send(w, http.StatusOK, map[string]string{ + "checked_in_status": strconv.FormatBool(result), + }) +} diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql index a68e0594..a57650a8 100644 --- a/apps/api/internal/db/queries/event_roles.sql +++ b/apps/api/internal/db/queries/event_roles.sql @@ -71,3 +71,12 @@ FROM auth.users u JOIN event_roles er ON u.id = er.user_id WHERE er.event_id = $1 AND er.rfid = $2; + +-- name: GetCheckedInStatusByIds :one +SELECT EXISTS ( + SELECT 1 + FROM event_roles + WHERE user_id = $1 + AND event_id = $2 + AND checked_in_at IS NOT NULL +)::bool; diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go index 86ed536e..30e94dd4 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -16,6 +16,7 @@ var ( ErrDuplicateEvent = errors.New("event already exists in database") ErrNoEventsDeleted = errors.New("no events deleted") ErrMultipleEventsDeleted = errors.New("multiple events affected by delete query while only expecting one to delete one") + ErrUserEventNotFound = errors.New("the user and event id combination was not found") ErrUnknown = errors.New("an unkown error was caught") ) @@ -206,6 +207,20 @@ func (r *EventRepository) GetEventRoleByDiscordIDAndEventId(ctx context.Context, return &eventRole, nil } +func (r *EventRepository) GetCheckedInStatusByUserIdAndEventId(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) (bool, error) { + params := sqlc.GetCheckedInStatusByIdsParams{ + UserID: userId, + EventID: eventId, + } + + result, err := r.db.Query.GetCheckedInStatusByIds(ctx, params) + + if err != nil { + return false, err + } + + return result, nil + func (r *EventRepository) GetAttendeeUserIdsByEventId(ctx context.Context, eventID uuid.UUID) ([]uuid.UUID, error) { return r.db.Query.GetAttendeeUserIdsByEventId(ctx, eventID) } diff --git a/apps/api/internal/db/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go index 32c99a23..5cdea072 100644 --- a/apps/api/internal/db/sqlc/event_roles.sql.go +++ b/apps/api/internal/db/sqlc/event_roles.sql.go @@ -68,6 +68,28 @@ func (q *Queries) GetAttendeeUserIdsByEventId(ctx context.Context, eventID uuid. return items, nil } +const getCheckedInStatusByIds = `-- name: GetCheckedInStatusByIds :one +SELECT EXISTS ( + SELECT 1 + FROM event_roles + WHERE user_id = $1 + AND event_id = $2 + AND checked_in_at IS NOT NULL +)::bool +` + +type GetCheckedInStatusByIdsParams struct { + UserID uuid.UUID `json:"user_id"` + EventID uuid.UUID `json:"event_id"` +} + +func (q *Queries) GetCheckedInStatusByIds(ctx context.Context, arg GetCheckedInStatusByIdsParams) (bool, error) { + row := q.db.QueryRow(ctx, getCheckedInStatusByIds, arg.UserID, arg.EventID) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + const getEventAttendeesWithDiscord = `-- name: GetEventAttendeesWithDiscord :many SELECT a.account_id as discord_id, diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index 67c893ea..c20c5918 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -422,3 +422,13 @@ func (s *EventService) GetUserInfoForEvent(ctx context.Context, userId, eventId return result, nil } + +func (s *EventService) GetCheckedInStatusByIds(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) (bool, error) { + result, err := s.eventRepo.GetCheckedInStatusByUserIdAndEventId(ctx, userId, eventId) + if err != nil { + s.logger.Err(err).Msg("Failed to get user by ids") + return false, err + } + return result, nil + +} diff --git a/apps/api/internal/services/redeemables.go b/apps/api/internal/services/redeemables.go index bf02076e..c99a797f 100644 --- a/apps/api/internal/services/redeemables.go +++ b/apps/api/internal/services/redeemables.go @@ -60,6 +60,10 @@ func (s *RedeemablesService) UpdateRedeemable(ctx context.Context, redeemableID } func (s *RedeemablesService) RedeemRedeemable(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) error { + // Need to do a check to see if the user is checked in + // Probably need event service + + // CREATE NEW SQL function for getting checked in status _, err := s.redeemablesRepo.RedeemRedeemable(ctx, redeemableID, userID) if err != nil { diff --git a/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx b/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx index 818d2617..69d5b4d3 100644 --- a/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx +++ b/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { EventAttendanceWithdrawalModal } from "@/features/Event/components/EventAttendanceWithdrawalModal"; import { generateIdentifyIntent } from "@/lib/qr-intents/generate"; -import { DialogTrigger, Heading } from "react-aria-components"; +import { DialogTrigger, Heading, Link } from "react-aria-components"; import QRCode from "react-qr-code"; interface Props { @@ -12,35 +12,164 @@ interface Props { export default function AttendeeOverview({ userId, eventId }: Props) { const identificationIntentString = generateIdentifyIntent(userId); + const hackerGuideUrl = "https://swamphack.notion.site/sh-xi-hacker-guide"; - // Used to get the right colors for QR Code - const styles = getComputedStyle(document.documentElement); - const bg = styles.getPropertyValue("--surface").trim(); - const fg = styles.getPropertyValue("--text-main").trim(); + const allowWithdrawal = false; return ( -
- +
+ Overview - - - - -
-

Can't make it to the event?

- - - - +
+ {/* Left Column: QR ID Card */} +
+ +
+
+
+ + Attendee Pass + +
+ + {/* HIGH CONTRAST QR WRAPPER */} + {/* We force bg-white and text-black here regardless of theme for scan reliability */} +
+ +
+ +
+

+ Personal QR Code +

+

+ ID: {userId} +

+
+
+
+
+
+ + {/* Right Column: Info & Links */} +
+ {/* QR Explanation */} +
+

+ Your Identifier +

+
+

+ This QR code is your unique digital badge. It identifies you to + our staff and is used for check-ins, swag pickups, and meal + redemptions! +

+
+
+ +
+ + {/* Unified Style Resource Links */} +
+ +
+ {/* Consistent Icon Style */} +
+ + + + +
+ +
+

+ Official Hacker Guide + + + + + +

+

+ Have questions? Look here! +

+
+
+ +
+ Open Guide + + + +
+ +
+ + {/* Management / Withdrawal */} + {allowWithdrawal && ( +
+
+
+

+ Plans changed? +

+

+ Withdrawal is permanent for this event. +

+
+ + + + +
+
+ )} +
); diff --git a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx index f53450aa..c44d81db 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx @@ -9,6 +9,7 @@ export interface RedeemableCardProps { maxUserAmount: number; totalRedeemed: number; eventId: string; + eventRole: string | undefined; } export function RedeemableCard({ @@ -18,6 +19,7 @@ export function RedeemableCard({ maxUserAmount, totalRedeemed, eventId, + eventRole, }: RedeemableCardProps) { const remaining = totalStock - (totalRedeemed as number); const percentageRemaining = @@ -63,6 +65,7 @@ export function RedeemableCard({ maxUserAmount={maxUserAmount} totalRedeemed={totalRedeemed as number} eventId={eventId} + eventRole={eventRole} /> diff --git a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx index 5986c132..cda1b839 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx @@ -11,6 +11,7 @@ import { useRedeemRedeemable, getUserByRFID, useUpdateRedeemable, + useGetCheckedInStatus, } from "../hooks/useRedeemables"; import { DeleteRedeemableModal } from "./DeleteRedeemableModal"; import { Scanner, type IDetectedBarcode } from "@yudiel/react-qr-scanner"; @@ -26,6 +27,7 @@ export interface RedeemableDetailsModalProps { maxUserAmount: number; totalRedeemed: number; eventId: string; + eventRole: string | undefined; } type ModalMode = "details" | "qr" | "edit"; @@ -37,6 +39,7 @@ export function RedeemableDetailsModal({ maxUserAmount, totalRedeemed, eventId, + eventRole, }: RedeemableDetailsModalProps) { const state = useContext(OverlayTriggerStateContext)!; const remaining = totalStock - totalRedeemed; @@ -51,6 +54,8 @@ export function RedeemableDetailsModal({ eventId, id, ); + const { mutateAsync: getCheckedInStatus } = useGetCheckedInStatus(eventId); + const [formData, setFormData] = useState({ name: name, totalStock: totalStock, @@ -120,6 +125,16 @@ export function RedeemableDetailsModal({ setIsScanning(true); try { + // check if they are checked in + const checkedInStatus = await getCheckedInStatus(res.value.user_id); + if (checkedInStatus.checked_in_status != "true") { + showToast({ + title: "Error", + message: "Failed to Redeem with QR. User is not checked in", + type: "error", + }); + return; + } await redeemRedeemable(res.value.user_id); showToast({ @@ -128,7 +143,6 @@ export function RedeemableDetailsModal({ type: "success", }); - // 4. Unlock after your desired delay setTimeout(() => { setIsScanning(false); }, 5000); @@ -335,12 +349,14 @@ export function RedeemableDetailsModal({ > Back to Details - - - - + {eventRole === "admin" && ( + + + + + )}
diff --git a/apps/web/src/features/Redeemables/hooks/useRedeemables.ts b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts index bc2ea870..91fdce41 100644 --- a/apps/web/src/features/Redeemables/hooks/useRedeemables.ts +++ b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts @@ -95,3 +95,18 @@ export function useUpdateRedeemable(eventId: string, redeemableId: string) { }, }); } + +interface CheckInStatusResponse { + checked_in_status: string; +} + +export function useGetCheckedInStatus(eventId: string) { + return useMutation({ + mutationFn: async (userId: string) => { + const response = await api + .get(`events/${eventId}/users/${userId}/checked-in-status`) + .json(); + return response; + }, + }); +} diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx index a31a6b61..11efa382 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Heading } from "react-aria-components"; +import { Heading, Link } from "react-aria-components"; export const Route = createFileRoute( "/_protected/events/$eventId/dashboard/_attendee/schedule", @@ -8,13 +8,89 @@ export const Route = createFileRoute( }); function RouteComponent() { + const notionUrl = + "https://swamphack.notion.site/29a3b41de22f8061a741c84448a3f5ce?pvs=25#2ee3b41de22f80cbb2cee020b7ad4be3"; + return ( -
- +
+ Event Schedule -

To be announced...

+
+ +
+ {/* Themed Icon Container */} +
+ + + + + + +
+ +
+
+

+ View Full Schedule +

+ + + + + +
+

+ Find the workshop timings, meal breaks, and hacker activities on + our live Notion page. +

+
+
+ + {/* Themed Action Badge */} +
+ Open Notion + + + +
+ +
); } diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx index eeb679bd..1070852e 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx @@ -13,6 +13,7 @@ export const Route = createFileRoute( function RouteComponent() { const { eventId } = Route.useParams(); + const { eventRole } = Route.useRouteContext(); const { data: redeemables, isLoading, isError } = useRedeemables(eventId); if (isLoading) { @@ -69,6 +70,7 @@ function RouteComponent() { : redeemable.total_redeemed } eventId={eventId} + eventRole={eventRole} /> ))}