From 6a42d8832c8998b53f11574a9cea68feeccbf634 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Fri, 19 Sep 2025 18:07:54 -0400 Subject: [PATCH 1/6] feat: new staff management page outline --- .../EventAdmin/components/RoleBadge.tsx | 36 ++++ .../EventAdmin/components/StaffTable.tsx | 121 +++++++++++ .../EventManager/components/AddEventModal.tsx | 0 .../EventManager/components/AddStaffForm.tsx | 0 .../components/DeleteEventDialog.tsx | 0 .../components/EventDetailsCard.tsx | 0 .../components/ManageEventStaffDialog.tsx | 0 .../EventManager/components/StaffTable.tsx | 0 .../hooks/useAdminEventActions.ts | 0 .../EventManager/hooks/useAdminEvents.ts | 0 .../hooks/useAdminStaffActions.ts | 0 .../EventManager/hooks/useCreateAdminEvent.ts | 0 .../EventManager/hooks/useEventStaffUsers.ts | 2 +- .../_protected/admin/events-management.tsx | 197 +----------------- .../dashboard/_admin/staff-management.tsx | 44 +++- 15 files changed, 204 insertions(+), 196 deletions(-) create mode 100644 apps/web/src/features/EventAdmin/components/RoleBadge.tsx create mode 100644 apps/web/src/features/EventAdmin/components/StaffTable.tsx rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/components/AddEventModal.tsx (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/components/AddStaffForm.tsx (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/components/DeleteEventDialog.tsx (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/components/EventDetailsCard.tsx (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/components/ManageEventStaffDialog.tsx (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/components/StaffTable.tsx (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/hooks/useAdminEventActions.ts (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/hooks/useAdminEvents.ts (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/hooks/useAdminStaffActions.ts (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/hooks/useCreateAdminEvent.ts (100%) rename apps/web/src/features/{Admin => PlatformAdmin}/EventManager/hooks/useEventStaffUsers.ts (96%) diff --git a/apps/web/src/features/EventAdmin/components/RoleBadge.tsx b/apps/web/src/features/EventAdmin/components/RoleBadge.tsx new file mode 100644 index 00000000..daaf34b5 --- /dev/null +++ b/apps/web/src/features/EventAdmin/components/RoleBadge.tsx @@ -0,0 +1,36 @@ +import { Badge } from "@/components/ui/Badge"; + +interface Props { + role: "admin" | "staff" | "attendee" | "applicant"; +} + +const RoleBadge = ({ role }: Props) => { + switch (role) { + case "admin": + return ( + + Admin + + ); + case "staff": + return ( + + Staff + + ); + case "attendee": + return ( + + Attendee + + ); + case "applicant": + return ( + + Applicant + + ); + } +}; + +export default RoleBadge; diff --git a/apps/web/src/features/EventAdmin/components/StaffTable.tsx b/apps/web/src/features/EventAdmin/components/StaffTable.tsx new file mode 100644 index 00000000..753cfdba --- /dev/null +++ b/apps/web/src/features/EventAdmin/components/StaffTable.tsx @@ -0,0 +1,121 @@ +import { Button } from "@/components/ui/Button"; +import TablerTrash from "~icons/tabler/trash"; + +import { + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table"; +import RoleBadge from "./RoleBadge"; +import type { StaffUser } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; + +const columns: ColumnDef[] = [ + { + id: "avatar", + header: "Avatar", + cell: ({ row }) => { + const avatarUrl = row.original.image; + + //TODO: Replace with actual avatar component + return avatarUrl ? ( + Avatar + ) : ( +
+ N/A +
+ ); + }, + }, + { + id: "name", + header: "Name", + cell: ({ row }) => { + const name = row.original.name; + return name ? name : "Unknown"; + }, + }, + { + accessorKey: "email", + header: "Email", + }, + { + accessorKey: "event_role", + header: "Role", + cell: ({ row }) => { + const role = row.original.event_role; + return ; + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const eventRole = row.original.event_role; + + return ( + + ); + }, + }, +]; + +const fallbackData: StaffUser[] = []; + +interface Props { + data?: StaffUser[]; +} + +const StaffTable = ({ data }: Props) => { + const table = useReactTable({ + columns, + data: data ?? fallbackData, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row, i) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); +}; + +export default StaffTable; diff --git a/apps/web/src/features/Admin/EventManager/components/AddEventModal.tsx b/apps/web/src/features/PlatformAdmin/EventManager/components/AddEventModal.tsx similarity index 100% rename from apps/web/src/features/Admin/EventManager/components/AddEventModal.tsx rename to apps/web/src/features/PlatformAdmin/EventManager/components/AddEventModal.tsx diff --git a/apps/web/src/features/Admin/EventManager/components/AddStaffForm.tsx b/apps/web/src/features/PlatformAdmin/EventManager/components/AddStaffForm.tsx similarity index 100% rename from apps/web/src/features/Admin/EventManager/components/AddStaffForm.tsx rename to apps/web/src/features/PlatformAdmin/EventManager/components/AddStaffForm.tsx diff --git a/apps/web/src/features/Admin/EventManager/components/DeleteEventDialog.tsx b/apps/web/src/features/PlatformAdmin/EventManager/components/DeleteEventDialog.tsx similarity index 100% rename from apps/web/src/features/Admin/EventManager/components/DeleteEventDialog.tsx rename to apps/web/src/features/PlatformAdmin/EventManager/components/DeleteEventDialog.tsx diff --git a/apps/web/src/features/Admin/EventManager/components/EventDetailsCard.tsx b/apps/web/src/features/PlatformAdmin/EventManager/components/EventDetailsCard.tsx similarity index 100% rename from apps/web/src/features/Admin/EventManager/components/EventDetailsCard.tsx rename to apps/web/src/features/PlatformAdmin/EventManager/components/EventDetailsCard.tsx diff --git a/apps/web/src/features/Admin/EventManager/components/ManageEventStaffDialog.tsx b/apps/web/src/features/PlatformAdmin/EventManager/components/ManageEventStaffDialog.tsx similarity index 100% rename from apps/web/src/features/Admin/EventManager/components/ManageEventStaffDialog.tsx rename to apps/web/src/features/PlatformAdmin/EventManager/components/ManageEventStaffDialog.tsx diff --git a/apps/web/src/features/Admin/EventManager/components/StaffTable.tsx b/apps/web/src/features/PlatformAdmin/EventManager/components/StaffTable.tsx similarity index 100% rename from apps/web/src/features/Admin/EventManager/components/StaffTable.tsx rename to apps/web/src/features/PlatformAdmin/EventManager/components/StaffTable.tsx diff --git a/apps/web/src/features/Admin/EventManager/hooks/useAdminEventActions.ts b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useAdminEventActions.ts similarity index 100% rename from apps/web/src/features/Admin/EventManager/hooks/useAdminEventActions.ts rename to apps/web/src/features/PlatformAdmin/EventManager/hooks/useAdminEventActions.ts diff --git a/apps/web/src/features/Admin/EventManager/hooks/useAdminEvents.ts b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useAdminEvents.ts similarity index 100% rename from apps/web/src/features/Admin/EventManager/hooks/useAdminEvents.ts rename to apps/web/src/features/PlatformAdmin/EventManager/hooks/useAdminEvents.ts diff --git a/apps/web/src/features/Admin/EventManager/hooks/useAdminStaffActions.ts b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useAdminStaffActions.ts similarity index 100% rename from apps/web/src/features/Admin/EventManager/hooks/useAdminStaffActions.ts rename to apps/web/src/features/PlatformAdmin/EventManager/hooks/useAdminStaffActions.ts diff --git a/apps/web/src/features/Admin/EventManager/hooks/useCreateAdminEvent.ts b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useCreateAdminEvent.ts similarity index 100% rename from apps/web/src/features/Admin/EventManager/hooks/useCreateAdminEvent.ts rename to apps/web/src/features/PlatformAdmin/EventManager/hooks/useCreateAdminEvent.ts diff --git a/apps/web/src/features/Admin/EventManager/hooks/useEventStaffUsers.ts b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts similarity index 96% rename from apps/web/src/features/Admin/EventManager/hooks/useEventStaffUsers.ts rename to apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts index 3c8b2a95..511ff2fb 100644 --- a/apps/web/src/features/Admin/EventManager/hooks/useEventStaffUsers.ts +++ b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts @@ -6,7 +6,7 @@ export function getEventStaffUsersQueryKey(eventId: string) { return ["event", eventId, "staff-users"] as const; } -type StaffUsers = +export type StaffUsers = operations["get-event-staff"]["responses"]["200"]["content"]["application/json"]; export function useEventStaffUsers(eventId: string) { diff --git a/apps/web/src/routes/_protected/admin/events-management.tsx b/apps/web/src/routes/_protected/admin/events-management.tsx index a1442efd..b9b0e632 100644 --- a/apps/web/src/routes/_protected/admin/events-management.tsx +++ b/apps/web/src/routes/_protected/admin/events-management.tsx @@ -2,9 +2,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { Heading, DialogTrigger, Text } from "react-aria-components"; import { Button } from "@/components/ui/Button"; -import { AddEventModal } from "@/features/Admin/EventManager/components/AddEventModal"; -import { useAdminEvents } from "@/features/Admin/EventManager/hooks/useAdminEvents"; -import { EventDetailsCard } from "@/features/Admin/EventManager/components/EventDetailsCard"; +import { AddEventModal } from "@/features/PlatformAdmin/EventManager/components/AddEventModal"; +import { useAdminEvents } from "@/features/PlatformAdmin/EventManager/hooks/useAdminEvents"; +import { EventDetailsCard } from "@/features/PlatformAdmin/EventManager/components/EventDetailsCard"; export const Route = createFileRoute("/_protected/admin/events-management")({ component: RouteComponent, @@ -69,195 +69,4 @@ function RouteComponent() { ); - - // return ( - //
- //
- //
- //

Events Management

- // - //
- - // {/* List of Event Cards */} - //
- // {events.length > 0 ? ( - // events.map((event) => ( - // handleOpenEditModal(event)} - // className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded-lg" - // > - // - //

{event.name}

- //

- // Starts: {new Date(event.startTime).toLocaleString()} - //

- //
- //
- // )) - // ) : ( - // - //

No events found. Click "Add Event" to create one.

- //
- // )} - //
- //
- - // {/* Modal for adding a new event (no changes) */} - // - // - // - // {({ close }) => ( - // - // - // Create New Event - // - //
- // {/* Form inputs remain the same */} - //
- // - // - //
- //
- // - // - //
- //
- // - // - //
- //
- // - // - //
- //
- // - // - //
- //
- // - // - //
- //
- //
- // )} - //
- //
- //
- - // {/* General Modal for Editing an Event */} - // - // - // - // {({ close }) => ( - // - // - // Edit Event - // - //

- // {selectedEvent?.name} - //

- - //
- // {/* Example Action Button */} - // - // {/* Other action buttons would go here */} - //
- - //
- // {/* Delete button is now a secondary action */} - // - // - //
- //
- // )} - //
- //
- //
- //
- // ); } diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx index 1a562b86..7d8ddd41 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx @@ -1,4 +1,8 @@ +import { Button } from "@/components/ui/Button"; +import StaffTable from "@/features/EventAdmin/components/StaffTable"; +import { useEventStaffUsers } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; import { createFileRoute } from "@tanstack/react-router"; +import { Heading } from "react-aria-components"; export const Route = createFileRoute( "/_protected/events/$eventId/dashboard/_admin/staff-management", @@ -7,5 +11,43 @@ export const Route = createFileRoute( }); function RouteComponent() { - return
Hello "/events/$eventId/dashboard/_admin/staff-management"!
; + const eventId = Route.useParams().eventId; + const { data, isLoading, isError } = useEventStaffUsers(eventId); + const { eventRole } = Route.useRouteContext(); + + if (isLoading) { + return ( +
+ + Staff Management + + +
Loading...
+
+ ); + } + + if (isError) { +
+ + Staff Management + + +
Error loading staff users.
+
; + } + + return ( +
+ + Staff Management + + +
+ {eventRole === "admin" && } +
+ + +
+ ); } From 269c13298203e3b75b3edc26dfcdb612f8ad1557 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Fri, 19 Sep 2025 18:09:46 -0400 Subject: [PATCH 2/6] fix: add staff user type --- .../PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts index 511ff2fb..de387e08 100644 --- a/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts +++ b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts @@ -8,6 +8,7 @@ export function getEventStaffUsersQueryKey(eventId: string) { export type StaffUsers = operations["get-event-staff"]["responses"]["200"]["content"]["application/json"]; +export type StaffUser = StaffUsers[number]; export function useEventStaffUsers(eventId: string) { async function fetchEventStaffUsers(): Promise { From 25befaf8b31ae227a58535941bd7074f9bbf30b1 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Tue, 23 Sep 2025 03:37:47 -0400 Subject: [PATCH 3/6] fix: weird golang sqlc types --- apps/api/internal/api/api.go | 1 + apps/api/internal/api/handlers/user.go | 31 ++++++++++++ apps/api/internal/db/queries/users.sql | 8 ++++ apps/api/internal/db/repository/users.go | 10 ++++ apps/api/internal/db/sqlc/querier.go | 1 + apps/api/internal/db/sqlc/users.sql.go | 47 ++++++++++++++++++ apps/api/internal/ptr/int32.go | 5 ++ apps/api/internal/services/user.go | 4 ++ apps/api/internal/web/query.go | 25 ++++++++++ apps/api/sqlc.yml | 4 +- apps/web/src/components/ui/Modal/Modal.tsx | 2 + .../EventAdmin/components/AddStaffModal.tsx | 48 +++++++++++++++++++ .../EventAdmin/components/StaffTable.tsx | 30 ++++++++++-- apps/web/src/features/Users/hooks/useUsers.ts | 28 +++++++++++ .../dashboard/_admin/staff-management.tsx | 11 ++++- 15 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 apps/api/internal/ptr/int32.go create mode 100644 apps/web/src/features/EventAdmin/components/AddStaffModal.tsx create mode 100644 apps/web/src/features/Users/hooks/useUsers.ts diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 45d3a27d..485dd7b1 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -79,6 +79,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // --- User routes --- api.Router.Route("/users", func(r chi.Router) { r.Use(mw.Auth.RequireAuth) + r.Get("/", api.Handlers.User.GetUsers) r.Get("/me", api.Handlers.User.GetProfile) r.Patch("/me", api.Handlers.User.UpdateUser) r.Patch("/me/email-consent", api.Handlers.User.UpdateEmailConsent) diff --git a/apps/api/internal/api/handlers/user.go b/apps/api/internal/api/handlers/user.go index 014fdb71..9ef8aaa7 100644 --- a/apps/api/internal/api/handlers/user.go +++ b/apps/api/internal/api/handlers/user.go @@ -9,7 +9,9 @@ import ( "github.com/swamphacks/core/apps/api/internal/ctxutils" "github.com/swamphacks/core/apps/api/internal/db/sqlc" "github.com/swamphacks/core/apps/api/internal/email" + "github.com/swamphacks/core/apps/api/internal/ptr" "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/web" ) type UserHandler struct { @@ -184,3 +186,32 @@ func (h *UserHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } + +func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { + queryParams := r.URL.Query() + + // Parse search from query params, and limit, and offset + searchTerm := web.ParseParamString(queryParams, "search", nil) + limit, err := web.ParseParamInt32(queryParams, "limit", ptr.Int32ToPtr(50)) + if err != nil { + h.logger.Err(err).Msg("Limit field was misconfigured. Please check your query parameters.") + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_query", "Your 'limit' query parameter was malformed.")) + return + } + offset, err := web.ParseParamInt32(queryParams, "offset", ptr.Int32ToPtr(0)) + if err != nil { + + h.logger.Err(err).Msg("Offset field was misconfigured. Please check your query parameters.") + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_query", "Your 'offset' query parameter was malformed.")) + return + } + + users, err := h.userService.GetAllUsers(r.Context(), searchTerm, *limit, *offset) + if err != nil { + h.logger.Err(err).Msg("Failed to retrieve all users") + res.SendError(w, http.StatusInternalServerError, res.NewError("update_failed", "Failed to complete onboarding")) + return + } + + res.Send(w, http.StatusOK, users) +} diff --git a/apps/api/internal/db/queries/users.sql b/apps/api/internal/db/queries/users.sql index 589fa33a..f4c0e43a 100644 --- a/apps/api/internal/db/queries/users.sql +++ b/apps/api/internal/db/queries/users.sql @@ -33,3 +33,11 @@ WHERE -- name: DeleteUser :exec DELETE FROM auth.users WHERE id = $1; + +-- name: GetUsers :many +SELECT * +FROM auth.users +WHERE LOWER(name) LIKE LOWER('%' || COALESCE(sqlc.arg('search'), '') || '%') + OR LOWER(email) LIKE LOWER('%' || COALESCE(sqlc.arg('search'), '') || '%') +ORDER BY name +LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); diff --git a/apps/api/internal/db/repository/users.go b/apps/api/internal/db/repository/users.go index a6bad7d2..5bb1ffca 100644 --- a/apps/api/internal/db/repository/users.go +++ b/apps/api/internal/db/repository/users.go @@ -74,3 +74,13 @@ func (r *UserRepository) UpdateUser(ctx context.Context, params sqlc.UpdateUserP } return err } + +func (r *UserRepository) GetAllUsers(ctx context.Context, search *string, limit, offset int32) ([]sqlc.AuthUser, error) { + params := sqlc.GetUsersParams{ + Search: search, + Limit: limit, + Offset: offset, + } + + return r.db.Query.GetUsers(ctx, params) +} diff --git a/apps/api/internal/db/sqlc/querier.go b/apps/api/internal/db/sqlc/querier.go index e7806f69..6f51c6b0 100644 --- a/apps/api/internal/db/sqlc/querier.go +++ b/apps/api/internal/db/sqlc/querier.go @@ -41,6 +41,7 @@ type Querier interface { GetSessionsByUserID(ctx context.Context, userID uuid.UUID) ([]AuthSession, error) GetUserByEmail(ctx context.Context, email *string) (AuthUser, error) GetUserByID(ctx context.Context, id uuid.UUID) (AuthUser, error) + GetUsers(ctx context.Context, arg GetUsersParams) ([]AuthUser, error) InvalidateSessionByID(ctx context.Context, id uuid.UUID) error TouchSession(ctx context.Context, arg TouchSessionParams) error UpdateApplication(ctx context.Context, arg UpdateApplicationParams) error diff --git a/apps/api/internal/db/sqlc/users.sql.go b/apps/api/internal/db/sqlc/users.sql.go index d4d69c9a..bead77ac 100644 --- a/apps/api/internal/db/sqlc/users.sql.go +++ b/apps/api/internal/db/sqlc/users.sql.go @@ -100,6 +100,53 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (AuthUser, erro return i, err } +const getUsers = `-- name: GetUsers :many +SELECT id, name, email, email_verified, onboarded, image, created_at, updated_at, role, preferred_email, email_consent +FROM auth.users +WHERE LOWER(name) LIKE LOWER('%' || COALESCE($1, '') || '%') + OR LOWER(email) LIKE LOWER('%' || COALESCE($1, '') || '%') +ORDER BY name +LIMIT $3 OFFSET $2 +` + +type GetUsersParams struct { + Search *string `json:"search"` + Offset int32 `json:"offset"` + Limit int32 `json:"limit"` +} + +func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]AuthUser, error) { + rows, err := q.db.Query(ctx, getUsers, arg.Search, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthUser{} + for rows.Next() { + var i AuthUser + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.EmailVerified, + &i.Onboarded, + &i.Image, + &i.CreatedAt, + &i.UpdatedAt, + &i.Role, + &i.PreferredEmail, + &i.EmailConsent, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateUser = `-- name: UpdateUser :exec UPDATE auth.users SET diff --git a/apps/api/internal/ptr/int32.go b/apps/api/internal/ptr/int32.go new file mode 100644 index 00000000..0ec31804 --- /dev/null +++ b/apps/api/internal/ptr/int32.go @@ -0,0 +1,5 @@ +package ptr + +func Int32ToPtr(v int32) *int32 { + return &v +} diff --git a/apps/api/internal/services/user.go b/apps/api/internal/services/user.go index 727d95bf..97de3ac3 100644 --- a/apps/api/internal/services/user.go +++ b/apps/api/internal/services/user.go @@ -75,3 +75,7 @@ func (s *UserService) CompleteOnboarding(ctx context.Context, userId uuid.UUID, return s.UpdateUser(ctx, userId, params) } + +func (s *UserService) GetAllUsers(ctx context.Context, search *string, limit, offset int32) ([]sqlc.AuthUser, error) { + return s.userRepo.GetAllUsers(ctx, search, limit, offset) +} diff --git a/apps/api/internal/web/query.go b/apps/api/internal/web/query.go index e7afd0cd..f5ec7fb4 100644 --- a/apps/api/internal/web/query.go +++ b/apps/api/internal/web/query.go @@ -34,3 +34,28 @@ func ParseParamBoolean(queryParams url.Values, key string, defaultVal *bool) (*b return &parsed, nil } + +func ParseParamInt32(queryParams url.Values, key string, defaultVal *int32) (*int32, error) { + v := queryParams.Get(key) + if v == "" { + return defaultVal, nil + } + + parsed, err := strconv.ParseInt(v, 10, 32) + if err != nil { + return nil, ErrMalformedField + } + + val := int32(parsed) + + return &val, nil +} + +func ParseParamString(queryParams url.Values, key string, defaultVal *string) *string { + v := queryParams.Get(key) + if v == "" { + return defaultVal + } + + return &v +} diff --git a/apps/api/sqlc.yml b/apps/api/sqlc.yml index b34bff57..f35ea367 100644 --- a/apps/api/sqlc.yml +++ b/apps/api/sqlc.yml @@ -9,7 +9,7 @@ sql: out: "internal/db/sqlc" emit_json_tags: true emit_prepared_queries: false - emit_interface: true + emit_interface: false emit_exact_table_names: false emit_pointers_for_null_types: true emit_empty_slices: true @@ -29,5 +29,3 @@ sql: - db_type: uuid nullable: true go_type: "*github.com/google/uuid.UUID" - - diff --git a/apps/web/src/components/ui/Modal/Modal.tsx b/apps/web/src/components/ui/Modal/Modal.tsx index a9c68aad..d1bbb316 100644 --- a/apps/web/src/components/ui/Modal/Modal.tsx +++ b/apps/web/src/components/ui/Modal/Modal.tsx @@ -15,6 +15,8 @@ const modal = tv({ md: "max-w-md", lg: "max-w-lg", xl: "max-w-xl", + "2xl": "max-w-2xl", + "3xl": "max-w-3xl", }, padding: { none: "p-0", diff --git a/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx b/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx new file mode 100644 index 00000000..9168ffff --- /dev/null +++ b/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx @@ -0,0 +1,48 @@ +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { TextField } from "@/components/ui/TextField"; +import { useUsers } from "@/features/Users/hooks/useUsers"; +import { useState } from "react"; + +const AddStaffModal = () => { + const [searchTerm, setSearchTerm] = useState(""); + const { data, isLoading, isError } = useUsers(searchTerm) + + // Tanstack Query can be used here to fetch users based on the search term + + return ( + +
+ setSearchTerm(e)} + /> + + {isLoading &&

Loading...

} + {isError &&

Error loading users.

} + {data && data.length === 0 &&

No users found.

} + {data && data.length > 0 && ( +
+ {data.map((user) => ( +
+
+

{user.name}

+

{user.email}

+
+ +
+ ))} +
+ )} + +

Searching for {searchTerm}

+
+
+ ); +}; + +export default AddStaffModal; diff --git a/apps/web/src/features/EventAdmin/components/StaffTable.tsx b/apps/web/src/features/EventAdmin/components/StaffTable.tsx index 753cfdba..42b3809e 100644 --- a/apps/web/src/features/EventAdmin/components/StaffTable.tsx +++ b/apps/web/src/features/EventAdmin/components/StaffTable.tsx @@ -4,11 +4,19 @@ import TablerTrash from "~icons/tabler/trash"; import { flexRender, getCoreRowModel, + getFilteredRowModel, useReactTable, type ColumnDef, + type FilterFn, } from "@tanstack/react-table"; import RoleBadge from "./RoleBadge"; import type { StaffUser } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; +import { TextField } from "@/components/ui/TextField"; + +const fuzzyTextFilterFn: FilterFn = (row, columnId, value) => { + const rowValue = row.getValue(columnId) as string; + return rowValue.toLowerCase().includes((value as string).toLowerCase()); +}; const columns: ColumnDef[] = [ { @@ -38,10 +46,13 @@ const columns: ColumnDef[] = [ const name = row.original.name; return name ? name : "Unknown"; }, + accessorKey: "name", + filterFn: fuzzyTextFilterFn, }, { accessorKey: "email", header: "Email", + filterFn: fuzzyTextFilterFn, }, { accessorKey: "event_role", @@ -50,6 +61,7 @@ const columns: ColumnDef[] = [ const role = row.original.event_role; return ; }, + enableColumnFilter: false, }, { id: "actions", @@ -81,6 +93,7 @@ const StaffTable = ({ data }: Props) => { columns, data: data ?? fallbackData, getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), }); return ( @@ -91,12 +104,23 @@ const StaffTable = ({ data }: Props) => { {headerGroup.headers.map((header) => ( - {header.isPlaceholder - ? null - : flexRender( +
+
+ {flexRender( header.column.columnDef.header, header.getContext(), )} +
+ {header.column.getCanFilter() && ( + header.column.setFilterValue(e)} + placeholder="Search..." + className="mt-2" + /> + )} +
))} diff --git a/apps/web/src/features/Users/hooks/useUsers.ts b/apps/web/src/features/Users/hooks/useUsers.ts new file mode 100644 index 00000000..27e3c14e --- /dev/null +++ b/apps/web/src/features/Users/hooks/useUsers.ts @@ -0,0 +1,28 @@ +import { api } from "@/lib/ky"; +import type { User } from "@/lib/openapi/types"; +import { useQuery } from "@tanstack/react-query"; + +export const useUsers = ( + searchTerm: string | null, + limit: number = 10, + offset: number = 0, +) => { + const fetchUsers = async () => { + const params = new URLSearchParams(); + if (searchTerm) { + params.set("search", searchTerm); + } + params.set("limit", limit.toString()); + params.set("offset", offset.toString()); + + const result = await api.get(`users?${params.toString()}`).json(); + + return result; + }; + + return useQuery({ + queryKey: ["users", searchTerm, limit, offset], + queryFn: fetchUsers, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx index 7d8ddd41..ec6a747f 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx @@ -1,8 +1,9 @@ import { Button } from "@/components/ui/Button"; +import AddStaffModal from "@/features/EventAdmin/components/AddStaffModal"; import StaffTable from "@/features/EventAdmin/components/StaffTable"; import { useEventStaffUsers } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; import { createFileRoute } from "@tanstack/react-router"; -import { Heading } from "react-aria-components"; +import { DialogTrigger, Heading } from "react-aria-components"; export const Route = createFileRoute( "/_protected/events/$eventId/dashboard/_admin/staff-management", @@ -44,7 +45,13 @@ function RouteComponent() {
- {eventRole === "admin" && } + {eventRole === "admin" && ( + + + + + + )}
From ba2fd7e3eb1278a3afeb7d7b624309034dcd1cfe Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Wed, 1 Oct 2025 18:55:42 -0400 Subject: [PATCH 4/6] chore: refactor types and add staff modal --- apps/api/Makefile | 2 +- apps/api/docs/docs.go | 231 +++++++++++++- apps/api/docs/swagger.json | 231 +++++++++++++- apps/api/docs/swagger.yaml | 181 +++++++++++ apps/api/internal/api/handlers/auth.go | 2 +- apps/api/internal/api/handlers/user.go | 16 +- apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 23 ++ apps/web/src/components/ui/Modal/Modal.tsx | 2 +- .../EventAdmin/components/AddStaffModal.tsx | 135 +++++++-- .../EventAdmin/components/StaffTable.tsx | 1 + apps/web/src/features/Users/hooks/useUsers.ts | 18 +- apps/web/src/lib/openapi/schema.d.ts | 286 +++++++++++------- .../dashboard/_admin/staff-management.tsx | 2 +- 14 files changed, 984 insertions(+), 148 deletions(-) diff --git a/apps/api/Makefile b/apps/api/Makefile index 81ba39f4..f9b63b38 100644 --- a/apps/api/Makefile +++ b/apps/api/Makefile @@ -10,7 +10,7 @@ generate: @sqlc generate openapi-generate: - swag init --dir cmd/api,internal/api/handlers --parseDependency -v3.1 + swag init --dir cmd/api,internal/api/handlers --parseDependency --requiredByDefault -v3.1 openapi-format: swag f --dir cmd/api,internal/api/handlers diff --git a/apps/api/docs/docs.go b/apps/api/docs/docs.go index 7374425d..cc939eee 100644 --- a/apps/api/docs/docs.go +++ b/apps/api/docs/docs.go @@ -17,6 +17,10 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "email", + "source" + ], "type": "object" }, "handlers.AssignRoleFields": { @@ -31,6 +35,11 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "email", + "role", + "user_id" + ], "type": "object" }, "handlers.CompleteOnboardingRequest": { @@ -42,6 +51,10 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "name", + "preferred_email" + ], "type": "object" }, "handlers.CreateEventFields": { @@ -91,9 +104,17 @@ const docTemplate = `{ "required": [ "application_close", "application_open", + "decision_release", + "description", "end_time", + "is_published", + "location", + "location_url", + "max_attendees", "name", - "start_time" + "rsvp_deadline", + "start_time", + "website_url" ], "type": "object" }, @@ -112,6 +133,12 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "assigned_at", + "event_id", + "role", + "user_id" + ], "type": "object" }, "handlers.QueueEmailRequest": { @@ -126,6 +153,11 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "body", + "from", + "to" + ], "type": "object" }, "handlers.UpdateEmailConsentRequest": { @@ -134,6 +166,9 @@ const docTemplate = `{ "type": "boolean" } }, + "required": [ + "email_consent" + ], "type": "object" }, "handlers.UpdateProfileRequest": { @@ -145,6 +180,10 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "name", + "preferred_email" + ], "type": "object" }, "middleware.UserContext": { @@ -191,6 +230,16 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "email", + "emailConsent", + "image", + "name", + "onboarded", + "preferredEmail", + "role", + "userId" + ], "type": "object" }, "response.ErrorResponse": { @@ -202,6 +251,10 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "error", + "message" + ], "type": "object" }, "sqlc.Application": { @@ -232,6 +285,15 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "application", + "created_at", + "event_id", + "saved_at", + "status", + "updated_at", + "user_id" + ], "type": "object" }, "sqlc.ApplicationStatus": { @@ -291,6 +353,19 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "created_at", + "email", + "email_consent", + "email_verified", + "id", + "image", + "name", + "onboarded", + "preferred_email", + "role", + "updated_at" + ], "type": "object" }, "sqlc.AuthUserRole": { @@ -359,6 +434,25 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "application_close", + "application_open", + "banner", + "created_at", + "decision_release", + "description", + "end_time", + "id", + "is_published", + "location", + "location_url", + "max_attendees", + "name", + "rsvp_deadline", + "start_time", + "updated_at", + "website_url" + ], "type": "object" }, "sqlc.EventRoleType": { @@ -415,6 +509,20 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "created_at", + "email", + "email_consent", + "email_verified", + "event_role", + "id", + "image", + "name", + "onboarded", + "preferred_email", + "role", + "updated_at" + ], "type": "object" }, "sqlc.GetEventsWithUserInfoRow": { @@ -477,6 +585,27 @@ const docTemplate = `{ "type": "string" } }, + "required": [ + "application_close", + "application_open", + "application_status", + "banner", + "created_at", + "decision_release", + "description", + "end_time", + "event_role", + "id", + "is_published", + "location", + "location_url", + "max_attendees", + "name", + "rsvp_deadline", + "start_time", + "updated_at", + "website_url" + ], "type": "object" }, "sqlc.NullApplicationStatus": { @@ -489,6 +618,10 @@ const docTemplate = `{ "type": "boolean" } }, + "required": [ + "application_status", + "valid" + ], "type": "object" }, "sqlc.NullEventRoleType": { @@ -501,6 +634,10 @@ const docTemplate = `{ "type": "boolean" } }, + "required": [ + "event_role_type", + "valid" + ], "type": "object" } } @@ -1485,6 +1622,98 @@ const docTemplate = `{ ] } }, + "/users": { + "get": { + "description": "Get or search for users by name or email. If no search term is provided, returns all users with pagination.", + "parameters": [ + { + "description": "The authenticated session token/id", + "in": "cookie", + "name": "sh_session", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Search term to filter users by name or email (optional)", + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of users to return (default is 50)", + "in": "query", + "name": "limit", + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Number of users to skip for pagination (default is 0)", + "in": "query", + "name": "offset", + "schema": { + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/sqlc.AuthUser" + }, + "type": "array" + } + } + }, + "description": "OK: Returns a list of users matching the search criteria, or all users if no search term is provided." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Invalid query parameter(s)" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Unauthenticated: Requester is not currently authenticated." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Failed to retrieve users" + } + }, + "summary": "Get/Search for users", + "tags": [ + "User" + ] + } + }, "/users/email-consent": { "patch": { "description": "Update the user's email consent setting", diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 71fa5122..76ad0e83 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -10,6 +10,10 @@ "type": "string" } }, + "required": [ + "email", + "source" + ], "type": "object" }, "handlers.AssignRoleFields": { @@ -24,6 +28,11 @@ "type": "string" } }, + "required": [ + "email", + "role", + "user_id" + ], "type": "object" }, "handlers.CompleteOnboardingRequest": { @@ -35,6 +44,10 @@ "type": "string" } }, + "required": [ + "name", + "preferred_email" + ], "type": "object" }, "handlers.CreateEventFields": { @@ -84,9 +97,17 @@ "required": [ "application_close", "application_open", + "decision_release", + "description", "end_time", + "is_published", + "location", + "location_url", + "max_attendees", "name", - "start_time" + "rsvp_deadline", + "start_time", + "website_url" ], "type": "object" }, @@ -105,6 +126,12 @@ "type": "string" } }, + "required": [ + "assigned_at", + "event_id", + "role", + "user_id" + ], "type": "object" }, "handlers.QueueEmailRequest": { @@ -119,6 +146,11 @@ "type": "string" } }, + "required": [ + "body", + "from", + "to" + ], "type": "object" }, "handlers.UpdateEmailConsentRequest": { @@ -127,6 +159,9 @@ "type": "boolean" } }, + "required": [ + "email_consent" + ], "type": "object" }, "handlers.UpdateProfileRequest": { @@ -138,6 +173,10 @@ "type": "string" } }, + "required": [ + "name", + "preferred_email" + ], "type": "object" }, "middleware.UserContext": { @@ -184,6 +223,16 @@ "type": "string" } }, + "required": [ + "email", + "emailConsent", + "image", + "name", + "onboarded", + "preferredEmail", + "role", + "userId" + ], "type": "object" }, "response.ErrorResponse": { @@ -195,6 +244,10 @@ "type": "string" } }, + "required": [ + "error", + "message" + ], "type": "object" }, "sqlc.Application": { @@ -225,6 +278,15 @@ "type": "string" } }, + "required": [ + "application", + "created_at", + "event_id", + "saved_at", + "status", + "updated_at", + "user_id" + ], "type": "object" }, "sqlc.ApplicationStatus": { @@ -284,6 +346,19 @@ "type": "string" } }, + "required": [ + "created_at", + "email", + "email_consent", + "email_verified", + "id", + "image", + "name", + "onboarded", + "preferred_email", + "role", + "updated_at" + ], "type": "object" }, "sqlc.AuthUserRole": { @@ -352,6 +427,25 @@ "type": "string" } }, + "required": [ + "application_close", + "application_open", + "banner", + "created_at", + "decision_release", + "description", + "end_time", + "id", + "is_published", + "location", + "location_url", + "max_attendees", + "name", + "rsvp_deadline", + "start_time", + "updated_at", + "website_url" + ], "type": "object" }, "sqlc.EventRoleType": { @@ -408,6 +502,20 @@ "type": "string" } }, + "required": [ + "created_at", + "email", + "email_consent", + "email_verified", + "event_role", + "id", + "image", + "name", + "onboarded", + "preferred_email", + "role", + "updated_at" + ], "type": "object" }, "sqlc.GetEventsWithUserInfoRow": { @@ -470,6 +578,27 @@ "type": "string" } }, + "required": [ + "application_close", + "application_open", + "application_status", + "banner", + "created_at", + "decision_release", + "description", + "end_time", + "event_role", + "id", + "is_published", + "location", + "location_url", + "max_attendees", + "name", + "rsvp_deadline", + "start_time", + "updated_at", + "website_url" + ], "type": "object" }, "sqlc.NullApplicationStatus": { @@ -482,6 +611,10 @@ "type": "boolean" } }, + "required": [ + "application_status", + "valid" + ], "type": "object" }, "sqlc.NullEventRoleType": { @@ -494,6 +627,10 @@ "type": "boolean" } }, + "required": [ + "event_role_type", + "valid" + ], "type": "object" } } @@ -1478,6 +1615,98 @@ ] } }, + "/users": { + "get": { + "description": "Get or search for users by name or email. If no search term is provided, returns all users with pagination.", + "parameters": [ + { + "description": "The authenticated session token/id", + "in": "cookie", + "name": "sh_session", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Search term to filter users by name or email (optional)", + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of users to return (default is 50)", + "in": "query", + "name": "limit", + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Number of users to skip for pagination (default is 0)", + "in": "query", + "name": "offset", + "schema": { + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/sqlc.AuthUser" + }, + "type": "array" + } + } + }, + "description": "OK: Returns a list of users matching the search criteria, or all users if no search term is provided." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Invalid query parameter(s)" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Unauthenticated: Requester is not currently authenticated." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Failed to retrieve users" + } + }, + "summary": "Get/Search for users", + "tags": [ + "User" + ] + } + }, "/users/email-consent": { "patch": { "description": "Update the user's email consent setting", diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 9cffd7d0..c7cd0809 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -6,6 +6,9 @@ components: type: string source: type: string + required: + - email + - source type: object handlers.AssignRoleFields: properties: @@ -15,6 +18,10 @@ components: $ref: '#/components/schemas/sqlc.EventRoleType' user_id: type: string + required: + - email + - role + - user_id type: object handlers.CompleteOnboardingRequest: properties: @@ -22,6 +29,9 @@ components: type: string preferred_email: type: string + required: + - name + - preferred_email type: object handlers.CreateEventFields: properties: @@ -56,9 +66,17 @@ components: required: - application_close - application_open + - decision_release + - description - end_time + - is_published + - location + - location_url + - max_attendees - name + - rsvp_deadline - start_time + - website_url type: object handlers.NullableEventRole: properties: @@ -70,6 +88,11 @@ components: $ref: '#/components/schemas/sqlc.EventRoleType' user_id: type: string + required: + - assigned_at + - event_id + - role + - user_id type: object handlers.QueueEmailRequest: properties: @@ -79,11 +102,17 @@ components: type: string to: type: string + required: + - body + - from + - to type: object handlers.UpdateEmailConsentRequest: properties: email_consent: type: boolean + required: + - email_consent type: object handlers.UpdateProfileRequest: properties: @@ -91,6 +120,9 @@ components: type: string preferred_email: type: string + required: + - name + - preferred_email type: object middleware.UserContext: description: Information about the current user session. @@ -127,6 +159,15 @@ components: example: 550e8400-e29b-41d4-a716-446655440000 format: uuid type: string + required: + - email + - emailConsent + - image + - name + - onboarded + - preferredEmail + - role + - userId type: object response.ErrorResponse: properties: @@ -134,6 +175,9 @@ components: type: string message: type: string + required: + - error + - message type: object sqlc.Application: properties: @@ -154,6 +198,14 @@ components: type: string user_id: type: string + required: + - application + - created_at + - event_id + - saved_at + - status + - updated_at + - user_id type: object sqlc.ApplicationStatus: enum: @@ -197,6 +249,18 @@ components: $ref: '#/components/schemas/sqlc.AuthUserRole' updated_at: type: string + required: + - created_at + - email + - email_consent + - email_verified + - id + - image + - name + - onboarded + - preferred_email + - role + - updated_at type: object sqlc.AuthUserRole: description: Role assigned to the user @@ -243,6 +307,24 @@ components: type: string website_url: type: string + required: + - application_close + - application_open + - banner + - created_at + - decision_release + - description + - end_time + - id + - is_published + - location + - location_url + - max_attendees + - name + - rsvp_deadline + - start_time + - updated_at + - website_url type: object sqlc.EventRoleType: enum: @@ -282,6 +364,19 @@ components: $ref: '#/components/schemas/sqlc.AuthUserRole' updated_at: type: string + required: + - created_at + - email + - email_consent + - email_verified + - event_role + - id + - image + - name + - onboarded + - preferred_email + - role + - updated_at type: object sqlc.GetEventsWithUserInfoRow: properties: @@ -323,6 +418,26 @@ components: type: string website_url: type: string + required: + - application_close + - application_open + - application_status + - banner + - created_at + - decision_release + - description + - end_time + - event_role + - id + - is_published + - location + - location_url + - max_attendees + - name + - rsvp_deadline + - start_time + - updated_at + - website_url type: object sqlc.NullApplicationStatus: properties: @@ -331,6 +446,9 @@ components: valid: description: Valid is true if ApplicationStatus is not NULL type: boolean + required: + - application_status + - valid type: object sqlc.NullEventRoleType: properties: @@ -339,6 +457,9 @@ components: valid: description: Valid is true if EventRoleType is not NULL type: boolean + required: + - event_role_type + - valid type: object externalDocs: description: "" @@ -950,6 +1071,66 @@ paths: summary: Get all staff users for an event tags: - Event + /users: + get: + description: Get or search for users by name or email. If no search term is + provided, returns all users with pagination. + parameters: + - description: The authenticated session token/id + in: cookie + name: sh_session + required: true + schema: + type: string + - description: Search term to filter users by name or email (optional) + in: query + name: search + schema: + type: string + - description: Maximum number of users to return (default is 50) + in: query + name: limit + schema: + maximum: 100 + minimum: 1 + type: integer + - description: Number of users to skip for pagination (default is 0) + in: query + name: offset + schema: + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + items: + $ref: '#/components/schemas/sqlc.AuthUser' + type: array + description: 'OK: Returns a list of users matching the search criteria, + or all users if no search term is provided.' + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: Invalid query parameter(s) + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Unauthenticated: Requester is not currently authenticated.' + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: Failed to retrieve users + summary: Get/Search for users + tags: + - User /users/email-consent: patch: description: Update the user's email consent setting diff --git a/apps/api/internal/api/handlers/auth.go b/apps/api/internal/api/handlers/auth.go index 2cd0729f..1fdc107b 100644 --- a/apps/api/internal/api/handlers/auth.go +++ b/apps/api/internal/api/handlers/auth.go @@ -39,7 +39,7 @@ func NewAuthHandler(authService *services.AuthService, cfg *config.Config, logge // @Success 200 {object} middleware.UserContext // @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." // @Failure 500 {object} response.ErrorResponse -// @Router /auth/me [get] +// @Router /auth/me [get] func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) { user, err := h.authService.GetMe(r.Context()) if err != nil { diff --git a/apps/api/internal/api/handlers/user.go b/apps/api/internal/api/handlers/user.go index b4d68ab6..cb34409b 100644 --- a/apps/api/internal/api/handlers/user.go +++ b/apps/api/internal/api/handlers/user.go @@ -237,6 +237,20 @@ func (h *UserHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } +// Get/Search for users +// +// @Summary Get/Search for users +// @Description Get or search for users by name or email. If no search term is provided, returns all users with pagination. +// @Tags User +// @Param sh_session cookie string true "The authenticated session token/id" +// @Param search query string false "Search term to filter users by name or email (optional)" +// @Param limit query int32 false "Maximum number of users to return (default is 50)" minimum(1) maximum(100) +// @Param offset query int32 false "Number of users to skip for pagination (default is 0)" minimum(0) +// @Success 200 {array} sqlc.AuthUser "OK: Returns a list of users matching the search criteria, or all users if no search term is provided." +// @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." +// @Failure 400 {object} response.ErrorResponse "Invalid query parameter(s)" +// @Failure 500 {object} response.ErrorResponse "Failed to retrieve users" +// @Router /users [get] func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { queryParams := r.URL.Query() @@ -259,7 +273,7 @@ func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { users, err := h.userService.GetAllUsers(r.Context(), searchTerm, *limit, *offset) if err != nil { h.logger.Err(err).Msg("Failed to retrieve all users") - res.SendError(w, http.StatusInternalServerError, res.NewError("update_failed", "Failed to complete onboarding")) + res.SendError(w, http.StatusInternalServerError, res.NewError("update_failed", "Failed to retrieve users")) return } diff --git a/apps/web/package.json b/apps/web/package.json index aeb0f79c..11a11d20 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,7 @@ "date-fns": "^4.1.0", "js-cookie": "^3.0.5", "ky": "^1.8.1", + "lodash.debounce": "^4.0.8", "nanoid": "^5.1.5", "react": "^19.1.0", "react-aria": "^3.41.1", @@ -76,6 +77,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@types/js-cookie": "^3.0.6", + "@types/lodash.debounce": "^4.0.9", "@types/node": "^22.15.15", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index e83dd5b4..9734ae01 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: ky: specifier: ^1.8.1 version: 1.8.1 + lodash.debounce: + specifier: ^4.0.8 + version: 4.0.8 nanoid: specifier: ^5.1.5 version: 5.1.5 @@ -153,6 +156,9 @@ importers: '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 + '@types/lodash.debounce': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^22.15.15 version: 22.15.34 @@ -2073,6 +2079,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash.debounce@4.0.9': + resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -3373,6 +3385,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -6858,6 +6873,12 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash.debounce@4.0.9': + dependencies: + '@types/lodash': 4.17.20 + + '@types/lodash@4.17.20': {} + '@types/mdx@2.0.13': {} '@types/node@22.15.34': @@ -8230,6 +8251,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.debounce@4.0.8: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} diff --git a/apps/web/src/components/ui/Modal/Modal.tsx b/apps/web/src/components/ui/Modal/Modal.tsx index d1bbb316..40c154e6 100644 --- a/apps/web/src/components/ui/Modal/Modal.tsx +++ b/apps/web/src/components/ui/Modal/Modal.tsx @@ -120,7 +120,7 @@ export function Modal({ {title && ( {title} )} - + {children} diff --git a/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx b/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx index 9168ffff..e833b93a 100644 --- a/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx +++ b/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx @@ -1,45 +1,120 @@ import { Button } from "@/components/ui/Button"; import { Modal } from "@/components/ui/Modal"; import { TextField } from "@/components/ui/TextField"; +import { useEventStaffUsers } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; import { useUsers } from "@/features/Users/hooks/useUsers"; -import { useState } from "react"; +import { useCallback, useState } from "react"; +import debounce from "lodash.debounce"; +import { cn } from "@/utils/cn"; -const AddStaffModal = () => { - const [searchTerm, setSearchTerm] = useState(""); - const { data, isLoading, isError } = useUsers(searchTerm) +interface Props { + eventId: string; +} + +const AddStaffModal = ({ eventId }: Props) => { + const [query, setQuery] = useState(""); + const [selectedUserIds, setSelectedUserIds] = useState([]); + + const { + data: users, + isLoading: isUsersLoading, + isError: isUsersError, + } = useUsers(query); + const { + data: eventStaff, + isLoading: isStaffUsersLoading, + isError: isStaffUsersError, + } = useEventStaffUsers(eventId); // Tanstack Query can be used here to fetch users based on the search term + const handleSearch = useCallback( + debounce((value: string) => { + setQuery(value); + }, 500), + [], + ); + + const onSelectionChange = (userId: string) => { + setSelectedUserIds((prev) => { + if (prev.includes(userId)) { + return prev.filter((id) => id !== userId); + } else { + return [...prev, userId]; + } + }); + }; + + const isAlreadyStaff = (userId: string) => + eventStaff?.some((staff) => staff.id === userId); + const isSelected = (userId: string) => selectedUserIds.includes(userId); + return ( -
- setSearchTerm(e)} - /> - - {isLoading &&

Loading...

} - {isError &&

Error loading users.

} - {data && data.length === 0 &&

No users found.

} - {data && data.length > 0 && ( -
- {data.map((user) => ( -
-
-

{user.name}

-

{user.email}

-
- +
+
+ handleSearch(e)} + /> + + {query === "" ? ( +

Type to search for users

+ ) : isUsersLoading || isStaffUsersLoading ? ( +

Loading...

+ ) : isUsersError || isStaffUsersError ? ( +

Error loading users.

+ ) : users && users.length === 0 ? ( +

No users found.

+ ) : ( + users && ( +
+ {users.map((user) => { + const alreadyStaff = isAlreadyStaff(user.id); + const selected = isSelected(user.id); + + return ( +
+ !alreadyStaff && onSelectionChange(user.id) + } + className={cn( + "w-full bg-input-bg hover:cursor-pointer hover:bg-neutral-900 rounded-md p-4 flex flex-row items-center justify-between select-none", + selected + ? "border-1 border-green-700" + : alreadyStaff + ? "opacity-50 cursor-not-allowed hover:cursor-default hover:bg-input-bg" + : "border-1 border-transparent", + )} + > +
+

{user.name}

+

+ {user.email} +

+
+ + {alreadyStaff && ( +

+ Already Staff +

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

Searching for {searchTerm}

+ {selectedUserIds.length > 0 && ( + + )}
); diff --git a/apps/web/src/features/EventAdmin/components/StaffTable.tsx b/apps/web/src/features/EventAdmin/components/StaffTable.tsx index 1fe7ce44..debbf439 100644 --- a/apps/web/src/features/EventAdmin/components/StaffTable.tsx +++ b/apps/web/src/features/EventAdmin/components/StaffTable.tsx @@ -117,6 +117,7 @@ const StaffTable = ({ data }: Props) => { header.column.setFilterValue(e)} placeholder="Search..." className="mt-2" diff --git a/apps/web/src/features/Users/hooks/useUsers.ts b/apps/web/src/features/Users/hooks/useUsers.ts index 27e3c14e..304f25d6 100644 --- a/apps/web/src/features/Users/hooks/useUsers.ts +++ b/apps/web/src/features/Users/hooks/useUsers.ts @@ -1,28 +1,34 @@ import { api } from "@/lib/ky"; -import type { User } from "@/lib/openapi/types"; +import type { paths } from "@/lib/openapi/schema"; import { useQuery } from "@tanstack/react-query"; +type GetUsersResponse = + paths["/users"]["get"]["responses"]["200"]["content"]["application/json"]; + export const useUsers = ( - searchTerm: string | null, + query: string | null, limit: number = 10, offset: number = 0, ) => { const fetchUsers = async () => { const params = new URLSearchParams(); - if (searchTerm) { - params.set("search", searchTerm); + if (query) { + params.set("search", query); } params.set("limit", limit.toString()); params.set("offset", offset.toString()); - const result = await api.get(`users?${params.toString()}`).json(); + const result = await api + .get(`users?${params.toString()}`) + .json(); return result; }; return useQuery({ - queryKey: ["users", searchTerm, limit, offset], + queryKey: ["users", query, limit, offset], queryFn: fetchUsers, + enabled: !!query, // Only fetch when there's a search term staleTime: 5 * 60 * 1000, // 5 minutes }); }; diff --git a/apps/web/src/lib/openapi/schema.d.ts b/apps/web/src/lib/openapi/schema.d.ts index 7905fbff..27fd0d52 100644 --- a/apps/web/src/lib/openapi/schema.d.ts +++ b/apps/web/src/lib/openapi/schema.d.ts @@ -925,6 +925,82 @@ export interface paths { patch?: never; trace?: never; }; + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get/Search for users + * @description Get or search for users by name or email. If no search term is provided, returns all users with pagination. + */ + get: { + parameters: { + query?: { + /** @description Search term to filter users by name or email (optional) */ + search?: string; + /** @description Maximum number of users to return (default is 50) */ + limit?: number; + /** @description Number of users to skip for pagination (default is 0) */ + offset?: number; + }; + header?: never; + path?: never; + cookie: { + /** @description The authenticated session token/id */ + sh_session: string; + }; + }; + requestBody?: never; + responses: { + /** @description OK: Returns a list of users matching the search criteria, or all users if no search term is provided. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["sqlc.AuthUser"][]; + }; + }; + /** @description Invalid query parameter(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Unauthenticated: Requester is not currently authenticated. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Failed to retrieve users */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/email-consent": { parameters: { query?: never; @@ -1226,50 +1302,50 @@ export type webhooks = Record; export interface components { schemas: { "handlers.AddEmailRequest": { - email?: string; - source?: string; + email: string; + source: string; }; "handlers.AssignRoleFields": { - email?: string; - role?: components["schemas"]["sqlc.EventRoleType"]; - user_id?: string; + email: string; + role: components["schemas"]["sqlc.EventRoleType"]; + user_id: string; }; "handlers.CompleteOnboardingRequest": { - name?: string; - preferred_email?: string; + name: string; + preferred_email: string; }; "handlers.CreateEventFields": { application_close: string; application_open: string; - decision_release?: string; - description?: string; + decision_release: string; + description: string; end_time: string; - is_published?: boolean; - location?: string; - location_url?: string; - max_attendees?: number; + is_published: boolean; + location: string; + location_url: string; + max_attendees: number; name: string; - rsvp_deadline?: string; + rsvp_deadline: string; start_time: string; - website_url?: string; + website_url: string; }; "handlers.NullableEventRole": { - assigned_at?: string; - event_id?: string; - role?: components["schemas"]["sqlc.EventRoleType"]; - user_id?: string; + assigned_at: string; + event_id: string; + role: components["schemas"]["sqlc.EventRoleType"]; + user_id: string; }; "handlers.QueueEmailRequest": { - body?: string; - from?: string; - to?: string; + body: string; + from: string; + to: string; }; "handlers.UpdateEmailConsentRequest": { - email_consent?: boolean; + email_consent: boolean; }; "handlers.UpdateProfileRequest": { - name?: string; - preferred_email?: string; + name: string; + preferred_email: string; }; /** @description Information about the current user session. */ "middleware.UserContext": { @@ -1277,52 +1353,52 @@ export interface components { * @description Primary email address (nullable) * @example user@example.com */ - email?: string; + email: string; /** * @description Whether the user agreed to receive emails * @example false */ - emailConsent?: boolean; + emailConsent: boolean; /** * @description Optional profile image URL * @example https://cdn.example.com/avatar.png */ - image?: string | null; + image: string | null; /** * @description Full display name * @example Jane Doe */ - name?: string; + name: string; /** * @description Whether the user completed onboarding * @example true */ - onboarded?: boolean; + onboarded: boolean; /** * @description Preferred email address for communications * @example user.alt@example.com */ - preferredEmail?: string; - role?: components["schemas"]["sqlc.AuthUserRole"]; + preferredEmail: string; + role: components["schemas"]["sqlc.AuthUserRole"]; /** * Format: uuid * @description Unique identifier for the user * @example 550e8400-e29b-41d4-a716-446655440000 */ - userId?: string; + userId: string; }; "response.ErrorResponse": { - error?: string; - message?: string; + error: string; + message: string; }; "sqlc.Application": { - application?: number[]; - created_at?: string; - event_id?: string; - saved_at?: string; - status?: components["schemas"]["sqlc.NullApplicationStatus"]; - updated_at?: string; - user_id?: string; + application: number[]; + created_at: string; + event_id: string; + saved_at: string; + status: components["schemas"]["sqlc.NullApplicationStatus"]; + updated_at: string; + user_id: string; }; /** @enum {string} */ "sqlc.ApplicationStatus": @@ -1334,17 +1410,17 @@ export interface components { | "waitlisted" | "withdrawn"; "sqlc.AuthUser": { - created_at?: string; - email?: string; - email_consent?: boolean; - email_verified?: boolean; - id?: string; - image?: string; - name?: string; - onboarded?: boolean; - preferred_email?: string; - role?: components["schemas"]["sqlc.AuthUserRole"]; - updated_at?: string; + created_at: string; + email: string; + email_consent: boolean; + email_verified: boolean; + id: string; + image: string; + name: string; + onboarded: boolean; + preferred_email: string; + role: components["schemas"]["sqlc.AuthUserRole"]; + updated_at: string; }; /** * @description Role assigned to the user @@ -1352,70 +1428,70 @@ export interface components { */ "sqlc.AuthUserRole": "user" | "superuser"; "sqlc.Event": { - application_close?: string; - application_open?: string; - banner?: string; - created_at?: string; - decision_release?: string; - description?: string; - end_time?: string; - id?: string; - is_published?: boolean; - location?: string; - location_url?: string; - max_attendees?: number; - name?: string; - rsvp_deadline?: string; - start_time?: string; - updated_at?: string; - website_url?: string; + application_close: string; + application_open: string; + banner: string; + created_at: string; + decision_release: string; + description: string; + end_time: string; + id: string; + is_published: boolean; + location: string; + location_url: string; + max_attendees: number; + name: string; + rsvp_deadline: string; + start_time: string; + updated_at: string; + website_url: string; }; /** @enum {string} */ "sqlc.EventRoleType": "admin" | "staff" | "attendee" | "applicant"; "sqlc.GetEventStaffRow": { - created_at?: string; - email?: string; - email_consent?: boolean; - email_verified?: boolean; - event_role?: components["schemas"]["sqlc.EventRoleType"]; - id?: string; - image?: string; - name?: string; - onboarded?: boolean; - preferred_email?: string; - role?: components["schemas"]["sqlc.AuthUserRole"]; - updated_at?: string; + created_at: string; + email: string; + email_consent: boolean; + email_verified: boolean; + event_role: components["schemas"]["sqlc.EventRoleType"]; + id: string; + image: string; + name: string; + onboarded: boolean; + preferred_email: string; + role: components["schemas"]["sqlc.AuthUserRole"]; + updated_at: string; }; "sqlc.GetEventsWithUserInfoRow": { - application_close?: string; - application_open?: string; - application_status?: components["schemas"]["sqlc.NullApplicationStatus"]; - banner?: string; - created_at?: string; - decision_release?: string; - description?: string; - end_time?: string; - event_role?: components["schemas"]["sqlc.NullEventRoleType"]; - id?: string; - is_published?: boolean; - location?: string; - location_url?: string; - max_attendees?: number; - name?: string; - rsvp_deadline?: string; - start_time?: string; - updated_at?: string; - website_url?: string; + application_close: string; + application_open: string; + application_status: components["schemas"]["sqlc.NullApplicationStatus"]; + banner: string; + created_at: string; + decision_release: string; + description: string; + end_time: string; + event_role: components["schemas"]["sqlc.NullEventRoleType"]; + id: string; + is_published: boolean; + location: string; + location_url: string; + max_attendees: number; + name: string; + rsvp_deadline: string; + start_time: string; + updated_at: string; + website_url: string; }; "sqlc.NullApplicationStatus": { - application_status?: components["schemas"]["sqlc.ApplicationStatus"]; + application_status: components["schemas"]["sqlc.ApplicationStatus"]; /** @description Valid is true if ApplicationStatus is not NULL */ - valid?: boolean; + valid: boolean; }; "sqlc.NullEventRoleType": { - event_role_type?: components["schemas"]["sqlc.EventRoleType"]; + event_role_type: components["schemas"]["sqlc.EventRoleType"]; /** @description Valid is true if EventRoleType is not NULL */ - valid?: boolean; + valid: boolean; }; }; responses: never; diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx index ec6a747f..c7ddd27e 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx @@ -49,7 +49,7 @@ function RouteComponent() { - + )}
From 59db0754ef37465b1fd1986cd786847067475df0 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Wed, 1 Oct 2025 23:42:14 -0400 Subject: [PATCH 5/6] feat: deleting staff users --- apps/api/docs/docs.go | 151 +++++++++++++++++ apps/api/docs/swagger.json | 151 +++++++++++++++++ apps/api/docs/swagger.yaml | 94 +++++++++++ apps/api/internal/api/api.go | 2 + apps/api/internal/api/handlers/auth.go | 2 +- apps/api/internal/api/handlers/events.go | 122 ++++++++++++++ apps/api/internal/api/handlers/user.go | 2 +- apps/api/internal/db/queries/event_roles.sql | 5 + apps/api/internal/db/repository/events.go | 9 + apps/api/internal/db/sqlc/event_roles.sql.go | 16 ++ apps/api/internal/services/events.go | 4 + apps/web/src/components/ui/Modal/Modal.tsx | 3 + .../EventAdmin/components/AddStaffModal.tsx | 155 +++++++++++------- .../components/DeleteStaffDialog.tsx | 69 ++++++++ .../EventAdmin/components/StaffTable.tsx | 142 ++++++++-------- .../EventAdmin/hooks/useStaffActions.ts | 67 ++++++++ .../EventManager/hooks/useEventStaffUsers.ts | 2 +- apps/web/src/lib/openapi/schema.d.ts | 132 +++++++++++++++ apps/web/src/lib/openapi/types.ts | 2 +- .../dashboard/_admin/staff-management.tsx | 2 +- 20 files changed, 1001 insertions(+), 131 deletions(-) create mode 100644 apps/web/src/features/EventAdmin/components/DeleteStaffDialog.tsx create mode 100644 apps/web/src/features/EventAdmin/hooks/useStaffActions.ts diff --git a/apps/api/docs/docs.go b/apps/api/docs/docs.go index cc939eee..b62b3c7d 100644 --- a/apps/api/docs/docs.go +++ b/apps/api/docs/docs.go @@ -23,6 +23,21 @@ const docTemplate = `{ ], "type": "object" }, + "handlers.AssignRoleBatch": { + "properties": { + "assignments": { + "items": { + "$ref": "#/components/schemas/handlers.AssignRoleFields" + }, + "type": "array", + "uniqueItems": false + } + }, + "required": [ + "assignments" + ], + "type": "object" + }, "handlers.AssignRoleFields": { "properties": { "email": { @@ -1567,6 +1582,142 @@ const docTemplate = `{ ] } }, + "/events/{eventId}/roles/batch": { + "post": { + "description": "Modify users' role for a specific event", + "parameters": [ + { + "description": "Event ID", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/handlers.AssignRoleBatch", + "summary": "request", + "description": "Event roles data" + } + ] + } + } + }, + "description": "Event roles data", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK - Roles updated" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Not Found - User not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: Something went terribly wrong on our end." + } + }, + "summary": "Change or add event role of a user in batch", + "tags": [ + "Event" + ] + } + }, + "/events/{eventId}/roles/{userId}": { + "delete": { + "description": "Remove user's role for a specific event", + "parameters": [ + { + "description": "Event ID", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "User ID", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK - Role revoked" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Not Found - User not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: Something went terribly wrong on our end." + } + }, + "summary": "Revoke event role of a user", + "tags": [ + "Event" + ] + } + }, "/events/{eventId}/staff": { "get": { "description": "Gets all users with role STAFF or ADMIN", diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 76ad0e83..4b8c645f 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -16,6 +16,21 @@ ], "type": "object" }, + "handlers.AssignRoleBatch": { + "properties": { + "assignments": { + "items": { + "$ref": "#/components/schemas/handlers.AssignRoleFields" + }, + "type": "array", + "uniqueItems": false + } + }, + "required": [ + "assignments" + ], + "type": "object" + }, "handlers.AssignRoleFields": { "properties": { "email": { @@ -1560,6 +1575,142 @@ ] } }, + "/events/{eventId}/roles/batch": { + "post": { + "description": "Modify users' role for a specific event", + "parameters": [ + { + "description": "Event ID", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/handlers.AssignRoleBatch", + "summary": "request", + "description": "Event roles data" + } + ] + } + } + }, + "description": "Event roles data", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK - Roles updated" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Not Found - User not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: Something went terribly wrong on our end." + } + }, + "summary": "Change or add event role of a user in batch", + "tags": [ + "Event" + ] + } + }, + "/events/{eventId}/roles/{userId}": { + "delete": { + "description": "Remove user's role for a specific event", + "parameters": [ + { + "description": "Event ID", + "in": "path", + "name": "eventId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "User ID", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OK - Role revoked" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Not Found - User not found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/response.ErrorResponse" + } + } + }, + "description": "Server Error: Something went terribly wrong on our end." + } + }, + "summary": "Revoke event role of a user", + "tags": [ + "Event" + ] + } + }, "/events/{eventId}/staff": { "get": { "description": "Gets all users with role STAFF or ADMIN", diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index c7cd0809..380cdb8e 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -10,6 +10,16 @@ components: - email - source type: object + handlers.AssignRoleBatch: + properties: + assignments: + items: + $ref: '#/components/schemas/handlers.AssignRoleFields' + type: array + uniqueItems: false + required: + - assignments + type: object handlers.AssignRoleFields: properties: email: @@ -1037,6 +1047,90 @@ paths: summary: Change or add event role of a user tags: - Event + /events/{eventId}/roles/{userId}: + delete: + description: Remove user's role for a specific event + parameters: + - description: Event ID + in: path + name: eventId + required: true + schema: + format: uuid + type: string + - description: User ID + in: path + name: userId + required: true + schema: + format: uuid + type: string + responses: + "200": + content: + application/json: + schema: + type: object + description: OK - Role revoked + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: Not Found - User not found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Server Error: Something went terribly wrong on our end.' + summary: Revoke event role of a user + tags: + - Event + /events/{eventId}/roles/batch: + post: + description: Modify users' role for a specific event + parameters: + - description: Event ID + in: path + name: eventId + required: true + schema: + format: uuid + type: string + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/handlers.AssignRoleBatch' + description: Event roles data + summary: request + description: Event roles data + required: true + responses: + "200": + content: + application/json: + schema: + type: object + description: OK - Roles updated + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: Not Found - User not found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/response.ErrorResponse' + description: 'Server Error: Something went terribly wrong on our end.' + summary: Change or add event role of a user in batch + tags: + - Event /events/{eventId}/staff: get: description: Gets all users with role STAFF or ADMIN diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index f3614de2..d0c0381a 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -131,6 +131,8 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.With(ensureEventAdmin).Delete("/banner", api.Handlers.Event.DeleteBanner) r.With(ensureEventAdmin).Get("/staff", api.Handlers.Event.GetEventStaffUsers) r.With(ensureEventAdmin).Post("/roles", api.Handlers.Event.AssignEventRole) + r.With(ensureEventAdmin).Delete("/roles/{userId}", api.Handlers.Event.RevokeEventRole) + r.With(ensureEventAdmin).Post("/roles/batch", api.Handlers.Event.BatchAssignEventRoles) // Superuser-only r.With(ensureSuperuser).Delete("/", api.Handlers.Event.DeleteEventById) diff --git a/apps/api/internal/api/handlers/auth.go b/apps/api/internal/api/handlers/auth.go index 1fdc107b..e88e7d06 100644 --- a/apps/api/internal/api/handlers/auth.go +++ b/apps/api/internal/api/handlers/auth.go @@ -39,7 +39,7 @@ func NewAuthHandler(authService *services.AuthService, cfg *config.Config, logge // @Success 200 {object} middleware.UserContext // @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." // @Failure 500 {object} response.ErrorResponse -// @Router /auth/me [get] +// @Router /auth/me [get] func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) { user, err := h.authService.GetMe(r.Context()) if err != nil { diff --git a/apps/api/internal/api/handlers/events.go b/apps/api/internal/api/handlers/events.go index 8e8b0799..e30d9816 100644 --- a/apps/api/internal/api/handlers/events.go +++ b/apps/api/internal/api/handlers/events.go @@ -531,6 +531,128 @@ func (h *EventHandler) AssignEventRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +type AssignRoleBatch struct { + Assignments []AssignRoleFields `json:"assignments"` +} + +func (b *AssignRoleBatch) Validate() error { + if len(b.Assignments) == 0 { + return fmt.Errorf("at least one assignment is required") + } + for i, a := range b.Assignments { + if err := a.Validate(); err != nil { + return fmt.Errorf("assignment[%d]: %w", i, err) + } + } + return nil +} + +// Change or add event role of a user in batch +// +// @Summary Change or add event role of a user in batch +// @Description Modify users' role for a specific event +// @Tags Event +// @Accept json +// @Produce json +// @Param eventId path string true "Event ID" Format(uuid) +// @Param request body AssignRoleBatch true "Event roles data" +// @Success 200 "OK - Roles updated" +// @Failure 404 {object} response.ErrorResponse "Not Found - User not found" +// @Failure 500 {object} response.ErrorResponse "Server Error: Something went terribly wrong on our end." +// @Router /events/{eventId}/roles/batch [post] +func (h *EventHandler) BatchAssignEventRoles(w http.ResponseWriter, r *http.Request) { + eventIdStr := chi.URLParam(r, "eventId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + var input AssignRoleBatch + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_body", err.Error())) + return + } + + if err := input.Validate(); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_body", err.Error())) + return + } + + for _, assignment := range input.Assignments { + userId := parse.ParseUUIDOrNil(assignment.UserID) + email := parse.ParseStrToPtr(assignment.Email) + + err = h.eventService.AssignEventRole(r.Context(), userId, email, eventId, assignment.Role) + if err != nil { + switch err { + case repository.ErrUserNotFound: + res.SendError(w, http.StatusNotFound, res.NewError("user_missing", fmt.Sprintf("The user %v does not exist", assignment.UserID))) + default: + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong on our end")) + } + return + } + } + + w.WriteHeader(http.StatusOK) +} + +// Revoke event role of a user +// +// @Summary Revoke event role of a user +// @Description Remove user's role for a specific event +// @Tags Event +// @Produce json +// @Param eventId path string true "Event ID" Format(uuid) +// @Param userId path string true "User ID" Format(uuid) +// @Success 200 "OK - Role revoked" +// @Failure 404 {object} response.ErrorResponse "Not Found - User not found" +// @Failure 500 {object} response.ErrorResponse "Server Error: Something went terribly wrong on our end." +// @Router /events/{eventId}/roles/{userId} [delete] +func (h *EventHandler) RevokeEventRole(w http.ResponseWriter, r *http.Request) { + eventIdStr := chi.URLParam(r, "eventId") + userIdStr := chi.URLParam(r, "userId") + if eventIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + if userIdStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) + return + } + + userId, err := uuid.Parse(userIdStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_user_id", "The user ID is not a valid UUID")) + return + } + + err = h.eventService.RevokeEventRole(r.Context(), userId, eventId) + if err != nil { + switch err { + case repository.ErrUserNotFound: + res.SendError(w, http.StatusNotFound, res.NewError("user_missing", "The user does not exist")) + default: + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong on our end")) + } + return + } + + w.WriteHeader(http.StatusOK) +} + func deferredCloser(c io.Closer, name string) func() { return func() { if err := c.Close(); err != nil { diff --git a/apps/api/internal/api/handlers/user.go b/apps/api/internal/api/handlers/user.go index cb34409b..22be4620 100644 --- a/apps/api/internal/api/handlers/user.go +++ b/apps/api/internal/api/handlers/user.go @@ -244,7 +244,7 @@ func (h *UserHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) // @Tags User // @Param sh_session cookie string true "The authenticated session token/id" // @Param search query string false "Search term to filter users by name or email (optional)" -// @Param limit query int32 false "Maximum number of users to return (default is 50)" minimum(1) maximum(100) +// @Param limit query int32 false "Maximum number of users to return (default is 50)" minimum(1) maximum(100) // @Param offset query int32 false "Number of users to skip for pagination (default is 0)" minimum(0) // @Success 200 {array} sqlc.AuthUser "OK: Returns a list of users matching the search criteria, or all users if no search term is provided." // @Failure 401 {object} response.ErrorResponse "Unauthenticated: Requester is not currently authenticated." diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql index 780fced3..94370566 100644 --- a/apps/api/internal/db/queries/event_roles.sql +++ b/apps/api/internal/db/queries/event_roles.sql @@ -9,3 +9,8 @@ WHERE er.event_id = $1 INSERT INTO event_roles (event_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING; + +-- name: RemoveRole :exec +DELETE FROM event_roles +WHERE event_id = $1 + AND user_id = $2; \ No newline at end of file diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go index 4c6a8af4..4771b7cb 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -116,3 +116,12 @@ func (r *EventRepository) GetEventStaff(ctx context.Context, eventId uuid.UUID) func (r *EventRepository) AssignRole(ctx context.Context, params sqlc.AssignRoleParams) error { return r.db.Query.AssignRole(ctx, params) } + +func (r *EventRepository) RevokeRole(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) error { + params := sqlc.RemoveRoleParams{ + UserID: userId, + EventID: eventId, + } + + return r.db.Query.RemoveRole(ctx, params) +} diff --git a/apps/api/internal/db/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go index 75ed670e..7cfebc37 100644 --- a/apps/api/internal/db/sqlc/event_roles.sql.go +++ b/apps/api/internal/db/sqlc/event_roles.sql.go @@ -84,3 +84,19 @@ func (q *Queries) GetEventStaff(ctx context.Context, eventID uuid.UUID) ([]GetEv } return items, nil } + +const removeRole = `-- name: RemoveRole :exec +DELETE FROM event_roles +WHERE event_id = $1 + AND user_id = $2 +` + +type RemoveRoleParams struct { + EventID uuid.UUID `json:"event_id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) RemoveRole(ctx context.Context, arg RemoveRoleParams) error { + _, err := q.db.Exec(ctx, removeRole, arg.EventID, arg.UserID) + return err +} diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index 2b405f08..41bede4d 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -197,6 +197,10 @@ func (s *EventService) AssignEventRole( return nil } +func (s *EventService) RevokeEventRole(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) error { + return s.eventRepo.RevokeRole(ctx, userId, eventId) +} + func (s *EventService) IsApplicationsOpen(ctx context.Context, eventId uuid.UUID) (bool, error) { event, err := s.GetEventByID(ctx, eventId) if err != nil { diff --git a/apps/web/src/components/ui/Modal/Modal.tsx b/apps/web/src/components/ui/Modal/Modal.tsx index 40c154e6..1be74182 100644 --- a/apps/web/src/components/ui/Modal/Modal.tsx +++ b/apps/web/src/components/ui/Modal/Modal.tsx @@ -70,12 +70,14 @@ export type ModalProps = { className?: string; minimumSheetHeight?: string; isDismissible?: boolean; + isOpen?: boolean; } & VariantProps; export function Modal({ title, children, isDismissible = true, + isOpen, minimumSheetHeight = "65vh", size, padding, @@ -89,6 +91,7 @@ export function Modal({ }; return ( ` ${overlay({ responsive })} diff --git a/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx b/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx index e833b93a..bf9467a6 100644 --- a/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx +++ b/apps/web/src/features/EventAdmin/components/AddStaffModal.tsx @@ -3,17 +3,27 @@ import { Modal } from "@/components/ui/Modal"; import { TextField } from "@/components/ui/TextField"; import { useEventStaffUsers } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; import { useUsers } from "@/features/Users/hooks/useUsers"; -import { useCallback, useState } from "react"; +import { useContext, useMemo, useState } from "react"; import debounce from "lodash.debounce"; import { cn } from "@/utils/cn"; +import type { User } from "@/lib/openapi/types"; +import { useStaffActions } from "../hooks/useStaffActions"; +import { toast } from "react-toastify"; +import { OverlayTriggerStateContext } from "react-aria-components"; interface Props { eventId: string; } const AddStaffModal = ({ eventId }: Props) => { + const state = useContext(OverlayTriggerStateContext)!; + const [query, setQuery] = useState(""); - const [selectedUserIds, setSelectedUserIds] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + + const { + addMany: { mutate: addManyMutate, isPending: isAddPending }, + } = useStaffActions(eventId); const { data: users, @@ -26,28 +36,53 @@ const AddStaffModal = ({ eventId }: Props) => { isError: isStaffUsersError, } = useEventStaffUsers(eventId); - // Tanstack Query can be used here to fetch users based on the search term - - const handleSearch = useCallback( - debounce((value: string) => { - setQuery(value); - }, 500), + const handleSearch = useMemo( + () => + debounce((value: string) => { + setQuery(value); + }, 500), [], ); - const onSelectionChange = (userId: string) => { - setSelectedUserIds((prev) => { - if (prev.includes(userId)) { - return prev.filter((id) => id !== userId); + const onSelectionChange = (user: User) => { + setSelectedUsers((prev) => { + if (prev.some((u) => u.id === user.id)) { + return prev.filter((u) => u.id !== user.id); } else { - return [...prev, userId]; + return [...prev, user]; } }); }; const isAlreadyStaff = (userId: string) => eventStaff?.some((staff) => staff.id === userId); - const isSelected = (userId: string) => selectedUserIds.includes(userId); + + const isSelected = (userId: string) => + selectedUsers.some((u) => u.id === userId); + + const displayedUsers: User[] = [ + ...selectedUsers, + ...(users?.filter((u) => !selectedUsers.some((s) => s.id === u.id)) || []), + ]; + + const onAddStaff = async () => { + if (selectedUsers.length === 0) return; + + addManyMutate( + selectedUsers.map((u) => u.id), + { + onSuccess: () => { + state.close(); + setSelectedUsers([]); + setQuery(""); + toast.success("Staff members added successfully."); + }, + onError: () => { + toast.error("Failed to add staff members. Please try again."); + }, + }, + ); + }; return ( @@ -60,59 +95,61 @@ const AddStaffModal = ({ eventId }: Props) => { onChange={(e) => handleSearch(e)} /> - {query === "" ? ( -

Type to search for users

- ) : isUsersLoading || isStaffUsersLoading ? ( + {isUsersLoading || isStaffUsersLoading ? (

Loading...

) : isUsersError || isStaffUsersError ? (

Error loading users.

- ) : users && users.length === 0 ? ( -

No users found.

+ ) : displayedUsers.length === 0 ? ( +

No users found.

) : ( - users && ( -
- {users.map((user) => { - const alreadyStaff = isAlreadyStaff(user.id); - const selected = isSelected(user.id); - - return ( -
- !alreadyStaff && onSelectionChange(user.id) - } - className={cn( - "w-full bg-input-bg hover:cursor-pointer hover:bg-neutral-900 rounded-md p-4 flex flex-row items-center justify-between select-none", - selected - ? "border-1 border-green-700" - : alreadyStaff - ? "opacity-50 cursor-not-allowed hover:cursor-default hover:bg-input-bg" - : "border-1 border-transparent", - )} - > -
-

{user.name}

-

- {user.email} -

-
- - {alreadyStaff && ( -

- Already Staff -

- )} +
+ {displayedUsers.map((user) => { + const alreadyStaff = isAlreadyStaff(user.id); + const selected = isSelected(user.id); + + return ( +
+ !alreadyStaff && !isAddPending && onSelectionChange(user) + } + className={cn( + "w-full bg-input-bg hover:cursor-pointer hover:bg-neutral-900 rounded-md p-4 flex flex-row items-center justify-between select-none", + selected + ? "border-1 border-green-700" + : alreadyStaff + ? "opacity-50 cursor-not-allowed hover:cursor-default hover:bg-input-bg" + : "border-1 border-transparent", + )} + > +
+

{user.name}

+

+ {user.email} +

- ); - })} -
- ) + + {alreadyStaff && ( +

+ Already Staff +

+ )} +
+ ); + })} +
)}
- {selectedUserIds.length > 0 && ( - )}
diff --git a/apps/web/src/features/EventAdmin/components/DeleteStaffDialog.tsx b/apps/web/src/features/EventAdmin/components/DeleteStaffDialog.tsx new file mode 100644 index 00000000..a9df2d13 --- /dev/null +++ b/apps/web/src/features/EventAdmin/components/DeleteStaffDialog.tsx @@ -0,0 +1,69 @@ +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { + Group, + Heading, + OverlayTriggerStateContext, + Text, +} from "react-aria-components"; +import { useContext } from "react"; +import type { StaffUser } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; +import { useStaffActions } from "../hooks/useStaffActions"; +import { toast } from "react-toastify"; + +interface DeleteStaffDialogProps { + eventId: string; + user: StaffUser; +} + +function DeleteStaffDialog({ eventId, user }: DeleteStaffDialogProps) { + const state = useContext(OverlayTriggerStateContext)!; + const { + remove: { mutateAsync, isPending }, + } = useStaffActions(eventId); + + const handleDelete = async () => { + await mutateAsync(user.id, { + onSuccess: () => { + toast.success("Staff member removed successfully."); + state.close(); + }, + onError: () => { + toast.error("Failed to remove staff member. Please try again."); + }, + }); + }; + + return ( + +
+
+ + Remove {user.name} from event staff? + + + Are you sure you want to remove this user from the event staff? You + can always add them back later. + +
+
+ + + + + +
+
+
+ ); +} + +export { DeleteStaffDialog }; diff --git a/apps/web/src/features/EventAdmin/components/StaffTable.tsx b/apps/web/src/features/EventAdmin/components/StaffTable.tsx index debbf439..aadf55f1 100644 --- a/apps/web/src/features/EventAdmin/components/StaffTable.tsx +++ b/apps/web/src/features/EventAdmin/components/StaffTable.tsx @@ -12,85 +12,93 @@ import { import RoleBadge from "./RoleBadge"; import type { StaffUser } from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; import { TextField } from "@/components/ui/TextField"; +import { useMemo } from "react"; +import { DialogTrigger } from "react-aria-components"; +import { DeleteStaffDialog } from "./DeleteStaffDialog"; const fuzzyTextFilterFn: FilterFn = (row, columnId, value) => { const rowValue = row.getValue(columnId) as string; return rowValue.toLowerCase().includes((value as string).toLowerCase()); }; -const columns: ColumnDef[] = [ - { - id: "avatar", - header: "Avatar", - cell: ({ row }) => { - const avatarUrl = row.original.image; - - //TODO: Replace with actual avatar component - return avatarUrl ? ( - Avatar - ) : ( -
- N/A -
- ); - }, - }, - { - id: "name", - header: "Name", - cell: ({ row }) => { - const name = row.original.name; - return name ? name : "Unknown"; - }, - accessorKey: "name", - filterFn: fuzzyTextFilterFn, - }, - { - accessorKey: "email", - header: "Email", - filterFn: fuzzyTextFilterFn, - }, - { - accessorKey: "event_role", - header: "Role", - cell: ({ row }) => { - const role = row.original.event_role; - if (!role) return "N/A"; - - return ; - }, - enableColumnFilter: false, - }, - { - id: "actions", - header: "Actions", - cell: ({ row }) => { - const eventRole = row.original.event_role; - - return ( - - ); - }, - }, -]; - const fallbackData: StaffUser[] = []; interface Props { + eventId: string; data?: StaffUser[]; } -const StaffTable = ({ data }: Props) => { +const StaffTable = ({ data, eventId }: Props) => { + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "avatar", + header: "Avatar", + cell: ({ row }) => { + const avatarUrl = row.original.image; + return avatarUrl ? ( + Avatar + ) : ( +
+ N/A +
+ ); + }, + }, + { + id: "name", + header: "Name", + cell: ({ row }) => { + const name = row.original.name; + return name ? name : "Unknown"; + }, + accessorKey: "name", + filterFn: fuzzyTextFilterFn, + }, + { + accessorKey: "email", + header: "Email", + filterFn: fuzzyTextFilterFn, + }, + { + accessorKey: "event_role", + header: "Role", + cell: ({ row }) => { + const role = row.original.event_role; + if (!role) return "N/A"; + + return ; + }, + enableColumnFilter: false, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const eventRole = row.original.event_role; + + return ( + + + + + ); + }, + }, + ], + [], + ); + const table = useReactTable({ columns, data: data ?? fallbackData, diff --git a/apps/web/src/features/EventAdmin/hooks/useStaffActions.ts b/apps/web/src/features/EventAdmin/hooks/useStaffActions.ts new file mode 100644 index 00000000..f0eb94ff --- /dev/null +++ b/apps/web/src/features/EventAdmin/hooks/useStaffActions.ts @@ -0,0 +1,67 @@ +import { + getEventStaffUsersQueryKey, + type StaffUsers, +} from "@/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers"; +import { api } from "@/lib/ky"; +import type { components } from "@/lib/openapi/schema"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +type AddRoleFields = { + assignments: (Omit< + components["schemas"]["handlers.AssignRoleFields"], + "email" + > & { email?: string })[]; +}; + +async function addEventStaff(eventId: string, userIds: string[]) { + if (userIds.length === 0) return; + + const body: AddRoleFields = { + assignments: userIds.map((userId) => ({ + user_id: userId, + role: "staff", + })), + }; + + await api.post(`events/${eventId}/roles/batch`, { + json: body, + }); +} + +async function deleteEventStaff(eventId: string, userId: string) { + await api.delete(`events/${eventId}/roles/${userId}`); + + return userId; +} + +export function useStaffActions(eventId: string) { + const queryClient = useQueryClient(); + + const addMany = useMutation({ + mutationFn: (userIds: string[]) => addEventStaff(eventId, userIds), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getEventStaffUsersQueryKey(eventId), + }); + }, + }); + + const remove = useMutation({ + mutationFn: (userId: string) => deleteEventStaff(eventId, userId), + onSuccess: (userId) => { + queryClient.setQueryData( + getEventStaffUsersQueryKey(eventId), + (old) => { + if (!old) return old; + + return old.filter((user) => user.id !== userId); + }, + ); + }, + }); + + return { + addMany, + remove, + }; +} diff --git a/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts index 861466e7..f4db1641 100644 --- a/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts +++ b/apps/web/src/features/PlatformAdmin/EventManager/hooks/useEventStaffUsers.ts @@ -6,7 +6,7 @@ export function getEventStaffUsersQueryKey(eventId: string) { return ["event", eventId, "staff-users"] as const; } -type StaffUsers = +export type StaffUsers = paths["/events/{eventId}/staff"]["get"]["responses"]["200"]["content"]["application/json"]; export type StaffUser = StaffUsers[number]; diff --git a/apps/web/src/lib/openapi/schema.d.ts b/apps/web/src/lib/openapi/schema.d.ts index 27fd0d52..10132a14 100644 --- a/apps/web/src/lib/openapi/schema.d.ts +++ b/apps/web/src/lib/openapi/schema.d.ts @@ -870,6 +870,135 @@ export interface paths { patch?: never; trace?: never; }; + "/events/{eventId}/roles/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke event role of a user + * @description Remove user's role for a specific event + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Event ID */ + eventId: string; + /** @description User ID */ + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK - Role revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Not Found - User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Server Error: Something went terribly wrong on our end. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/events/{eventId}/roles/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Change or add event role of a user in batch + * @description Modify users' role for a specific event + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Event ID */ + eventId: string; + }; + cookie?: never; + }; + /** @description Event roles data */ + requestBody: { + content: { + "application/json": + | Record + | components["schemas"]["handlers.AssignRoleBatch"]; + }; + }; + responses: { + /** @description OK - Roles updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Not Found - User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + /** @description Server Error: Something went terribly wrong on our end. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["response.ErrorResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/events/{eventId}/staff": { parameters: { query?: never; @@ -1305,6 +1434,9 @@ export interface components { email: string; source: string; }; + "handlers.AssignRoleBatch": { + assignments: components["schemas"]["handlers.AssignRoleFields"][]; + }; "handlers.AssignRoleFields": { email: string; role: components["schemas"]["sqlc.EventRoleType"]; diff --git a/apps/web/src/lib/openapi/types.ts b/apps/web/src/lib/openapi/types.ts index fde73eeb..da691db8 100644 --- a/apps/web/src/lib/openapi/types.ts +++ b/apps/web/src/lib/openapi/types.ts @@ -6,7 +6,7 @@ export type PlatformRole = components["schemas"]["sqlc.AuthUserRole"]; export type Event = components["schemas"]["sqlc.Event"]; export type CreateEvent = paths["/events"]["post"]["requestBody"]["content"]["application/json"]; -export type User = components["schemas"]["middleware.UserContext"]; +export type User = components["schemas"]["sqlc.AuthUser"]; //TODO: Remove extension once OpenAPI is updated export type EventWithUserInfo = diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx index c7ddd27e..833fd384 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_admin/staff-management.tsx @@ -54,7 +54,7 @@ function RouteComponent() { )} - + ); } From fe2e25374be871a6e11aef479d313cc9c6025dcf Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Wed, 1 Oct 2025 23:42:47 -0400 Subject: [PATCH 6/6] chore: use memo deps --- apps/web/src/features/EventAdmin/components/StaffTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/features/EventAdmin/components/StaffTable.tsx b/apps/web/src/features/EventAdmin/components/StaffTable.tsx index aadf55f1..ccaa105a 100644 --- a/apps/web/src/features/EventAdmin/components/StaffTable.tsx +++ b/apps/web/src/features/EventAdmin/components/StaffTable.tsx @@ -96,7 +96,7 @@ const StaffTable = ({ data, eventId }: Props) => { }, }, ], - [], + [eventId], ); const table = useReactTable({