diff --git a/apps/api/docs/docs.go b/apps/api/docs/docs.go index b62b3c7d..6b9e9806 100644 --- a/apps/api/docs/docs.go +++ b/apps/api/docs/docs.go @@ -913,12 +913,12 @@ const docTemplate = `{ "description": "Gets events with a nullable event role for authenticated users.", "parameters": [ { - "description": "If true, include unpublished events as well. Superusers ONLY.", + "description": "Can be scoped to either published, scoped, or all. Scoped means admins and staff can see unpublished events", "in": "query", - "name": "include_published", + "name": "scope", "schema": { - "default": false, - "type": "boolean" + "default": "\"published\"", + "type": "string" } } ], diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 4b8c645f..d4a758aa 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -906,12 +906,12 @@ "description": "Gets events with a nullable event role for authenticated users.", "parameters": [ { - "description": "If true, include unpublished events as well. Superusers ONLY.", + "description": "Can be scoped to either published, scoped, or all. Scoped means admins and staff can see unpublished events", "in": "query", - "name": "include_published", + "name": "scope", "schema": { - "default": false, - "type": "boolean" + "default": "\"published\"", + "type": "string" } } ], diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 380cdb8e..a0d8f83a 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -640,12 +640,13 @@ paths: get: description: Gets events with a nullable event role for authenticated users. parameters: - - description: If true, include unpublished events as well. Superusers ONLY. + - description: Can be scoped to either published, scoped, or all. Scoped means + admins and staff can see unpublished events in: query - name: include_published + name: scope schema: - default: false - type: boolean + default: '"published"' + type: string requestBody: content: application/json: diff --git a/apps/api/internal/api/handlers/events.go b/apps/api/internal/api/handlers/events.go index e30d9816..8c64ab90 100644 --- a/apps/api/internal/api/handlers/events.go +++ b/apps/api/internal/api/handlers/events.go @@ -21,9 +21,7 @@ import ( "github.com/swamphacks/core/apps/api/internal/email" "github.com/swamphacks/core/apps/api/internal/parse" . "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 { @@ -386,22 +384,21 @@ func (h *EventHandler) DeleteEventById(w http.ResponseWriter, r *http.Request) { // @Tags Event // @Accept json // @Produce json -// @Param include_published query boolean false "If true, include unpublished events as well. Superusers ONLY." default(false) -// @Success 200 {array} sqlc.GetEventsWithUserInfoRow "OK: Events returned" +// @Param scope query string false "Can be scoped to either published, scoped, or all. Scoped means admins and staff can see unpublished events" default("published") +// @Success 200 {array} sqlc.GetEventsWithUserInfoRow "OK: Events returned" // @Router /events [get] 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)) + q := r.URL.Query() + scope, err := parse.ParseGetEventScopeType(q.Get("scope")) + if err != nil { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameters: include_unpublished=true,false")) + res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameters: include_unpublished=published, scoped, all, or none (default to published)")) return } - events, err := h.eventService.GetEvents(r.Context(), *includeUnpublished) + events, err := h.eventService.GetEvents(r.Context(), scope) if errors.Is(err, services.ErrMissingFields) { - res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameteres: status=all,published")) + res.SendError(w, http.StatusBadRequest, res.NewError("missing_fields", "Missing/malformed query. Available parameters: include_unpublished=published, scoped, all, or none (default to published)")) return } diff --git a/apps/api/internal/db/migrations/20251002000347_add_get_event_scope_type.sql b/apps/api/internal/db/migrations/20251002000347_add_get_event_scope_type.sql new file mode 100644 index 00000000..40a93db5 --- /dev/null +++ b/apps/api/internal/db/migrations/20251002000347_add_get_event_scope_type.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE get_event_scope_type AS ENUM ( + 'published', + 'scoped', + 'all' +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TYPE get_event_scope_type; +-- +goose StatementEnd diff --git a/apps/api/internal/db/queries/events.sql b/apps/api/internal/db/queries/events.sql index 4da4db15..ccf7e2af 100644 --- a/apps/api/internal/db/queries/events.sql +++ b/apps/api/internal/db/queries/events.sql @@ -89,5 +89,13 @@ LEFT JOIN event_roles er 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) +WHERE + CASE sqlc.arg(scope)::get_event_scope_type + WHEN 'all' THEN + TRUE + WHEN 'scoped' THEN + e.is_published = TRUE OR (e.is_published = FALSE AND (er.role = 'staff' or er.role = 'admin')) + ELSE + e.is_published = TRUE + END 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 4771b7cb..661f1d25 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -84,10 +84,11 @@ func (r *EventRepository) GetPublishedEvents(ctx context.Context, userId uuid.UU return &events, err } -func (r *EventRepository) GetEventsWithRoles(ctx context.Context, userId *uuid.UUID, includeUnpublished bool) (*[]sqlc.GetEventsWithUserInfoRow, error) { +// find a struct for valid roles to enforce type safety +func (r *EventRepository) GetEventsWithRoles(ctx context.Context, userId *uuid.UUID, scope sqlc.GetEventScopeType) (*[]sqlc.GetEventsWithUserInfoRow, error) { events, err := r.db.Query.GetEventsWithUserInfo(ctx, sqlc.GetEventsWithUserInfoParams{ - UserID: userId, - IncludeUnpublished: includeUnpublished, + UserID: userId, + Scope: scope, }) return &events, err } diff --git a/apps/api/internal/db/sqlc/events.sql.go b/apps/api/internal/db/sqlc/events.sql.go index b712008b..a58d5dd6 100644 --- a/apps/api/internal/db/sqlc/events.sql.go +++ b/apps/api/internal/db/sqlc/events.sql.go @@ -241,13 +241,21 @@ LEFT JOIN event_roles er 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) +WHERE + CASE $2::get_event_scope_type + WHEN 'all' THEN + TRUE + WHEN 'scoped' THEN + e.is_published = TRUE OR (e.is_published = FALSE AND (er.role = 'staff' or er.role = 'admin')) + ELSE + e.is_published = TRUE + END ORDER BY e.start_time ASC ` type GetEventsWithUserInfoParams struct { - UserID *uuid.UUID `json:"user_id"` - IncludeUnpublished bool `json:"include_unpublished"` + UserID *uuid.UUID `json:"user_id"` + Scope GetEventScopeType `json:"scope"` } type GetEventsWithUserInfoRow struct { @@ -273,7 +281,7 @@ type GetEventsWithUserInfoRow struct { } func (q *Queries) GetEventsWithUserInfo(ctx context.Context, arg GetEventsWithUserInfoParams) ([]GetEventsWithUserInfoRow, error) { - rows, err := q.db.Query(ctx, getEventsWithUserInfo, arg.UserID, arg.IncludeUnpublished) + rows, err := q.db.Query(ctx, getEventsWithUserInfo, arg.UserID, arg.Scope) if err != nil { return nil, err } diff --git a/apps/api/internal/db/sqlc/models.go b/apps/api/internal/db/sqlc/models.go index ac91f6c2..446635f5 100644 --- a/apps/api/internal/db/sqlc/models.go +++ b/apps/api/internal/db/sqlc/models.go @@ -145,6 +145,49 @@ func (ns NullEventRoleType) Value() (driver.Value, error) { return string(ns.EventRoleType), nil } +type GetEventScopeType string + +const ( + GetEventScopeTypePublished GetEventScopeType = "published" + GetEventScopeTypeScoped GetEventScopeType = "scoped" + GetEventScopeTypeAll GetEventScopeType = "all" +) + +func (e *GetEventScopeType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = GetEventScopeType(s) + case string: + *e = GetEventScopeType(s) + default: + return fmt.Errorf("unsupported scan type for GetEventScopeType: %T", src) + } + return nil +} + +type NullGetEventScopeType struct { + GetEventScopeType GetEventScopeType `json:"get_event_scope_type"` + Valid bool `json:"valid"` // Valid is true if GetEventScopeType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullGetEventScopeType) Scan(value interface{}) error { + if value == nil { + ns.GetEventScopeType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.GetEventScopeType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullGetEventScopeType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.GetEventScopeType), nil +} + type Application struct { UserID uuid.UUID `json:"user_id"` EventID uuid.UUID `json:"event_id"` diff --git a/apps/api/internal/parse/parse.go b/apps/api/internal/parse/parse.go index db796fed..70d8fe55 100644 --- a/apps/api/internal/parse/parse.go +++ b/apps/api/internal/parse/parse.go @@ -1,23 +1,46 @@ package parse -import "github.com/google/uuid" +import ( + "fmt" + + "github.com/google/uuid" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) // Parses a string ptr to a UUID or nil if failed to parse func ParseUUIDOrNil(s *string) *uuid.UUID { - if s == nil || *s == "" { - return nil - } - id, err := uuid.Parse(*s) - if err != nil { - return nil - } - return &id + if s == nil || *s == "" { + return nil + } + id, err := uuid.Parse(*s) + if err != nil { + return nil + } + return &id } // Parses a string pointer to a pointer or nil func ParseStrToPtr(s *string) *string { - if s == nil || *s == "" { - return nil - } - return s -} \ No newline at end of file + if s == nil || *s == "" { + return nil + } + return s +} + +func ParseGetEventScopeType(s string) (sqlc.GetEventScopeType, error) { + enumType := sqlc.GetEventScopeType(s) + + // an empty string should default to published + if s == "" { + return sqlc.GetEventScopeTypePublished, nil + } + + // sqlc generates constants for each enum value. We check if the input matches one of the valid, known constants. + switch enumType { + case sqlc.GetEventScopeTypePublished, sqlc.GetEventScopeTypeScoped, sqlc.GetEventScopeTypeAll: + return enumType, nil + default: + // The input string is not a valid enum value. + return "", fmt.Errorf("'%s' is not a valid GetEventScopeType", s) + } +} diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index 41bede4d..f620861c 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -116,16 +116,16 @@ func (s *EventService) DeleteEventById(ctx context.Context, id uuid.UUID) error return err } -func (s *EventService) GetEvents(ctx context.Context, includeUnpublished bool) (*[]sqlc.GetEventsWithUserInfoRow, error) { +func (s *EventService) GetEvents(ctx context.Context, scope sqlc.GetEventScopeType) (*[]sqlc.GetEventsWithUserInfoRow, error) { isSuperuser := ctxu.IsSuperuser(ctx) userId := ctxu.GetUserIdFromCtx(ctx) - // Non-superusers can't get unpublished events - if !isSuperuser && includeUnpublished { + // Non-superusers can't get all unpublished events + if !isSuperuser && scope == "all" { return nil, ErrMissingPerms } - return s.eventRepo.GetEventsWithRoles(ctx, userId, includeUnpublished) + return s.eventRepo.GetEventsWithRoles(ctx, userId, scope) } diff --git a/apps/web/src/features/Event/hooks/useEventsWithUserInfo.ts b/apps/web/src/features/Event/hooks/useEventsWithUserInfo.ts index a81f55e2..96157e93 100644 --- a/apps/web/src/features/Event/hooks/useEventsWithUserInfo.ts +++ b/apps/web/src/features/Event/hooks/useEventsWithUserInfo.ts @@ -10,7 +10,8 @@ export type EventsWithUserInfo = EventWithUserInfo[]; export async function fetchEvents(): Promise { const result = await api - .get("events?include_unpublished=false") + .get("events?scope=scoped") // ADD QUERY PARAMETER + // TODO, find other calls to events and change the query parameter accordingly .json(); console.log("Fetched events:", result); diff --git a/apps/web/src/lib/auth/services/oauth.ts b/apps/web/src/lib/auth/services/oauth.ts index e42fff8c..456084c9 100644 --- a/apps/web/src/lib/auth/services/oauth.ts +++ b/apps/web/src/lib/auth/services/oauth.ts @@ -38,6 +38,8 @@ export function _oauthSignIn(config: T) { path: "/", sameSite: "lax", secure: import.meta.env.DEV ? false : true, + //TODO: change this to an env variable + domain: import.meta.env.DEV ? "localhost" : ".swamphacks.com", }); const params = createOAuthRequestParams( diff --git a/infra/Caddyfile.api b/infra/Caddyfile.api index 80f35eb9..261f0f9b 100644 --- a/infra/Caddyfile.api +++ b/infra/Caddyfile.api @@ -4,9 +4,9 @@ # Development API dev-api.swamphacks.com { - reverse_proxy localhost:8081 { - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} + reverse_proxy api-dev:8080 { + header_up X-Real-IP {remote} + header_up X-Forwarded-For {remote} header_up X-Forwarded-Port {server_port} header_up X-Forwarded-Proto {scheme} } @@ -27,9 +27,9 @@ dev-api.swamphacks.com { # Production API api.swamphacks.com { - reverse_proxy localhost:8080 { - header_up X-Real-IP {remote_host} - header_up X-Forwarded-For {remote_host} + reverse_proxy api:8080 { + header_up X-Real-IP {remote} + header_up X-Forwarded-For {remote} header_up X-Forwarded-Port {server_port} header_up X-Forwarded-Proto {scheme} }