diff --git a/cmd/api/api.go b/cmd/api/api.go index e48d1ed8..66b54206 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -182,6 +182,7 @@ func (app *application) mount() http.Handler { r.Route("/applications", func(r chi.Router) { r.Get("/", app.listApplicationsHandler) r.Get("/stats", app.getApplicationStatsHandler) + r.Get("/enabled", app.getApplicationsEnabled) r.Get("/{applicationID}", app.getApplication) r.Get("/{applicationID}/resume-url", app.getResumeDownloadURLHandler) @@ -238,6 +239,7 @@ func (app *application) mount() http.Handler { r.Get("/hackathon-date-range", app.getHackathonDateRange) r.Post("/hackathon-date-range", app.setHackathonDateRange) r.Put("/scan-types", app.updateScanTypesHandler) + r.Put("/applications-enabled", app.setApplicationsEnabled) }) r.Route("/applications", func(r chi.Router) { diff --git a/cmd/api/applications.go b/cmd/api/applications.go index a31035b4..13c4eea6 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -538,6 +538,10 @@ type EmailListResponse struct { Count int `json:"count"` } +type ApplicationsEnabledResponse struct { + Enabled bool `json:"enabled"` +} + // setApplicationStatus sets the final status on an application // // @Summary Set application status (Super Admin) diff --git a/cmd/api/settings.go b/cmd/api/settings.go index 4a4163de..3c1e8906 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -3,6 +3,7 @@ package main import ( "errors" "net/http" + "strconv" "time" "github.com/hackutd/portal/internal/store" @@ -414,3 +415,70 @@ func (app *application) setHackathonDateRange(w http.ResponseWriter, r *http.Req app.internalServerError(w, r, err) } } + +// getApplicationsEnabled returns whether applications are currently open +// +// @Summary Get applications enabled status +// @Description Returns whether the application portal is currently open for submissions +// @Tags applications +// @Produce json +// @Success 200 {object} ApplicationsEnabledResponse +// @Failure 401 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /applications/enabled [get] +func (app *application) getApplicationsEnabled(w http.ResponseWriter, r *http.Request) { + enabled, err := app.store.Application.GetApplicationsEnabled(r.Context()) + if err != nil { + app.internalServerError(w, r, err) + return + } + + response := ApplicationsEnabledResponse{ + Enabled: enabled, + } + + if err = app.jsonResponse(w, http.StatusOK, response); err != nil { + app.internalServerError(w, r, err) + return + } +} + +// setApplicationsEnabled updates whether applications are currently open +// +// @Summary Set applications enabled status +// @Description Sets whether the application portal is currently open for submissions. Requires SuperAdmin privileges. +// @Tags superadmin +// @Produce json +// @Param enabled query bool true "Enable or disable applications" +// @Success 200 {object} ApplicationsEnabledResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/applications-enabled [put] +func (app *application) setApplicationsEnabled(w http.ResponseWriter, r *http.Request) { + enabled, err := strconv.ParseBool(r.URL.Query().Get("enabled")) + + if err != nil { + app.badRequestResponse(w, r, errors.New("enabled must be a boolean value")) + return + } + + enabled, err = app.store.Application.SetApplicationsEnabled(r.Context(), enabled) + if err != nil { + app.internalServerError(w, r, err) + return + } + + //NOTE: Following existing design pattern of Get response and Set response structs + response := ApplicationsEnabledResponse{ + Enabled: enabled, + } + + if err = app.jsonResponse(w, http.StatusOK, response); err != nil { + app.internalServerError(w, r, err) + return + } +} diff --git a/cmd/migrate/migrations/000016_create_applications_enabled_setting.down.sql b/cmd/migrate/migrations/000016_create_applications_enabled_setting.down.sql new file mode 100644 index 00000000..00ab13be --- /dev/null +++ b/cmd/migrate/migrations/000016_create_applications_enabled_setting.down.sql @@ -0,0 +1 @@ +DELETE FROM settings WHERE key = 'applications_enabled'; diff --git a/cmd/migrate/migrations/000016_create_applications_enabled_setting.up.sql b/cmd/migrate/migrations/000016_create_applications_enabled_setting.up.sql new file mode 100644 index 00000000..ee3eec9d --- /dev/null +++ b/cmd/migrate/migrations/000016_create_applications_enabled_setting.up.sql @@ -0,0 +1,2 @@ +INSERT INTO settings (key, value) VALUES ('applications_enabled', 'true'::jsonb) +ON CONFLICT (key) DO NOTHING; diff --git a/cmd/migrate/migrations/000016_insert_review_assignment_setting.down.sql b/cmd/migrate/migrations/000023_insert_review_assignment_setting.down.sql similarity index 100% rename from cmd/migrate/migrations/000016_insert_review_assignment_setting.down.sql rename to cmd/migrate/migrations/000023_insert_review_assignment_setting.down.sql diff --git a/cmd/migrate/migrations/000016_insert_review_assignment_setting.up.sql b/cmd/migrate/migrations/000023_insert_review_assignment_setting.up.sql similarity index 100% rename from cmd/migrate/migrations/000016_insert_review_assignment_setting.up.sql rename to cmd/migrate/migrations/000023_insert_review_assignment_setting.up.sql diff --git a/docs/docs.go b/docs/docs.go index 9ee77088..3176fc21 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1482,6 +1482,53 @@ const docTemplate = `{ } } }, + "/applications/enabled": { + "get": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Returns whether the application portal is currently open for submissions", + "produces": [ + "application/json" + ], + "tags": [ + "applications" + ], + "summary": "Get applications enabled status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.ApplicationsEnabledResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/applications/me": { "get": { "security": [ @@ -2366,6 +2413,84 @@ const docTemplate = `{ } } }, + "/superadmin/settings/applications-enabled": { + "put": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Sets whether the application portal is currently open for submissions. Requires SuperAdmin privileges.", + "produces": [ + "application/json" + ], + "tags": [ + "superadmin" + ], + "summary": "Set applications enabled status", + "parameters": [ + { + "type": "boolean", + "description": "Enable or disable applications", + "name": "enabled", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.ApplicationsEnabledResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/superadmin/settings/hackathon-date-range": { "get": { "security": [ @@ -3326,6 +3451,14 @@ const docTemplate = `{ } } }, + "main.ApplicationsEnabledResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "main.CheckEmailResponse": { "type": "object", "properties": { diff --git a/internal/store/applications.go b/internal/store/applications.go index f310af7c..544258fe 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" "time" ) @@ -806,3 +807,39 @@ func (s *ApplicationsStore) GetEmailsByStatus(ctx context.Context, status Applic return users, rows.Err() } + +func (s *ApplicationsStore) GetApplicationsEnabled(ctx context.Context) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT value + FROM settings + WHERE key = 'applications_enabled' + ` + var value bool + err := s.db.QueryRowContext(ctx, query).Scan(&value) + if err != nil { + return false, err // We won't handle err here, (because if the setting doesn't exist, we want it to error instead of defaulting to false) + } + return value, nil +} + +func (s *ApplicationsStore) SetApplicationsEnabled(ctx context.Context, enabled bool) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + UPDATE settings + SET value = $1::jsonb + WHERE key = 'applications_enabled' + RETURNING value` + + var value bool + err := s.db.QueryRowContext(ctx, query, strconv.FormatBool(enabled)).Scan(&value) + if err != nil { + return value, err + } + + return value, nil +} diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index c01e027e..ef81aff2 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -145,6 +145,16 @@ func (m *MockApplicationStore) GetEmailsByStatus(ctx context.Context, status App return args.Get(0).([]UserEmailInfo), args.Error(1) } +func (m *MockApplicationStore) GetApplicationsEnabled(ctx context.Context) (bool, error) { + args := m.Called() + return args.Get(0).(bool), args.Error(1) +} + +func (m *MockApplicationStore) SetApplicationsEnabled(ctx context.Context, enabled bool) (bool, error) { + args := m.Called() + return args.Get(0).(bool), args.Error(1) +} + // mock implementation of the Settings interface type MockSettingsStore struct { mock.Mock diff --git a/internal/store/storage.go b/internal/store/storage.go index e01a8965..f3b773df 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -37,6 +37,8 @@ type Storage struct { GetStats(ctx context.Context) (*ApplicationStats, error) SetStatus(ctx context.Context, id string, status ApplicationStatus) (*Application, error) GetEmailsByStatus(ctx context.Context, status ApplicationStatus) ([]UserEmailInfo, error) + GetApplicationsEnabled(ctx context.Context) (bool, error) + SetApplicationsEnabled(ctx context.Context, enabled bool) (bool, error) } Settings interface { GetShortAnswerQuestions(ctx context.Context) ([]ShortAnswerQuestion, error)