diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 360db852..8d922390 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -54,14 +54,16 @@ func main() { accountRepo := repository.NewAccountRespository(database) sessionRepo := repository.NewSessionRepository(database) eventInterestRepo := repository.NewEventInterestRepository(database) + eventRepo := repository.NewEventRespository(database) // Injections into services authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth) eventInterestService := services.NewEventInterestService(eventInterestRepo, logger) + eventService := services.NewEventService(eventRepo, logger) emailService := services.NewEmailService(taskQueueClient, logger) // Injections into handlers - apiHandlers := handlers.NewHandlers(authService, eventInterestService, emailService, cfg, logger) + apiHandlers := handlers.NewHandlers(authService, eventInterestService, eventService, emailService, cfg, logger) api := api.NewAPI(&logger, apiHandlers, mw) diff --git a/apps/api/go.mod b/apps/api/go.mod index 49a249e8..a143c818 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -6,6 +6,7 @@ require ( github.com/caarlos0/env/v11 v11.3.1 github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 + github.com/go-playground/validator/v10 v10.27.0 github.com/google/uuid v1.6.0 github.com/hibiken/asynq v0.25.1 github.com/jackc/pgx/v5 v5.7.4 @@ -16,9 +17,13 @@ require ( require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/redis/go-redis/v9 v9.7.0 // indirect @@ -26,6 +31,7 @@ require ( github.com/spf13/cast v1.7.0 // indirect github.com/stretchr/testify v1.10.0 // indirect golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.25.0 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index feb8ee50..75898a9e 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -14,10 +14,20 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -39,11 +49,15 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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= @@ -67,6 +81,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 5407a2e8..06289310 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -34,6 +34,13 @@ func NewAPI(logger *zerolog.Logger, handlers *handlers.Handlers, middleware *mw. } func (api *API) setupRoutes(mw *mw.Middleware) { + + var ( + // Both requireXXRole functions automatically allow superusers + ensureSuperuser = mw.Auth.RequirePlatformRole([]sqlc.AuthUserRole{sqlc.AuthUserRoleSuperuser}) + ensureEventAdmin = mw.Event.RequireEventRole([]sqlc.EventRoleType{sqlc.EventRoleTypeAdmin}) + ) + api.Router.Use(middleware.Logger) api.Router.Use(middleware.RealIP) api.Router.Use(cors.Handler(cors.Options{ @@ -68,7 +75,13 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // Event routes api.Router.Route("/event", func(r chi.Router) { - r.Post("/{eventId}/interest", api.Handlers.EventInterest.AddEmailToEvent) + r.With(mw.Auth.RequireAuth, ensureSuperuser).Post("/", api.Handlers.Event.CreateEvent) + 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) + r.Get("/", api.Handlers.Event.GetEventByID) + r.Post("/interest", api.Handlers.EventInterest.AddEmailToEvent) + }) }) // Email routes @@ -87,7 +100,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { }) r.Group(func(r chi.Router) { - r.Use(mw.Auth.RequirePlatformRole(sqlc.AuthUserRoleUser)) + r.Use(mw.Auth.RequirePlatformRole([]sqlc.AuthUserRole{sqlc.AuthUserRoleUser})) r.Get("/user", func(w http.ResponseWriter, r *http.Request) { if _, err := w.Write([]byte("Welcome, user!\n")); err != nil { log.Err(err) @@ -96,7 +109,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { }) r.Group(func(r chi.Router) { - r.Use(mw.Auth.RequirePlatformRole(sqlc.AuthUserRoleSuperuser)) + r.Use(mw.Auth.RequirePlatformRole([]sqlc.AuthUserRole{sqlc.AuthUserRoleSuperuser})) r.Get("/superuser", func(w http.ResponseWriter, r *http.Request) { if _, err := w.Write([]byte("Welcome, superuser!\n")); err != nil { log.Err(err) diff --git a/apps/api/internal/api/handlers/events.go b/apps/api/internal/api/handlers/events.go new file mode 100644 index 00000000..e2cf3fa4 --- /dev/null +++ b/apps/api/internal/api/handlers/events.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "reflect" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/rs/zerolog" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" + "github.com/swamphacks/core/apps/api/internal/services" +) + +type EventHandler struct { + eventService *services.EventService + cfg *config.Config + logger zerolog.Logger +} + +func NewEventHandler(eventService *services.EventService, cfg *config.Config, logger zerolog.Logger) *EventHandler { + return &EventHandler{ + eventService: eventService, + cfg: cfg, + logger: logger.With().Str("handler", "EventHandler").Str("component", "events").Logger(), + } +} + +// Be very careful with the types in this struct. If a type is not a pointer (pointer types allow a null value), and the field is not present in the json body, its default value will be passed to the SQL query, and be a non-null value will be put into coalese(), which will then make a NULL value impossible and instead make the default value the type's zero value in Go. +type CreateEventFields struct { + Name string `json:"name" validate:"required,min=5,max=30"` + ApplicationOpen time.Time `json:"application_open" validate:"required"` + ApplicationClose time.Time `json:"application_close" validate:"required"` + StartTime time.Time `json:"start_time" validate:"required"` + EndTime time.Time `json:"end_time" validate:"required"` + Description *string `json:"description"` + Location *string `json:"location"` + LocationUrl *string `json:"location_url"` + MaxAttendees *int32 `json:"max_attendees"` + RsvpDeadline *time.Time `json:"rsvp_deadline"` + DecisionRelease *time.Time `json:"decision_release"` + WebsiteUrl *string `json:"website_url"` + IsPublished *bool `json:"is_published"` +} + +func (st CreateEventFields) ValidateTimeFields() bool { + if st.ApplicationClose.Before(st.ApplicationOpen) || st.ApplicationClose.Equal(st.ApplicationOpen) { + return false + } + if st.EndTime.Before(st.StartTime) || st.EndTime.Equal(st.StartTime) { + return false + } + if st.ApplicationOpen.Before(time.Now()) || + st.ApplicationClose.Before(time.Now()) || + st.StartTime.Before(time.Now()) || + st.EndTime.Before(time.Now()) { + return false + } + return true +} + +func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { + + // Parse JSON body + var req CreateEventFields + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() // Prevents requests with extraneous fields + // This will also throw an error for empty values for fields which correspond to types that cannot convert an empty string to a zero value (e.g. time.Time) + if err := decoder.Decode(&req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Could not parse request body")) + return + } + + validate := validator.New() + if err := validate.Struct(req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", err.Error())) + } + + if !req.ValidateTimeFields() { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_time", "Time fields must be sequential and not in the past.")) + return + } + + params := sqlc.CreateEventParams{ + Name: req.Name, + ApplicationOpen: req.ApplicationOpen, + ApplicationClose: req.ApplicationClose, + StartTime: req.StartTime, + EndTime: req.EndTime, + Description: req.Description, + Location: req.Location, + LocationUrl: req.LocationUrl, + MaxAttendees: req.MaxAttendees, + RsvpDeadline: req.RsvpDeadline, + DecisionRelease: req.DecisionRelease, + WebsiteUrl: req.WebsiteUrl, + IsPublished: req.IsPublished, + } + + event, err := h.eventService.CreateEvent(r.Context(), params) + if err != nil { + if err == services.ErrFailedToCreateEvent { + res.SendError(w, http.StatusInternalServerError, res.NewError("creation_error", "Failed to create event")) + } else { + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong")) + } + } + + res.Send(w, http.StatusCreated, event) +} + +func (h *EventHandler) GetEventByID(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 + } + + event, err := h.eventService.GetEventByID(r.Context(), eventId) + if err != nil { + switch err { + case services.ErrFailedToGetEvent: + res.SendError(w, http.StatusNotFound, res.NewError("no_event", "Event not found")) + default: + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong")) + } + } + + res.Send(w, http.StatusOK, event) +} + +func (h *EventHandler) UpdateEventById(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 req sqlc.UpdateEventByIdParams + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() // Prevents requests with extraneous fields + if err := decoder.Decode(&req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Invalid request body")) + return + } + + // Refactorme: could be improved by unmarshalling values into a generic that can include nil information + // Todo: make sure that non nullable values can't be updated to null + // Todo: Time validation + req.NameDoUpdate = reflect.ValueOf(req.Name).IsValid() + req.DescriptionDoUpdate = reflect.ValueOf(req.Description).IsValid() + req.LocationDoUpdate = reflect.ValueOf(req.Location).IsValid() + req.LocationUrlDoUpdate = reflect.ValueOf(req.LocationUrl).IsValid() + req.MaxAttendeesDoUpdate = reflect.ValueOf(req.MaxAttendees).IsValid() + req.ApplicationOpenDoUpdate = reflect.ValueOf(req.ApplicationOpen).IsValid() + req.ApplicationCloseDoUpdate = reflect.ValueOf(req.ApplicationClose).IsValid() + req.RsvpDeadlineDoUpdate = reflect.ValueOf(req.RsvpDeadline).IsValid() + req.DecisionReleaseDoUpdate = reflect.ValueOf(req.DecisionRelease).IsValid() + req.StartTimeDoUpdate = reflect.ValueOf(req.StartTime).IsValid() + req.EndTimeDoUpdate = reflect.ValueOf(req.EndTime).IsValid() + req.WebsiteUrlDoUpdate = reflect.ValueOf(req.WebsiteUrl).IsValid() + req.IsPublishedDoUpdate = reflect.ValueOf(req.IsPublished).IsValid() + req.ID = eventId + + event, err := h.eventService.UpdateEventById(r.Context(), req) + + if err != nil { + switch err { + case services.ErrFailedToUpdateEvent: + res.SendError(w, http.StatusInternalServerError, res.NewError("patch_error", "Failed to update event")) + default: + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong")) + } + } + + res.Send(w, http.StatusOK, event) +} + +func (h *EventHandler) DeleteEventById(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 + } + err = h.eventService.DeleteEventById(r.Context(), eventId) + + if err != nil { + switch err { + case services.ErrFailedToDeleteEvent: + res.SendError(w, http.StatusInternalServerError, res.NewError("delete_error", "Failed to delete event")) + default: + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong")) + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index 7841c371..6af8694a 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -9,13 +9,15 @@ import ( type Handlers struct { Auth *AuthHandler EventInterest *EventInterestHandler + Event *EventHandler Email *EmailHandler } -func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, emailService *services.EmailService, cfg *config.Config, logger zerolog.Logger) *Handlers { +func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, eventService *services.EventService, emailService *services.EmailService, cfg *config.Config, logger zerolog.Logger) *Handlers { return &Handlers{ Auth: NewAuthHandler(authService, cfg, logger), EventInterest: NewEventInterestHandler(eventInterestService, cfg, logger), + Event: NewEventHandler(eventService, cfg, logger), Email: NewEmailHandler(emailService, logger), } } diff --git a/apps/api/internal/api/middleware/auth.go b/apps/api/internal/api/middleware/auth.go index 28c1eda2..9def525a 100644 --- a/apps/api/internal/api/middleware/auth.go +++ b/apps/api/internal/api/middleware/auth.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "net/http" + "slices" "time" "github.com/google/uuid" @@ -97,7 +98,7 @@ func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { }) } -func (m *AuthMiddleware) RequirePlatformRole(role sqlc.AuthUserRole) func(http.Handler) http.Handler { +func (m *AuthMiddleware) RequirePlatformRole(roles []sqlc.AuthUserRole) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // get user from context @@ -108,8 +109,13 @@ func (m *AuthMiddleware) RequirePlatformRole(role sqlc.AuthUserRole) func(http.H return } + if userCtx.Role == sqlc.AuthUserRoleSuperuser { + next.ServeHTTP(w, r) + return + } + // check if user role matches required role - if userCtx.Role != role { + if !slices.Contains(roles, userCtx.Role) { m.logger.Warn().Msgf("User tried to access %s with insufficient permissions as role %s", r.URL.Path, string(userCtx.Role)) response.SendError(w, http.StatusForbidden, response.NewError("forbidden", "You are forbidden from this resource.")) return diff --git a/apps/api/internal/api/middleware/events.go b/apps/api/internal/api/middleware/events.go new file mode 100644 index 00000000..7c81b681 --- /dev/null +++ b/apps/api/internal/api/middleware/events.go @@ -0,0 +1,73 @@ +package middleware + +import ( + "net/http" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/db" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +type EventMiddleware struct { + db *db.DB + logger zerolog.Logger + cfg *config.Config + eventRespository *repository.EventRepository +} + +func NewEventMiddleware(db *db.DB, logger zerolog.Logger, cfg *config.Config) *EventMiddleware { + return &EventMiddleware{ + db: db, + logger: logger.With().Str("middleware", "EventMiddleware").Str("component", "api").Logger(), + cfg: cfg, + eventRespository: repository.NewEventRespository(db), + } +} + +func (m *EventMiddleware) RequireEventRole(eventRoles []sqlc.EventRoleType) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // get user from context + userCtx, ok := r.Context().Value(UserContextKey).(*UserContext) + if !ok { + m.logger.Warn().Msg("No event role context found.") + response.SendError(w, http.StatusUnauthorized, response.NewError("no_auth", "You are not authorized.")) + return + } + + eventIdStr := chi.URLParam(r, "eventId") + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + response.SendError(w, http.StatusBadRequest, response.NewError("invalid_event_id", "The event ID is not a valid UUID")) + return + } + + if userCtx.Role == sqlc.AuthUserRoleSuperuser { + next.ServeHTTP(w, r) + return + } + + userEventRole, err := m.eventRespository.GetEventRoleByIds(r.Context(), userCtx.UserID, eventId) + if err != nil { + // TODO: Will throw if user doesn't have permission, but how should we handle that with other possible errors? + m.logger.Warn().Msgf("Error while trying to access %s with insufficient permissions (userId: %s, eventId: %s)", r.URL.Path, userCtx.UserID, eventId) + response.SendError(w, http.StatusForbidden, response.NewError("forbidden", "You are forbidden from this resource.")) + return + + } + if !slices.Contains(eventRoles, userEventRole.Role) { + m.logger.Warn().Msgf("User tried to access %s with insufficient permissions (eventRole: %s)", r.URL.Path, string(userEventRole.Role)) + response.SendError(w, http.StatusForbidden, response.NewError("forbidden", "You are forbidden from this resource.")) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/apps/api/internal/api/middleware/middleware.go b/apps/api/internal/api/middleware/middleware.go index 8567c956..26c51b19 100644 --- a/apps/api/internal/api/middleware/middleware.go +++ b/apps/api/internal/api/middleware/middleware.go @@ -7,11 +7,13 @@ import ( ) type Middleware struct { - Auth *AuthMiddleware + Auth *AuthMiddleware + Event *EventMiddleware } func NewMiddleware(db *db.DB, logger zerolog.Logger, cfg *config.Config) *Middleware { return &Middleware{ - Auth: NewAuthMiddleware(db, logger, cfg), + Auth: NewAuthMiddleware(db, logger, cfg), + Event: NewEventMiddleware(db, logger, cfg), } } diff --git a/apps/api/internal/db/migrations/20250619161938_event_schema.sql b/apps/api/internal/db/migrations/20250619161938_event_schema.sql index 997e7ea9..74d19609 100644 --- a/apps/api/internal/db/migrations/20250619161938_event_schema.sql +++ b/apps/api/internal/db/migrations/20250619161938_event_schema.sql @@ -1,6 +1,7 @@ -- +goose Up -- +goose StatementBegin CREATE TABLE events ( + -- IMPORTANT: Double check INSERT queries after making changes to default values here, since any optional parameters must have its default value match. id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, description TEXT, @@ -21,7 +22,6 @@ CREATE TABLE events ( -- Metadata website_url TEXT, is_published BOOLEAN DEFAULT FALSE, - saved_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() diff --git a/apps/api/internal/db/queries/events.sql b/apps/api/internal/db/queries/events.sql index 55c57897..5c0f138c 100644 --- a/apps/api/internal/db/queries/events.sql +++ b/apps/api/internal/db/queries/events.sql @@ -2,12 +2,24 @@ INSERT INTO events ( name, application_open, application_close, - start_time, end_time + start_time, end_time, + description, location, location_url, max_attendees, + rsvp_deadline, decision_release, + website_url, is_published ) VALUES ( - $1, - $2, $3, - $4, $5 -) + -- FIXME: The second parameter in coalesce MUST be the default value created in the schema. I have not found a more automated way to insert the default value. + @name, + @application_open, @application_close, + @start_time, @end_time, + coalesce(sqlc.narg(description), NULL), + coalesce(sqlc.narg(location), NULL), + coalesce(sqlc.narg(location_url), NULL), + coalesce(sqlc.narg(max_attendees), NULL::INT), + coalesce(sqlc.narg(rsvp_deadline), NULL::TIMESTAMPTZ), + coalesce(sqlc.narg(decision_release), NULL::TIMESTAMPTZ), + coalesce(sqlc.narg(website_url), NULL), + coalesce(sqlc.narg(is_published), FALSE) +) RETURNING *; -- name: GetEventByID :one @@ -17,23 +29,28 @@ WHERE id = $1; -- name: UpdateEventById :exec UPDATE events SET - name = coalesce(sqlc.narg('name'), name), - description = coalesce(sqlc.narg('description'), description), - location = coalesce(sqlc.narg('location'), location), - location_url = coalesce(sqlc.narg('location_url'), location_url), - max_attendees = coalesce(sqlc.narg('max_attendees'), max_attendees), - application_open = coalesce(sqlc.narg('application_open'), application_open), - application_close = coalesce(sqlc.narg('application_close'), application_close), - rsvp_deadline = coalesce(sqlc.narg('rsvp_deadline'), rsvp_deadline), - decision_release = coalesce(sqlc.narg('decision_release'), decision_release), - start_time = coalesce(sqlc.narg('start_time'), start_time), - end_time = coalesce(sqlc.narg('end_time'), end_time), - website_url = coalesce(sqlc.narg('website_url'), website_url), - is_published = coalesce(sqlc.narg('is_published'), is_published), - saved_at = coalesce(sqlc.narg('saved_at'), is_published) + name = CASE WHEN @name_do_update::boolean THEN @name ELSE name END, + description = CASE WHEN @description_do_update::boolean THEN @description ELSE description END, + location = CASE WHEN @location_do_update::boolean THEN @location ELSE location END, + location_url = CASE WHEN @location_url_do_update::boolean THEN @location_url ELSE location_url END, + max_attendees = CASE WHEN @max_attendees_do_update::boolean THEN @max_attendees ELSE max_attendees END, + application_open = CASE WHEN @application_open_do_update::boolean THEN @application_open ELSE application_open END, + application_close = CASE WHEN @application_close_do_update::boolean THEN @application_close ELSE application_close END, + rsvp_deadline = CASE WHEN @rsvp_deadline_do_update::boolean THEN @rsvp_deadline ELSE rsvp_deadline END, + decision_release = CASE WHEN @decision_release_do_update::boolean THEN @decision_release ELSE decision_release END, + start_time = CASE WHEN @start_time_do_update::boolean THEN @start_time ELSE start_time END, + end_time = CASE WHEN @end_time_do_update::boolean THEN @end_time ELSE end_time END, + website_url = CASE WHEN @website_url_do_update::boolean THEN @website_url ELSE website_url END, + is_published = CASE WHEN @is_published_do_update::boolean THEN @is_published ELSE is_published END WHERE - id = @id::uuid; + id = @id::uuid +RETURNING *; --- name: DeleteEvent :exec +-- name: DeleteEventById :execrows +-- execrows returns affect row count on top of an error DELETE FROM events WHERE id = $1; + +-- name: GetEventRoleByIds :one +SELECT * FROM event_roles +WHERE user_id = @user_id::uuid AND event_id = @event_id::uuid; \ No newline at end of file diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go new file mode 100644 index 00000000..0ba25f8d --- /dev/null +++ b/apps/api/internal/db/repository/events.go @@ -0,0 +1,91 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + "github.com/google/uuid" + "github.com/swamphacks/core/apps/api/internal/db" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +var ( + ErrEventNotFound = errors.New("event not found") + ErrEventRoleNotFound = errors.New("event role not found") + ErrDuplicateEvent = errors.New("event already exists in database") + ErrNoEventsDeleted = errors.New("no events deleted") + ErrMultipleEventsDeleted = errors.New("multiple events affected by delete query while only expecting one to delete one") + ErrUnknown = errors.New("an unkown error was caught") +) + +type EventRepository struct { + db *db.DB +} + +func NewEventRespository(db *db.DB) *EventRepository { + return &EventRepository{ + db: db, + } +} + +func (r *EventRepository) CreateEvent(ctx context.Context, params sqlc.CreateEventParams) (*sqlc.Event, error) { + event, err := r.db.Query.CreateEvent(ctx, params) + if db.IsUniqueViolation(err) { + return nil, ErrDuplicateEvent + } + return &event, err +} + +func (r *EventRepository) GetEventByID(ctx context.Context, id uuid.UUID) (*sqlc.Event, error) { + event, err := r.db.Query.GetEventByID(ctx, id) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrEventNotFound + } + return nil, err + } + return &event, err +} + +func (r *EventRepository) UpdateEventById(ctx context.Context, params sqlc.UpdateEventByIdParams) error { + err := r.db.Query.UpdateEventById(ctx, params) + if err != nil { + if err == sql.ErrNoRows { + return ErrEventNotFound + } + } + return err +} + +func (r *EventRepository) DeleteEventById(ctx context.Context, id uuid.UUID) error { + affectedRows, err := r.db.Query.DeleteEventById(ctx, id) + if err != nil { + if err == sql.ErrNoRows { + return ErrEventNotFound + } + } + if affectedRows == 0 { + return ErrNoEventsDeleted + } else if affectedRows > 1 { + return ErrMultipleEventsDeleted + } + + return err +} + +func (r *EventRepository) GetEventRoleByIds(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) (*sqlc.EventRole, error) { + params := sqlc.GetEventRoleByIdsParams{ + UserID: userId, + EventID: eventId, + } + + eventRole, err := r.db.Query.GetEventRoleByIds(ctx, params) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrEventRoleNotFound + } + } + + return &eventRole, err +} diff --git a/apps/api/internal/db/sqlc/events.sql.go b/apps/api/internal/db/sqlc/events.sql.go index 25b8de7a..a599bb25 100644 --- a/apps/api/internal/db/sqlc/events.sql.go +++ b/apps/api/internal/db/sqlc/events.sql.go @@ -16,21 +16,41 @@ const createEvent = `-- name: CreateEvent :one INSERT INTO events ( name, application_open, application_close, - start_time, end_time + start_time, end_time, + description, location, location_url, max_attendees, + rsvp_deadline, decision_release, + website_url, is_published ) VALUES ( + -- FIXME: The second parameter in coalesce MUST be the default value created in the schema. I have not found a more automated way to insert the default value. $1, $2, $3, - $4, $5 -) -RETURNING id, name, description, location, location_url, max_attendees, application_open, application_close, rsvp_deadline, decision_release, start_time, end_time, website_url, is_published, saved_at, created_at, updated_at + $4, $5, + coalesce($6, NULL), + coalesce($7, NULL), + coalesce($8, NULL), + coalesce($9, NULL::INT), + coalesce($10, NULL::TIMESTAMPTZ), + coalesce($11, NULL::TIMESTAMPTZ), + coalesce($12, NULL), + coalesce($13, FALSE) +) +RETURNING 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 ` type CreateEventParams struct { - Name string `json:"name"` - ApplicationOpen time.Time `json:"application_open"` - ApplicationClose time.Time `json:"application_close"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` + Name string `json:"name"` + ApplicationOpen time.Time `json:"application_open"` + ApplicationClose time.Time `json:"application_close"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Description interface{} `json:"description"` + Location interface{} `json:"location"` + LocationUrl interface{} `json:"location_url"` + MaxAttendees interface{} `json:"max_attendees"` + RsvpDeadline interface{} `json:"rsvp_deadline"` + DecisionRelease interface{} `json:"decision_release"` + WebsiteUrl interface{} `json:"website_url"` + IsPublished interface{} `json:"is_published"` } func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { @@ -40,6 +60,14 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event arg.ApplicationClose, arg.StartTime, arg.EndTime, + arg.Description, + arg.Location, + arg.LocationUrl, + arg.MaxAttendees, + arg.RsvpDeadline, + arg.DecisionRelease, + arg.WebsiteUrl, + arg.IsPublished, ) var i Event err := row.Scan( @@ -57,25 +85,28 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event &i.EndTime, &i.WebsiteUrl, &i.IsPublished, - &i.SavedAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } -const deleteEvent = `-- name: DeleteEvent :exec +const deleteEventById = `-- name: DeleteEventById :execrows DELETE FROM events WHERE id = $1 ` -func (q *Queries) DeleteEvent(ctx context.Context, id uuid.UUID) error { - _, err := q.db.Exec(ctx, deleteEvent, id) - return err +// execrows returns affect row count on top of an error +func (q *Queries) DeleteEventById(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.Exec(ctx, deleteEventById, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil } const getEventByID = `-- name: GetEventByID :one -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, saved_at, created_at, updated_at FROM events +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 id = $1 ` @@ -97,68 +128,113 @@ func (q *Queries) GetEventByID(ctx context.Context, id uuid.UUID) (Event, error) &i.EndTime, &i.WebsiteUrl, &i.IsPublished, - &i.SavedAt, &i.CreatedAt, &i.UpdatedAt, ) return i, err } +const getEventRoleByIds = `-- name: GetEventRoleByIds :one +SELECT user_id, event_id, role, assigned_at FROM event_roles +WHERE user_id = $1::uuid AND event_id = $2::uuid +` + +type GetEventRoleByIdsParams struct { + UserID uuid.UUID `json:"user_id"` + EventID uuid.UUID `json:"event_id"` +} + +func (q *Queries) GetEventRoleByIds(ctx context.Context, arg GetEventRoleByIdsParams) (EventRole, error) { + row := q.db.QueryRow(ctx, getEventRoleByIds, arg.UserID, arg.EventID) + var i EventRole + err := row.Scan( + &i.UserID, + &i.EventID, + &i.Role, + &i.AssignedAt, + ) + return i, err +} + const updateEventById = `-- name: UpdateEventById :exec UPDATE events SET - name = coalesce($1, name), - description = coalesce($2, description), - location = coalesce($3, location), - location_url = coalesce($4, location_url), - max_attendees = coalesce($5, max_attendees), - application_open = coalesce($6, application_open), - application_close = coalesce($7, application_close), - rsvp_deadline = coalesce($8, rsvp_deadline), - decision_release = coalesce($9, decision_release), - start_time = coalesce($10, start_time), - end_time = coalesce($11, end_time), - website_url = coalesce($12, website_url), - is_published = coalesce($13, is_published), - saved_at = coalesce($14, is_published) + name = CASE WHEN $1::boolean THEN $2 ELSE name END, + description = CASE WHEN $3::boolean THEN $4 ELSE description END, + location = CASE WHEN $5::boolean THEN $6 ELSE location END, + location_url = CASE WHEN $7::boolean THEN $8 ELSE location_url END, + max_attendees = CASE WHEN $9::boolean THEN $10 ELSE max_attendees END, + application_open = CASE WHEN $11::boolean THEN $12 ELSE application_open END, + application_close = CASE WHEN $13::boolean THEN $14 ELSE application_close END, + rsvp_deadline = CASE WHEN $15::boolean THEN $16 ELSE rsvp_deadline END, + decision_release = CASE WHEN $17::boolean THEN $18 ELSE decision_release END, + start_time = CASE WHEN $19::boolean THEN $20 ELSE start_time END, + end_time = CASE WHEN $21::boolean THEN $22 ELSE end_time END, + website_url = CASE WHEN $23::boolean THEN $24 ELSE website_url END, + is_published = CASE WHEN $25::boolean THEN $26 ELSE is_published END WHERE - id = $15::uuid + id = $27::uuid +RETURNING 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 ` type UpdateEventByIdParams struct { - 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"` - SavedAt *time.Time `json:"saved_at"` - ID uuid.UUID `json:"id"` + NameDoUpdate bool `json:"name_do_update"` + Name string `json:"name"` + DescriptionDoUpdate bool `json:"description_do_update"` + Description *string `json:"description"` + LocationDoUpdate bool `json:"location_do_update"` + Location *string `json:"location"` + LocationUrlDoUpdate bool `json:"location_url_do_update"` + LocationUrl *string `json:"location_url"` + MaxAttendeesDoUpdate bool `json:"max_attendees_do_update"` + MaxAttendees *int32 `json:"max_attendees"` + ApplicationOpenDoUpdate bool `json:"application_open_do_update"` + ApplicationOpen time.Time `json:"application_open"` + ApplicationCloseDoUpdate bool `json:"application_close_do_update"` + ApplicationClose time.Time `json:"application_close"` + RsvpDeadlineDoUpdate bool `json:"rsvp_deadline_do_update"` + RsvpDeadline *time.Time `json:"rsvp_deadline"` + DecisionReleaseDoUpdate bool `json:"decision_release_do_update"` + DecisionRelease *time.Time `json:"decision_release"` + StartTimeDoUpdate bool `json:"start_time_do_update"` + StartTime time.Time `json:"start_time"` + EndTimeDoUpdate bool `json:"end_time_do_update"` + EndTime time.Time `json:"end_time"` + WebsiteUrlDoUpdate bool `json:"website_url_do_update"` + WebsiteUrl *string `json:"website_url"` + IsPublishedDoUpdate bool `json:"is_published_do_update"` + IsPublished *bool `json:"is_published"` + ID uuid.UUID `json:"id"` } func (q *Queries) UpdateEventById(ctx context.Context, arg UpdateEventByIdParams) error { _, err := q.db.Exec(ctx, updateEventById, + arg.NameDoUpdate, arg.Name, + arg.DescriptionDoUpdate, arg.Description, + arg.LocationDoUpdate, arg.Location, + arg.LocationUrlDoUpdate, arg.LocationUrl, + arg.MaxAttendeesDoUpdate, arg.MaxAttendees, + arg.ApplicationOpenDoUpdate, arg.ApplicationOpen, + arg.ApplicationCloseDoUpdate, arg.ApplicationClose, + arg.RsvpDeadlineDoUpdate, arg.RsvpDeadline, + arg.DecisionReleaseDoUpdate, arg.DecisionRelease, + arg.StartTimeDoUpdate, arg.StartTime, + arg.EndTimeDoUpdate, arg.EndTime, + arg.WebsiteUrlDoUpdate, arg.WebsiteUrl, + arg.IsPublishedDoUpdate, arg.IsPublished, - arg.SavedAt, arg.ID, ) return err diff --git a/apps/api/internal/db/sqlc/models.go b/apps/api/internal/db/sqlc/models.go index 2ca6d713..159ac243 100644 --- a/apps/api/internal/db/sqlc/models.go +++ b/apps/api/internal/db/sqlc/models.go @@ -210,7 +210,6 @@ type Event struct { EndTime time.Time `json:"end_time"` WebsiteUrl *string `json:"website_url"` IsPublished *bool `json:"is_published"` - SavedAt *time.Time `json:"saved_at"` CreatedAt *time.Time `json:"created_at"` UpdatedAt *time.Time `json:"updated_at"` } diff --git a/apps/api/internal/db/sqlc/querier.go b/apps/api/internal/db/sqlc/querier.go index 65bbba66..61d7a934 100644 --- a/apps/api/internal/db/sqlc/querier.go +++ b/apps/api/internal/db/sqlc/querier.go @@ -22,7 +22,8 @@ type Querier interface { CreateUser(ctx context.Context, arg CreateUserParams) (AuthUser, error) DeleteAccount(ctx context.Context, arg DeleteAccountParams) error DeleteApplication(ctx context.Context, arg DeleteApplicationParams) error - DeleteEvent(ctx context.Context, id uuid.UUID) error + // execrows returns affect row count on top of an error + DeleteEventById(ctx context.Context, id uuid.UUID) (int64, error) DeleteExpiredSession(ctx context.Context) error DeleteUser(ctx context.Context, id uuid.UUID) error GetActiveSessionUserInfo(ctx context.Context, id uuid.UUID) (GetActiveSessionUserInfoRow, error) @@ -30,6 +31,7 @@ type Querier interface { 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) 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/services/events.go b/apps/api/internal/services/events.go new file mode 100644 index 00000000..6b118eab --- /dev/null +++ b/apps/api/internal/services/events.go @@ -0,0 +1,107 @@ +package services + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +var ( + ErrFailedToCreateEvent = errors.New("failed to create event") + ErrFailedToGetEvent = errors.New("failed to get event") + ErrFailedToUpdateEvent = errors.New("failed to update event") + ErrFailedToDeleteEvent = errors.New("failed to delete event") +) + +type EventService struct { + eventRepo *repository.EventRepository + logger zerolog.Logger +} + +func NewEventService(eventRepo *repository.EventRepository, logger zerolog.Logger) *EventService { + return &EventService{ + eventRepo: eventRepo, + logger: logger.With().Str("service", "EventService").Str("component", "events").Logger(), + } +} + +func (s *EventService) CreateEvent(ctx context.Context, params sqlc.CreateEventParams) (*sqlc.Event, error) { + event, err := s.eventRepo.CreateEvent(ctx, params) + if err != nil { + if err == repository.ErrEventNotFound { + s.logger.Err(err).Msg(repository.ErrEventNotFound.Error()) + } else { + s.logger.Err(err).Msg(repository.ErrUnknown.Error()) + } + return nil, ErrFailedToCreateEvent + } + + return event, nil +} + +func (s *EventService) GetEventByID(ctx context.Context, id uuid.UUID) (*sqlc.Event, error) { + event, err := s.eventRepo.GetEventByID(ctx, id) + if err != nil { + if err == repository.ErrEventNotFound { + s.logger.Err(err).Msg(repository.ErrEventNotFound.Error()) + } else { + s.logger.Err(err).Msg(repository.ErrUnknown.Error()) + } + return nil, ErrFailedToGetEvent + } + + return event, nil +} + +func (s *EventService) UpdateEventById(ctx context.Context, params sqlc.UpdateEventByIdParams) (*sqlc.Event, error) { + err := s.eventRepo.UpdateEventById(ctx, params) + if err != nil { + if err == repository.ErrEventNotFound { + s.logger.Err(err).Msg(repository.ErrEventNotFound.Error()) + } else { + s.logger.Err(err).Msg(repository.ErrUnknown.Error()) + } + return nil, ErrFailedToUpdateEvent + } + + event, err := s.eventRepo.GetEventByID(ctx, params.ID) + + return event, err +} + +func (s *EventService) DeleteEventById(ctx context.Context, id uuid.UUID) error { + err := s.eventRepo.DeleteEventById(ctx, id) + if err != nil { + switch err { + case repository.ErrEventNotFound: + s.logger.Err(err).Msg(repository.ErrEventNotFound.Error()) + case repository.ErrNoEventsDeleted: + s.logger.Err(err).Msg(repository.ErrEventNotFound.Error()) + case repository.ErrMultipleEventsDeleted: + s.logger.Err(err).Msg(repository.ErrMultipleEventsDeleted.Error()) + default: + s.logger.Err(err).Msg(repository.ErrUnknown.Error()) + } + return ErrFailedToDeleteEvent + } + + return err +} + +func (s *EventService) GetEventRoleByIds(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) (*sqlc.EventRole, error) { + eventRole, err := s.eventRepo.GetEventRoleByIds(ctx, userId, eventId) + if err != nil { + if err == repository.ErrEventRoleNotFound { + s.logger.Err(err).Msg(repository.ErrEventRoleNotFound.Error()) + } else { + s.logger.Err(err).Msg(repository.ErrUnknown.Error()) + } + return nil, err + } + + return eventRole, err +} diff --git a/apps/web/package.json b/apps/web/package.json index 393bd70c..c7509cf2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -96,4 +96,4 @@ "plugin:storybook/recommended" ] } -} +} \ No newline at end of file diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 6d7e7219..6473d95d 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -63,54 +63,38 @@ /* EVENT CARD BUTTONS */ --event-button-bg-not-applied: var(--color-cyan-700); --event-button-bg-not-applied-hover: var(--color-cyan-800); - --event-button-bg-under-review: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-under-review-hover: color-mix( - in oklab, - var(--color-cyan-700) 15%, - transparent - ); + --event-button-bg-under-review: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-under-review-hover: color-mix(in oklab, + var(--color-cyan-700) 15%, + transparent); --event-button-bg-accepted-attending: var(--color-green-600); --event-button-bg-accepted-attending-hover: var(--color-green-700); --event-button-bg-accepted-not-going: var(--color-neutral-200); --event-button-bg-accepted-not-going-hover: var(--color-neutral-300); --event-button-bg-rejected: var(--color-neutral-200); --event-button-bg-rejected-hover: var(--color-neutral-300); - --event-button-bg-attending: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-attending-hover: color-mix( - in oklab, - var(--color-cyan-700) 30%, - transparent - ); + --event-button-bg-attending: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-attending-hover: color-mix(in oklab, + var(--color-cyan-700) 30%, + transparent); --event-button-bg-waitlisted: var(--color-violet-200); --event-button-bg-waitlisted-hover: var(--color-violet-300); - --event-button-bg-staff: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-staff-hover: color-mix( - in oklab, - var(--color-cyan-700) 30%, - transparent - ); - --event-button-bg-admin: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-admin-hover: color-mix( - in oklab, - var(--color-cyan-700) 30%, - transparent - ); + --event-button-bg-staff: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-staff-hover: color-mix(in oklab, + var(--color-cyan-700) 30%, + transparent); + --event-button-bg-admin: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-admin-hover: color-mix(in oklab, + var(--color-cyan-700) 30%, + transparent); --event-button-bg-not-going: var(--color-neutral-200); --event-button-bg-not-going-hover: var(--color-neutral-300); --event-button-bg-completed: var(--color-neutral-200); @@ -202,90 +186,58 @@ /* EVENT CARD BUTTONS */ --event-button-bg-not-applied: var(--color-cyan-800); --event-button-bg-not-applied-hover: var(--color-cyan-900); - --event-button-bg-under-review: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-under-review-hover: color-mix( - in oklab, - var(--color-cyan-700) 30%, - transparent - ); + --event-button-bg-under-review: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-under-review-hover: color-mix(in oklab, + var(--color-cyan-700) 30%, + transparent); --event-button-bg-accepted-attending: var(--color-green-700); --event-button-bg-accepted-attending-hover: var(--color-green-800); - --event-button-bg-accepted-not-going: color-mix( - in oklab, - var(--color-neutral-200) 25%, - transparent - ); - --event-button-bg-accepted-not-going-hover: color-mix( - in oklab, - var(--color-neutral-200) 30%, - transparent - ); - --event-button-bg-rejected: color-mix( - in oklab, - var(--color-neutral-200) 25%, - transparent - ); - --event-button-bg-rejected-hover: color-mix( - in oklab, - var(--color-neutral-200) 30%, - transparent - ); - --event-button-bg-attending: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-attending-hover: color-mix( - in oklab, - var(--color-cyan-700) 30%, - transparent - ); + --event-button-bg-accepted-not-going: color-mix(in oklab, + var(--color-neutral-200) 25%, + transparent); + --event-button-bg-accepted-not-going-hover: color-mix(in oklab, + var(--color-neutral-200) 30%, + transparent); + --event-button-bg-rejected: color-mix(in oklab, + var(--color-neutral-200) 25%, + transparent); + --event-button-bg-rejected-hover: color-mix(in oklab, + var(--color-neutral-200) 30%, + transparent); + --event-button-bg-attending: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-attending-hover: color-mix(in oklab, + var(--color-cyan-700) 30%, + transparent); --event-button-bg-waitlisted: #453a6b; --event-button-bg-waitlisted-hover: #413569; - --event-button-bg-staff: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-staff-hover: color-mix( - in oklab, - var(--color-cyan-700) 30%, - transparent - ); - --event-button-bg-admin: color-mix( - in oklab, - var(--color-cyan-700) 25%, - transparent - ); - --event-button-bg-admin-hover: color-mix( - in oklab, - var(--color-cyan-700) 30%, - transparent - ); - --event-button-bg-not-going: color-mix( - in oklab, - var(--color-neutral-200) 25%, - transparent - ); - --event-button-bg-not-going-hover: color-mix( - in oklab, - var(--color-neutral-200) 30%, - transparent - ); - --event-button-bg-completed: color-mix( - in oklab, - var(--color-neutral-200) 25%, - transparent - ); - --event-button-bg-completed-hover: color-mix( - in oklab, - var(--color-neutral-200) 30%, - transparent - ); + --event-button-bg-staff: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-staff-hover: color-mix(in oklab, + var(--color-cyan-700) 30%, + transparent); + --event-button-bg-admin: color-mix(in oklab, + var(--color-cyan-700) 25%, + transparent); + --event-button-bg-admin-hover: color-mix(in oklab, + var(--color-cyan-700) 30%, + transparent); + --event-button-bg-not-going: color-mix(in oklab, + var(--color-neutral-200) 25%, + transparent); + --event-button-bg-not-going-hover: color-mix(in oklab, + var(--color-neutral-200) 30%, + transparent); + --event-button-bg-completed: color-mix(in oklab, + var(--color-neutral-200) 25%, + transparent); + --event-button-bg-completed-hover: color-mix(in oklab, + var(--color-neutral-200) 30%, + transparent); --event-button-text-not-applied: var(--color-neutral-50); --event-button-text-under-review: var(--color-cyan-500); @@ -367,56 +319,32 @@ --color-badge-text-completed: var(--badge-text-completed); --color-event-button-bg-not-applied: var(--event-button-bg-not-applied); - --color-event-button-bg-not-applied-hover: var( - --event-button-bg-not-applied-hover - ); + --color-event-button-bg-not-applied-hover: var(--event-button-bg-not-applied-hover); --color-event-button-bg-under-review: var(--event-button-bg-under-review); - --color-event-button-bg-under-review-hover: var( - --event-button-bg-under-review-hover - ); - --color-event-button-bg-accepted-attending: var( - --event-button-bg-accepted-attending - ); - --color-event-button-bg-accepted-attending-hover: var( - --event-button-bg-accepted-attending-hover - ); - --color-event-button-bg-accepted-not-going: var( - --event-button-bg-accepted-not-going - ); - --color-event-button-bg-accepted-not-going-hover: var( - --event-button-bg-accepted-not-going-hover - ); + --color-event-button-bg-under-review-hover: var(--event-button-bg-under-review-hover); + --color-event-button-bg-accepted-attending: var(--event-button-bg-accepted-attending); + --color-event-button-bg-accepted-attending-hover: var(--event-button-bg-accepted-attending-hover); + --color-event-button-bg-accepted-not-going: var(--event-button-bg-accepted-not-going); + --color-event-button-bg-accepted-not-going-hover: var(--event-button-bg-accepted-not-going-hover); --color-event-button-bg-rejected: var(--event-button-bg-rejected); --color-event-button-bg-rejected-hover: var(--event-button-bg-rejected-hover); --color-event-button-bg-attending: var(--event-button-bg-attending); - --color-event-button-bg-attending-hover: var( - --event-button-bg-attending-hover - ); + --color-event-button-bg-attending-hover: var(--event-button-bg-attending-hover); --color-event-button-bg-waitlisted: var(--event-button-bg-waitlisted); - --color-event-button-bg-waitlisted-hover: var( - --event-button-bg-waitlisted-hover - ); + --color-event-button-bg-waitlisted-hover: var(--event-button-bg-waitlisted-hover); --color-event-button-bg-staff: var(--event-button-bg-staff); --color-event-button-bg-staff-hover: var(--event-button-bg-staff-hover); --color-event-button-bg-admin: var(--event-button-bg-admin); --color-event-button-bg-admin-hover: var(--event-button-bg-admin-hover); --color-event-button-bg-not-going: var(--event-button-bg-not-going); - --color-event-button-bg-not-going-hover: var( - --event-button-bg-not-going-hover - ); + --color-event-button-bg-not-going-hover: var(--event-button-bg-not-going-hover); --color-event-button-bg-completed: var(--event-button-bg-completed); - --color-event-button-bg-completed-hover: var( - --event-button-bg-completed-hover - ); + --color-event-button-bg-completed-hover: var(--event-button-bg-completed-hover); --color-event-button-text-not-applied: var(--event-button-text-not-applied); --color-event-button-text-under-review: var(--event-button-text-under-review); - --color-event-button-text-accepted-attending: var( - --event-button-text-accepted-attending - ); - --color-event-button-text-accepted-not-going: var( - --event-button-text-accepted-not-going - ); + --color-event-button-text-accepted-attending: var(--event-button-text-accepted-attending); + --color-event-button-text-accepted-not-going: var(--event-button-text-accepted-not-going); --color-event-button-text-rejected: var(--event-button-text-rejected); --color-event-button-text-attending: var(--event-button-text-attending); --color-event-button-text-waitlisted: var(--event-button-text-waitlisted); @@ -431,4 +359,4 @@ --color-input-border-disabled: var(--input-border-disabled); --color-input-text-error: var(--input-text-error); --color-input-text-disabled: var(--input-text-disabled); -} +} \ No newline at end of file diff --git a/shared/openapi/core-api.yaml b/shared/openapi/core-api.yaml index 03bc1e19..3059ce27 100644 --- a/shared/openapi/core-api.yaml +++ b/shared/openapi/core-api.yaml @@ -231,6 +231,147 @@ paths: operationId: post-event-interest tags: - Event + /event: + post: + summary: Create a new event + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - applicationOpen + - applicationClose + - startTime + - endTime + properties: + name: + type: string + example: "Open Software Club's Workshop" + applicationOpen: + type: string + format: date-time + example: 2025-08-01T08:00:00Z + applicationClose: + type: string + format: date-time + example: 2025-12-01T023:59:00Z + timeStart: + type: string + format: date-time + example: 2026-02-10T17:00:00Z + timeEnd: + type: string + format: date-time + example: 2026-02-12T08:00:00Z + description: + type: string + location: + type: string + location_url: + type: string + max_attendees: + type: integer + rsvp_deadline: + type: string + format: date-time + example: 2025-08-01T08:00:00Z + decision_release: + type: string + format: date-time + example: 2025-08-01T08:00:00Z + website_url: + type: string + is_published: + type: boolean + responses: + '201': + description: 'OK: Event created' + '400': + description: 'Bad request/Malformed request' + content: + application/json: + schema: '#/components/schemas/ErrorResponse' + examples: + Example 1: + value: + error: invalid_request + message: Could not parse request body + '409': + description: 'endTime is before startTime or applicationClose is before applicationOpen' + content: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + Example 1: + value: + error: time_conflict + message: endTime is before startTime or applicationClose is before applicationOpen + 5XX: + description: 'Server Error: Something went terribly wrong on our end.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + Example 1: + value: + error: internal_err + message: Something went wrong on our end. + operationId: post-event + tags: + - Event + /event/{eventId}: + get: + summary: Get an event + responses: + '201': + description: OK - Event recieved + '5XX': + description: 'Server Error: Something went terribly wrong on our end.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + Example 1: + value: + error: internal_err + message: Something went wrong on our end. + patch: + summary: Update an event + responses: + '204': + description: OK - Event updated (patched) + '5XX': + description: 'Server Error: Something went terribly wrong on our end.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + Example 1: + value: + error: internal_err + message: Something went wrong on our end. + delete: + summary: Delete an event + responses: + '204': + description: OK - Event deleted (patched) + '5XX': + description: 'Server Error: Something went terribly wrong on our end.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + Example 1: + value: + error: internal_err + message: Something went wrong on our end. components: schemas: ErrorResponse: