diff --git a/apps/api/go.sum b/apps/api/go.sum index 75898a9e..08bbbd99 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -56,8 +56,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= -github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 8a94202e..f41cadb9 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -76,7 +76,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // Event routes api.Router.Route("/events", func(r chi.Router) { r.With(mw.Auth.RequireAuth, ensureSuperuser).Post("/", api.Handlers.Event.CreateEvent) - r.With(mw.Auth.RequireAuth).Get("/", api.Handlers.Event.GetAllEvents) + r.With(mw.Auth.RequireAuth).Get("/", api.Handlers.Event.GetEvents) r.Route("/{eventId}", func(r chi.Router) { r.With(mw.Auth.RequireAuth, ensureEventAdmin).Patch("/", api.Handlers.Event.UpdateEventById) r.With(mw.Auth.RequireAuth, ensureSuperuser).Delete("/", api.Handlers.Event.DeleteEventById) diff --git a/apps/api/internal/api/handlers/events.go b/apps/api/internal/api/handlers/events.go index 4affe84c..4ed16c17 100644 --- a/apps/api/internal/api/handlers/events.go +++ b/apps/api/internal/api/handlers/events.go @@ -17,7 +17,9 @@ import ( "github.com/swamphacks/core/apps/api/internal/db/sqlc" "github.com/swamphacks/core/apps/api/internal/email" "github.com/swamphacks/core/apps/api/internal/parse" + "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 EventHandler struct { @@ -220,13 +222,31 @@ func (h *EventHandler) DeleteEventById(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -func (h *EventHandler) GetAllEvents(w http.ResponseWriter, r *http.Request) { - events, err := h.eventService.GetAllEvents(r.Context()) +func (h *EventHandler) GetEvents(w http.ResponseWriter, r *http.Request) { + // Parse query params + // include_unpublished="true,false", default: false + queryParams := r.URL.Query() + includeUnpublished, err := web.ParseParamBoolean(queryParams, "include_unpublished", ptr.BoolToPtr(false)) if err != nil { - res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong fetching events")) + res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameters: include_unpublished=true,false")) return } + events, err := h.eventService.GetEvents(r.Context(), *includeUnpublished) + if err == services.ErrMissingFields { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameteres: status=all,published")) + return + } + + if err == services.ErrMissingPerms { + res.SendError(w, http.StatusForbidden, res.NewError("forbidden", "You are forbidden from this resource.")) + return + } + + if err != nil { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong encoding response")) + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(events); err != nil { diff --git a/apps/api/internal/ctxutils/user.go b/apps/api/internal/ctxutils/user.go new file mode 100644 index 00000000..d5e86ca8 --- /dev/null +++ b/apps/api/internal/ctxutils/user.go @@ -0,0 +1,39 @@ +package ctxutils + +import ( + "context" + + "github.com/google/uuid" + mw "github.com/swamphacks/core/apps/api/internal/api/middleware" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +// Takes in a context and returns whether the user has the superuser role +func IsSuperuser(ctx context.Context) bool { + userCtx, ok := ctx.Value(mw.UserContextKey).(*mw.UserContext) + if !ok { + return false + } + + return userCtx.Role == sqlc.AuthUserRoleSuperuser +} + +// Takes in a context and returns whether the user has the user role +func IsUser(ctx context.Context) bool { + userCtx, ok := ctx.Value(mw.UserContextKey).(*mw.UserContext) + if !ok { + return false + } + + return userCtx.Role == sqlc.AuthUserRoleUser +} + +// Takes in the request context and returns the userID or nil if not retrievable +func GetUserIdFromCtx(ctx context.Context) *uuid.UUID { + userCtx, ok := ctx.Value(mw.UserContextKey).(*mw.UserContext) + if !ok { + return nil + } + + return &userCtx.UserID +} diff --git a/apps/api/internal/db/queries/events.sql b/apps/api/internal/db/queries/events.sql index 1fb46303..5cd95330 100644 --- a/apps/api/internal/db/queries/events.sql +++ b/apps/api/internal/db/queries/events.sql @@ -56,8 +56,37 @@ SELECT * FROM event_roles WHERE user_id = @user_id::uuid AND event_id = @event_id::uuid; -- name: GetPublishedEvents :many -SELECT * FROM events -WHERE is_published = TRUE; +SELECT + e.*, + er.role AS event_role +FROM events e +LEFT JOIN event_roles AS er + ON er.event_id = e.id + AND er.user_id = $1 +WHERE e.is_published = TRUE +ORDER BY e.start_time ASC; -- name: GetAllEvents :many -SELECT * FROM events; +SELECT + e.*, + er.role AS event_role +FROM events e +LEFT JOIN event_roles AS er + ON er.event_id = e.id + AND er.user_id = $1 +ORDER BY e.start_time ASC; + +-- name: GetEventsWithUserInfo :many +SELECT + e.*, + er.role AS event_role, + a.status AS application_status +FROM events e +LEFT JOIN event_roles er + ON er.event_id = e.id + AND er.user_id = sqlc.narg(user_id) +LEFT JOIN applications a + ON a.event_id = e.id + AND a.user_id = sqlc.narg(user_id) +WHERE (sqlc.arg(include_unpublished)::boolean IS TRUE OR e.is_published = TRUE) +ORDER BY e.start_time ASC; diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go index ca98f47e..cb544035 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -74,13 +74,21 @@ func (r *EventRepository) DeleteEventById(ctx context.Context, id uuid.UUID) err return err } -func (r *EventRepository) GetAllEvents(ctx context.Context) (*[]sqlc.Event, error) { - events, err := r.db.Query.GetAllEvents(ctx) +func (r *EventRepository) GetAllEvents(ctx context.Context, userId uuid.UUID) (*[]sqlc.GetAllEventsRow, error) { + events, err := r.db.Query.GetAllEvents(ctx, userId) return &events, err } -func (r *EventRepository) GetPublishedEvents(ctx context.Context) (*[]sqlc.Event, error) { - events, err := r.db.Query.GetPublishedEvents(ctx) +func (r *EventRepository) GetPublishedEvents(ctx context.Context, userId uuid.UUID) (*[]sqlc.GetPublishedEventsRow, error) { + events, err := r.db.Query.GetPublishedEvents(ctx, userId) + return &events, err +} + +func (r *EventRepository) GetEventsWithRoles(ctx context.Context, userId *uuid.UUID, includeUnpublished bool) (*[]sqlc.GetEventsWithUserInfoRow, error) { + events, err := r.db.Query.GetEventsWithUserInfo(ctx, sqlc.GetEventsWithUserInfoParams{ + UserID: userId, + IncludeUnpublished: includeUnpublished, + }) return &events, err } diff --git a/apps/api/internal/db/sqlc/events.sql.go b/apps/api/internal/db/sqlc/events.sql.go index 75230692..dc33caf0 100644 --- a/apps/api/internal/db/sqlc/events.sql.go +++ b/apps/api/internal/db/sqlc/events.sql.go @@ -106,18 +106,45 @@ func (q *Queries) DeleteEventById(ctx context.Context, id uuid.UUID) (int64, err } const getAllEvents = `-- name: GetAllEvents :many -SELECT id, name, description, location, location_url, max_attendees, application_open, application_close, rsvp_deadline, decision_release, start_time, end_time, website_url, is_published, created_at, updated_at FROM events +SELECT + e.id, e.name, e.description, e.location, e.location_url, e.max_attendees, e.application_open, e.application_close, e.rsvp_deadline, e.decision_release, e.start_time, e.end_time, e.website_url, e.is_published, e.created_at, e.updated_at, + er.role AS event_role +FROM events e +LEFT JOIN event_roles AS er + ON er.event_id = e.id + AND er.user_id = $1 +ORDER BY e.start_time ASC ` -func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { - rows, err := q.db.Query(ctx, getAllEvents) +type GetAllEventsRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Location *string `json:"location"` + LocationUrl *string `json:"location_url"` + MaxAttendees *int32 `json:"max_attendees"` + ApplicationOpen time.Time `json:"application_open"` + ApplicationClose time.Time `json:"application_close"` + RsvpDeadline *time.Time `json:"rsvp_deadline"` + DecisionRelease *time.Time `json:"decision_release"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + WebsiteUrl *string `json:"website_url"` + IsPublished *bool `json:"is_published"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + EventRole NullEventRoleType `json:"event_role"` +} + +func (q *Queries) GetAllEvents(ctx context.Context, userID uuid.UUID) ([]GetAllEventsRow, error) { + rows, err := q.db.Query(ctx, getAllEvents, userID) if err != nil { return nil, err } defer rows.Close() - items := []Event{} + items := []GetAllEventsRow{} for rows.Next() { - var i Event + var i GetAllEventsRow if err := rows.Scan( &i.ID, &i.Name, @@ -135,6 +162,7 @@ func (q *Queries) GetAllEvents(ctx context.Context) ([]Event, error) { &i.IsPublished, &i.CreatedAt, &i.UpdatedAt, + &i.EventRole, ); err != nil { return nil, err } @@ -197,20 +225,128 @@ func (q *Queries) GetEventRoleByIds(ctx context.Context, arg GetEventRoleByIdsPa return i, err } +const getEventsWithUserInfo = `-- name: GetEventsWithUserInfo :many +SELECT + e.id, e.name, e.description, e.location, e.location_url, e.max_attendees, e.application_open, e.application_close, e.rsvp_deadline, e.decision_release, e.start_time, e.end_time, e.website_url, e.is_published, e.created_at, e.updated_at, + er.role AS event_role, + a.status AS application_status +FROM events e +LEFT JOIN event_roles er + ON er.event_id = e.id + AND er.user_id = $1 +LEFT JOIN applications a + ON a.event_id = e.id + AND a.user_id = $1 +WHERE ($2::boolean IS TRUE OR e.is_published = TRUE) +ORDER BY e.start_time ASC +` + +type GetEventsWithUserInfoParams struct { + UserID *uuid.UUID `json:"user_id"` + IncludeUnpublished bool `json:"include_unpublished"` +} + +type GetEventsWithUserInfoRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Location *string `json:"location"` + LocationUrl *string `json:"location_url"` + MaxAttendees *int32 `json:"max_attendees"` + ApplicationOpen time.Time `json:"application_open"` + ApplicationClose time.Time `json:"application_close"` + RsvpDeadline *time.Time `json:"rsvp_deadline"` + DecisionRelease *time.Time `json:"decision_release"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + WebsiteUrl *string `json:"website_url"` + IsPublished *bool `json:"is_published"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + EventRole NullEventRoleType `json:"event_role"` + ApplicationStatus NullApplicationStatus `json:"application_status"` +} + +func (q *Queries) GetEventsWithUserInfo(ctx context.Context, arg GetEventsWithUserInfoParams) ([]GetEventsWithUserInfoRow, error) { + rows, err := q.db.Query(ctx, getEventsWithUserInfo, arg.UserID, arg.IncludeUnpublished) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetEventsWithUserInfoRow{} + for rows.Next() { + var i GetEventsWithUserInfoRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Location, + &i.LocationUrl, + &i.MaxAttendees, + &i.ApplicationOpen, + &i.ApplicationClose, + &i.RsvpDeadline, + &i.DecisionRelease, + &i.StartTime, + &i.EndTime, + &i.WebsiteUrl, + &i.IsPublished, + &i.CreatedAt, + &i.UpdatedAt, + &i.EventRole, + &i.ApplicationStatus, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getPublishedEvents = `-- name: GetPublishedEvents :many -SELECT id, name, description, location, location_url, max_attendees, application_open, application_close, rsvp_deadline, decision_release, start_time, end_time, website_url, is_published, created_at, updated_at FROM events -WHERE is_published = TRUE +SELECT + e.id, e.name, e.description, e.location, e.location_url, e.max_attendees, e.application_open, e.application_close, e.rsvp_deadline, e.decision_release, e.start_time, e.end_time, e.website_url, e.is_published, e.created_at, e.updated_at, + er.role AS event_role +FROM events e +LEFT JOIN event_roles AS er + ON er.event_id = e.id + AND er.user_id = $1 +WHERE e.is_published = TRUE +ORDER BY e.start_time ASC ` -func (q *Queries) GetPublishedEvents(ctx context.Context) ([]Event, error) { - rows, err := q.db.Query(ctx, getPublishedEvents) +type GetPublishedEventsRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Location *string `json:"location"` + LocationUrl *string `json:"location_url"` + MaxAttendees *int32 `json:"max_attendees"` + ApplicationOpen time.Time `json:"application_open"` + ApplicationClose time.Time `json:"application_close"` + RsvpDeadline *time.Time `json:"rsvp_deadline"` + DecisionRelease *time.Time `json:"decision_release"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + WebsiteUrl *string `json:"website_url"` + IsPublished *bool `json:"is_published"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + EventRole NullEventRoleType `json:"event_role"` +} + +func (q *Queries) GetPublishedEvents(ctx context.Context, userID uuid.UUID) ([]GetPublishedEventsRow, error) { + rows, err := q.db.Query(ctx, getPublishedEvents, userID) if err != nil { return nil, err } defer rows.Close() - items := []Event{} + items := []GetPublishedEventsRow{} for rows.Next() { - var i Event + var i GetPublishedEventsRow if err := rows.Scan( &i.ID, &i.Name, @@ -228,6 +364,7 @@ func (q *Queries) GetPublishedEvents(ctx context.Context) ([]Event, error) { &i.IsPublished, &i.CreatedAt, &i.UpdatedAt, + &i.EventRole, ); err != nil { return nil, err } diff --git a/apps/api/internal/db/sqlc/querier.go b/apps/api/internal/db/sqlc/querier.go index 44d8d9ff..e7806f69 100644 --- a/apps/api/internal/db/sqlc/querier.go +++ b/apps/api/internal/db/sqlc/querier.go @@ -28,14 +28,15 @@ type Querier interface { DeleteExpiredSession(ctx context.Context) error DeleteUser(ctx context.Context, id uuid.UUID) error GetActiveSessionUserInfo(ctx context.Context, id uuid.UUID) (GetActiveSessionUserInfoRow, error) - GetAllEvents(ctx context.Context) ([]Event, error) + GetAllEvents(ctx context.Context, userID uuid.UUID) ([]GetAllEventsRow, error) GetApplicationByUserAndEventID(ctx context.Context, arg GetApplicationByUserAndEventIDParams) (Application, error) GetByProviderAndAccountID(ctx context.Context, arg GetByProviderAndAccountIDParams) (AuthAccount, error) GetByUserID(ctx context.Context, userID uuid.UUID) ([]AuthAccount, error) GetEventByID(ctx context.Context, id uuid.UUID) (Event, error) GetEventRoleByIds(ctx context.Context, arg GetEventRoleByIdsParams) (EventRole, error) GetEventStaff(ctx context.Context, eventID uuid.UUID) ([]GetEventStaffRow, error) - GetPublishedEvents(ctx context.Context) ([]Event, error) + GetEventsWithUserInfo(ctx context.Context, arg GetEventsWithUserInfoParams) ([]GetEventsWithUserInfoRow, error) + GetPublishedEvents(ctx context.Context, userID uuid.UUID) ([]GetPublishedEventsRow, error) GetSessionByID(ctx context.Context, id uuid.UUID) (AuthSession, error) GetSessionsByUserID(ctx context.Context, userID uuid.UUID) ([]AuthSession, error) GetUserByEmail(ctx context.Context, email *string) (AuthUser, error) diff --git a/apps/api/internal/ptr/bool.go b/apps/api/internal/ptr/bool.go new file mode 100644 index 00000000..959dd4fe --- /dev/null +++ b/apps/api/internal/ptr/bool.go @@ -0,0 +1,6 @@ +package ptr + +// Takes a boolean and returns a pointer to that boolean +func BoolToPtr(b bool) *bool { + return &b +} diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index 2d05a21a..f174329d 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -6,7 +6,7 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog" - "github.com/swamphacks/core/apps/api/internal/api/middleware" + ctxu "github.com/swamphacks/core/apps/api/internal/ctxutils" "github.com/swamphacks/core/apps/api/internal/db/repository" "github.com/swamphacks/core/apps/api/internal/db/sqlc" ) @@ -18,6 +18,7 @@ var ( ErrFailedToDeleteEvent = errors.New("failed to delete event") ErrFailedToParseUUID = errors.New("failed to parse uuid") ErrMissingFields = errors.New("missing fields") + ErrMissingPerms = errors.New("missing perms") ) type EventService struct { @@ -97,20 +98,16 @@ func (s *EventService) DeleteEventById(ctx context.Context, id uuid.UUID) error return err } -func (s *EventService) GetAllEvents(ctx context.Context) (*[]sqlc.Event, error) { - // Check role, if role is none or user, return published events only - userCtx, ok := ctx.Value(middleware.UserContextKey).(*middleware.UserContext) - if !ok { - s.logger.Warn().Msg("Couldn't get user context") - return s.eventRepo.GetPublishedEvents(ctx) - } +func (s *EventService) GetEvents(ctx context.Context, includeUnpublished bool) (*[]sqlc.GetEventsWithUserInfoRow, error) { + isSuperuser := ctxu.IsSuperuser(ctx) + userId := ctxu.GetUserIdFromCtx(ctx) - //TODO: Replace with switch later - if userCtx.Role == sqlc.AuthUserRoleUser { - return s.eventRepo.GetPublishedEvents(ctx) + // Non-superusers can't get unpublished events + if !isSuperuser && includeUnpublished { + return nil, ErrMissingPerms } - return s.eventRepo.GetAllEvents(ctx) + return s.eventRepo.GetEventsWithRoles(ctx, userId, includeUnpublished) } diff --git a/apps/api/internal/web/query.go b/apps/api/internal/web/query.go new file mode 100644 index 00000000..e7afd0cd --- /dev/null +++ b/apps/api/internal/web/query.go @@ -0,0 +1,36 @@ +package web + +import ( + "errors" + "net/url" + "strconv" +) + +var ( + ErrMalformedField = errors.New("malformed field") +) + +// ParseParamBoolean parses a boolean query parameter from the given url.Values. +// If the parameter is missing or cannot be parsed as a boolean, it returns defaultVal. +// +// Parameters: +// - queryParams: the URL query parameters to read from +// - key: the name of the query parameter +// - defaultVal: pointer to the default boolean value to use if parsing fails or the parameter is absent +// +// Returns: +// - pointer to the parsed boolean value, or defaultVal if missing or invalid +// - error if field was malformed/not boolean +func ParseParamBoolean(queryParams url.Values, key string, defaultVal *bool) (*bool, error) { + v := queryParams.Get(key) + if v == "" { + return defaultVal, nil + } + + parsed, err := strconv.ParseBool(v) + if err != nil { + return nil, ErrMalformedField + } + + return &parsed, nil +} diff --git a/apps/api/sqlc.yml b/apps/api/sqlc.yml index 5dc30e91..b34bff57 100644 --- a/apps/api/sqlc.yml +++ b/apps/api/sqlc.yml @@ -26,5 +26,8 @@ sql: nullable: true go_type: type: "*time.Time" + - db_type: uuid + nullable: true + go_type: "*github.com/google/uuid.UUID" diff --git a/apps/web/package.json b/apps/web/package.json index cfccd886..b0ea75a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@tanstack/react-table": "^8.21.3", "axios": "^1.9.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "js-cookie": "^3.0.5", "ky": "^1.8.1", "nanoid": "^5.1.5", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 78733839..0699ca39 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -2574,6 +2577,9 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -7377,6 +7383,8 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + date-fns@4.1.0: {} + debug@4.4.1(supports-color@10.0.0): dependencies: ms: 2.1.3 diff --git a/apps/web/src/features/Admin/EventManager/hooks/useAdminEvents.ts b/apps/web/src/features/Admin/EventManager/hooks/useAdminEvents.ts index cacf8baf..707b0946 100644 --- a/apps/web/src/features/Admin/EventManager/hooks/useAdminEvents.ts +++ b/apps/web/src/features/Admin/EventManager/hooks/useAdminEvents.ts @@ -8,7 +8,9 @@ type Events = operations["get-events"]["responses"]["200"]["content"]["application/json"]; export async function fetchEvents(): Promise { - const result = await api.get("events").json(); + const result = await api + .get("events?include_unpublished=true") + .json(); console.log(result); return result; } diff --git a/apps/web/src/features/Event/components/EventButton.tsx b/apps/web/src/features/Event/components/EventButton.tsx index 3a29278b..172f42de 100644 --- a/apps/web/src/features/Event/components/EventButton.tsx +++ b/apps/web/src/features/Event/components/EventButton.tsx @@ -3,6 +3,7 @@ import { tv } from "tailwind-variants"; import applicationStatus from "../applicationStatus"; import { Button, button, type ButtonProps } from "@/components/ui/Button"; import { cn } from "@/utils/cn"; +import { useRouter } from "@tanstack/react-router"; type ApplicationStatusTypes = keyof typeof applicationStatus; @@ -25,11 +26,13 @@ export const eventButton = tv({ interface EventButtonProps extends ButtonProps { status: ApplicationStatusTypes; + eventId: string; text?: string; } const EventButton = ({ status: statusProp, + eventId, className, text, }: EventButtonProps) => { @@ -38,11 +41,64 @@ const EventButton = ({ variant: "skeleton", className, }); + const router = useRouter(); + + const onClick = () => { + switch (statusProp) { + case "accepted": + // Make API call and then navigate to dashboard with accepted query param + console.log("Accepted button clicked for event"); + router.navigate({ + to: `/events/${eventId}/dashboard`, + }); + break; + case "attending": + case "staff": + case "admin": + case "underReview": + // Navigate to the dashboard + router.navigate({ + to: `/events/${eventId}/dashboard`, + }); + break; + case "waitlisted": + // Navigate to the waitlist page + router.navigate({ + to: `/events/${eventId}/waitlist/info`, + }); + break; + case "rejected": + // Navigate to the event info page + router.navigate({ + to: `/events/${eventId}/rejected`, + }); + break; + case "notApplied": + // Navigate to the application page + router.navigate({ + to: `/events/${eventId}/application`, + }); + break; + case "notGoing": + // Navigate to the event info page + router.navigate({ + to: `/events/${eventId}/feedback/decline`, + }); + break; + case "completed": + // Navigate to the event info page + router.navigate({ + to: `/events/${eventId}/summary`, + }); + break; + } + }; return ( diff --git a/apps/web/src/features/Event/components/EventCard.tsx b/apps/web/src/features/Event/components/EventCard.tsx index 002dc47d..6a94b29a 100644 --- a/apps/web/src/features/Event/components/EventCard.tsx +++ b/apps/web/src/features/Event/components/EventCard.tsx @@ -10,7 +10,7 @@ import type applicationStatus from "../applicationStatus"; import { Separator } from "@/components/ui/Seperator"; import { Card } from "@/components/ui/Card"; -interface EventCardProps { +export interface EventCardProps { eventId: string; status: keyof typeof applicationStatus; title: string; @@ -70,15 +70,24 @@ const EventCard = ({ {status === "accepted" ? (
- +
) : ( - + )} diff --git a/apps/web/src/features/Event/hooks/useEventsWithUserInfo.ts b/apps/web/src/features/Event/hooks/useEventsWithUserInfo.ts new file mode 100644 index 00000000..ae7f939b --- /dev/null +++ b/apps/web/src/features/Event/hooks/useEventsWithUserInfo.ts @@ -0,0 +1,25 @@ +import { api } from "@/lib/ky"; +import type { operations } from "@/lib/openapi/schema"; +import { useQuery } from "@tanstack/react-query"; +import { mapEventsAPIResponseToEventCardProps } from "../utils/mapper"; + +export const eventsQueryKey = ["events", "published"] as const; + +export type EventsWithUserInfo = + operations["get-events"]["responses"]["200"]["content"]["application/json"]; + +export async function fetchEvents(): Promise { + const result = await api + .get("events?include_unpublished=false") + .json(); + return result; +} + +export function useEventsWithUserInfo() { + return useQuery({ + queryKey: eventsQueryKey, + queryFn: fetchEvents, + staleTime: 1000 * 60 * 5, // 5 minutes, + select: (data) => data.map(mapEventsAPIResponseToEventCardProps), + }); +} diff --git a/apps/web/src/features/Event/utils/mapper.ts b/apps/web/src/features/Event/utils/mapper.ts new file mode 100644 index 00000000..5aec83e9 --- /dev/null +++ b/apps/web/src/features/Event/utils/mapper.ts @@ -0,0 +1,91 @@ +// Mapping event api reponse to usable structure for event card + +import type { EventWithUserInfo } from "@/lib/openapi/types"; +import type applicationStatus from "../applicationStatus"; +import type { EventCardProps } from "../components/EventCard"; +import { format } from "date-fns"; + +function formatDateRange(start: Date, end: Date): string { + const startDay = format(start, "d"); + const endDay = format(end, "do"); // adds "st", "nd", "rd", "th" + const month = format(start, "MMM"); + + // If start and end are in the same month + if (format(start, "MMM") === format(end, "MMM")) { + return `${month} ${startDay}-${endDay}`; + } + + // Different months + const startMonth = format(start, "MMM"); + const endMonth = format(end, "MMM"); + return `${startMonth} ${startDay} - ${endMonth} ${endDay}`; +} + +export function mapEventsAPIResponseToEventCardProps( + data: EventWithUserInfo, +): EventCardProps { + let status: keyof typeof applicationStatus = "notApplied"; // Default + + if (!data.application_status) { + return { + eventId: data.id, + status, + title: data.name, + description: data.description ?? "No description", + date: formatDateRange(new Date(data.start_time), new Date(data.end_time)), + location: data.location ?? "Unknown", + }; + } + + // Handle roles cases + if (data.event_role?.event_role_type === "staff") { + status = "staff"; + } else if (data.event_role?.event_role_type === "admin") { + status = "admin"; + } else if (data.event_role?.event_role_type === "attendee") { + status = "attending"; + } else if (data.event_role?.event_role_type === "applicant") { + // Handle application cases + switch (data.application_status.application_status) { + case "accepted": + status = "accepted"; + break; + case "rejected": + status = "rejected"; + break; + case "waitlisted": + status = "waitlisted"; + break; + case "submitted": + case "under_review": + status = "underReview"; + break; + case "started": + status = "notApplied"; + break; + case "withdrawn": + status = "notGoing"; + break; + default: + status = "notApplied"; // Default case + break; + } + } else { + // If no specific role or application status, default to notApplied + status = "notApplied"; + } + + // Check if the event is completed + if (new Date(data.end_time) < new Date()) { + status = "completed"; + } + + return { + eventId: data.id, + status, + title: data.name, + description: data.description ?? "No description", + date: formatDateRange(new Date(data.start_time), new Date(data.end_time)), + location: data.location ?? "Unknown", + }; +} diff --git a/apps/web/src/lib/openapi/schema.d.ts b/apps/web/src/lib/openapi/schema.d.ts index 06061006..7b32906b 100644 --- a/apps/web/src/lib/openapi/schema.d.ts +++ b/apps/web/src/lib/openapi/schema.d.ts @@ -69,8 +69,8 @@ export interface paths { cookie?: never; }; /** - * Get all events - * @description Gets only published events for normal users, and all events for superusers + * Get events + * @description Gets events with a nullable event role for authenticated users. */ get: operations["get-events"]; put?: never; @@ -263,6 +263,28 @@ export interface components { * @enum {string} */ EventRole: "admin" | "staff" | "attendee" | "applicant"; + /** + * @example under_review + * @enum {string} + */ + ApplicationStatus: + | "started" + | "submitted" + | "under_review" + | "accepted" + | "rejected" + | "waitlisted" + | "withdrawn"; + EventWithUserInfo: components["schemas"]["Event"] & { + event_role?: { + event_role_type: components["schemas"]["EventRole"]; + valid: boolean; + }; + application_status?: { + application_status: components["schemas"]["ApplicationStatus"]; + valid: boolean; + }; + }; }; responses: { /** @description Unauthenticated: Requester is not currently authenticated. */ @@ -464,7 +486,10 @@ export interface operations { }; "get-events": { parameters: { - query?: never; + query?: { + /** @description If true, include unpublished events as well. Superusers ONLY. */ + include_published?: boolean; + }; header?: never; path?: never; cookie?: never; @@ -477,7 +502,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Event"][]; + "application/json": components["schemas"]["EventWithUserInfo"][]; }; }; }; diff --git a/apps/web/src/lib/openapi/types.ts b/apps/web/src/lib/openapi/types.ts index 5a508362..bbe30144 100644 --- a/apps/web/src/lib/openapi/types.ts +++ b/apps/web/src/lib/openapi/types.ts @@ -8,3 +8,4 @@ export type Event = components["schemas"]["Event"]; export type CreateEvent = operations["post-event"]["requestBody"]["content"]["application/json"]; export type User = components["schemas"]["User"]; +export type EventWithUserInfo = components["schemas"]["EventWithUserInfo"]; diff --git a/apps/web/src/routes/_main/portal.tsx b/apps/web/src/routes/_main/portal.tsx index 6e7e9725..e88df869 100644 --- a/apps/web/src/routes/_main/portal.tsx +++ b/apps/web/src/routes/_main/portal.tsx @@ -1,27 +1,81 @@ -import { Button } from "@/components/ui/Button"; -import { auth } from "@/lib/authClient"; +import { EventCard } from "@/features/Event/components/EventCard"; +import { useEventsWithUserInfo } from "@/features/Event/hooks/useEventsWithUserInfo"; import { createFileRoute } from "@tanstack/react-router"; +import { Heading, Text } from "react-aria-components"; export const Route = createFileRoute("/_main/portal")({ component: RouteComponent, }); function RouteComponent() { - const logout = async () => { - try { - await auth.logOut(); - window.location.href = "/"; - } catch (error) { - console.error("Error during logout:", error); - } - }; + const { user } = Route.useRouteContext(); + const { data, isLoading, isError } = useEventsWithUserInfo(); + + if (isLoading) { + return ( +
+
+ + Welcome, {user?.name ?? "hacker"}! + +

+ Ready to start hacking? +

+
+ +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + if (isError || data === undefined) { + return ( +
+
+ + Welcome, {user?.name ?? "hacker"}! + +

+ Ready to start hacking? +

+
+ +
+ + Whoops, something went wrong, please refresh and try again! + +
+
+ ); + } return ( -
-

Event Portal

- +
+
+ + Welcome, {user?.name ?? "hacker"}! + +

Ready to start hacking?

+
+ +
+ {data.map((event) => ( + + ))} + + {data.length === 0 && ( + + Awww such empty. Please check back later for events! + + )} +
); } diff --git a/apps/web/src/routes/events/$eventId/dashboard/index.tsx b/apps/web/src/routes/events/$eventId/dashboard/index.tsx new file mode 100644 index 00000000..b72eb762 --- /dev/null +++ b/apps/web/src/routes/events/$eventId/dashboard/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/events/$eventId/dashboard/")({ + component: RouteComponent, +}); + +function RouteComponent() { + return
Hello "/events/$eventId/dashboard/"!
; +} diff --git a/apps/web/src/routes/events/$eventId/feedback/declined.tsx b/apps/web/src/routes/events/$eventId/feedback/declined.tsx new file mode 100644 index 00000000..5002b26f --- /dev/null +++ b/apps/web/src/routes/events/$eventId/feedback/declined.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/events/$eventId/feedback/declined")({ + component: RouteComponent, +}); + +function RouteComponent() { + return
Hello "/events/$eventId/feedback/declined"!
; +} diff --git a/apps/web/src/routes/events/$eventId/rejected.tsx b/apps/web/src/routes/events/$eventId/rejected.tsx new file mode 100644 index 00000000..07a3811b --- /dev/null +++ b/apps/web/src/routes/events/$eventId/rejected.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/events/$eventId/rejected")({ + component: RouteComponent, +}); + +function RouteComponent() { + return
Hello "/events/$eventId/rejected"!
; +} diff --git a/apps/web/src/routes/events/$eventId/summary.tsx b/apps/web/src/routes/events/$eventId/summary.tsx new file mode 100644 index 00000000..f0d56f22 --- /dev/null +++ b/apps/web/src/routes/events/$eventId/summary.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/events/$eventId/summary")({ + component: RouteComponent, +}); + +function RouteComponent() { + return
Hello "/events/$eventId/summary"!
; +} diff --git a/apps/web/src/routes/events/$eventId/waitlist/info.tsx b/apps/web/src/routes/events/$eventId/waitlist/info.tsx new file mode 100644 index 00000000..649cd013 --- /dev/null +++ b/apps/web/src/routes/events/$eventId/waitlist/info.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/events/$eventId/waitlist/info")({ + component: RouteComponent, +}); + +function RouteComponent() { + return
Hello "/events/$eventId/waitlist/info"!
; +} diff --git a/shared/openapi/core-api.yaml b/shared/openapi/core-api.yaml index 70600b5f..8ae1dd1f 100644 --- a/shared/openapi/core-api.yaml +++ b/shared/openapi/core-api.yaml @@ -233,8 +233,16 @@ paths: - Event /events: get: - summary: Get all events - description: Gets only published events for normal users, and all events for superusers + summary: Get events + description: Gets events with a nullable event role for authenticated users. + parameters: + - name: include_published + in: query + description: If true, include unpublished events as well. Superusers ONLY. + required: false + schema: + type: boolean + default: false responses: '200': description: 'OK: Events returned' @@ -243,7 +251,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/Event' + $ref: '#/components/schemas/EventWithUserInfo' operationId: get-events tags: @@ -703,6 +711,42 @@ components: - attendee - applicant example: attendee + ApplicationStatus: + type: string + enum: + - started + - submitted + - under_review + - accepted + - rejected + - waitlisted + - withdrawn + example: under_review + EventWithUserInfo: + allOf: + - $ref: '#/components/schemas/Event' + - type: object + properties: + event_role: + type: object + properties: + event_role_type: + $ref: '#/components/schemas/EventRole' + valid: + type: boolean + required: + - event_role_type + - valid + application_status: + type: object + properties: + application_status: + $ref: '#/components/schemas/ApplicationStatus' + valid: + type: boolean + required: + - application_status + - valid securitySchemes: sh_session_id: type: http