diff --git a/cmd/api/api.go b/cmd/api/api.go index e48d1ed8..976ad672 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -123,9 +123,9 @@ func (app *application) mount() http.Handler { } if len(allowedOrigins) > 0 { r.Use(cors.Handler(cors.Options{ - AllowedOrigins: allowedOrigins, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: append([]string{"Content-Type", "X-API-Key"}, supertokens.GetAllCORSHeaders()...), + AllowedOrigins: []string{app.config.frontendURL}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: append([]string{"Content-Type"}, supertokens.GetAllCORSHeaders()...), AllowCredentials: true, })) } @@ -252,6 +252,9 @@ func (app *application) mount() http.Handler { r.Patch("/{userID}/role", app.updateUserRoleHandler) }) + // Users + r.Patch("/users/role", app.batchUpdateRolesHandler) + }) }) }) diff --git a/cmd/api/applications.go b/cmd/api/applications.go index a31035b4..5e86b768 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -58,7 +58,7 @@ type ApplicationWithQuestions struct { // // @Summary Get or create application // @Description Returns the authenticated user's hackathon application. If no application exists, creates a new draft application. -// @Tags hackers +// @Tags applications // @Accept json // @Produce json // @Success 200 {object} store.Application @@ -119,7 +119,7 @@ func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r * // // @Summary Update application // @Description Partially updates the authenticated user's application. Only fields included in the request body are updated. Application must be in draft status. -// @Tags hackers +// @Tags applications // @Accept json // @Produce json // @Param application body UpdateApplicationPayload true "Fields to update" @@ -257,7 +257,7 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. // // @Summary Submit application // @Description Submits the authenticated user's application for review. All required fields must be filled and acknowledgments must be accepted. Application must be in draft status. -// @Tags hackers +// @Tags applications // @Produce json // @Success 200 {object} store.Application // @Failure 400 {object} object{error=string} "Missing required fields" @@ -394,7 +394,7 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. // // @Summary Get application stats (Admin) // @Description Returns aggregated statistics for all applications -// @Tags admin/applications +// @Tags admin // @Produce json // @Success 200 {object} store.ApplicationStats // @Failure 401 {object} object{error=string} @@ -418,13 +418,12 @@ func (app *application) getApplicationStatsHandler(w http.ResponseWriter, r *htt // // @Summary List applications (Admin) // @Description Lists all applications with cursor-based pagination and optional status filter -// @Tags admin/applications +// @Tags admin // @Produce json // @Param cursor query string false "Pagination cursor" // @Param status query string false "Filter by status (draft, submitted, accepted, rejected, waitlisted)" // @Param limit query int false "Page size (default 50, max 100)" // @Param direction query string false "Pagination direction: forward (default) or backward" -// @Param sort_by query string false "Sort column: created_at (default), accept_votes, reject_votes, waitlist_votes" // @Success 200 {object} store.ApplicationListResult // @Failure 400 {object} object{error=string} // @Failure 401 {object} object{error=string} @@ -542,7 +541,7 @@ type EmailListResponse struct { // // @Summary Set application status (Super Admin) // @Description Sets the final status (accepted, rejected, or waitlisted) on an application -// @Tags superadmin/applications +// @Tags superadmin // @Accept json // @Produce json // @Param applicationID path string true "Application ID" @@ -592,7 +591,7 @@ func (app *application) setApplicationStatus(w http.ResponseWriter, r *http.Requ // // @Summary Get application by ID (Admin) // @Description Returns a single application by its ID with embedded short answer questions -// @Tags admin/applications +// @Tags admin // @Produce json // @Param applicationID path string true "Application ID" // @Success 200 {object} ApplicationWithQuestions @@ -641,7 +640,7 @@ func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { // // @Summary Get applicant emails by status (Super Admin) // @Description Returns a list of applicant emails filtered by application status (accepted, rejected, or waitlisted) -// @Tags superadmin/applications +// @Tags superadmin // @Produce json // @Param status query string true "Application status (accepted, rejected, or waitlisted)" // @Success 200 {object} EmailListResponse diff --git a/cmd/api/reviews.go b/cmd/api/reviews.go index 3859963c..263d3b5e 100644 --- a/cmd/api/reviews.go +++ b/cmd/api/reviews.go @@ -41,7 +41,7 @@ type AIPercentResponse struct { // // @Summary Get pending reviews (Admin) // @Description Returns all reviews assigned to the current admin that haven't been voted on yet, including application details -// @Tags admin/reviews +// @Tags admin // @Produce json // @Success 200 {object} PendingReviewsListResponse // @Failure 401 {object} object{error=string} @@ -71,7 +71,7 @@ func (app *application) getPendingReviews(w http.ResponseWriter, r *http.Request // // @Summary Get completed reviews (Admin) // @Description Returns all reviews the current admin has completed (voted on), including application details -// @Tags admin/reviews +// @Tags admin // @Produce json // @Success 200 {object} CompletedReviewsListResponse // @Failure 401 {object} object{error=string} @@ -101,7 +101,7 @@ func (app *application) getCompletedReviews(w http.ResponseWriter, r *http.Reque // // @Summary Get notes for an application (Admin) // @Description Returns all reviewer notes for a specific application without exposing votes -// @Tags admin/applications +// @Tags admin // @Produce json // @Param applicationID path string true "Application ID" // @Success 200 {object} NotesListResponse @@ -137,7 +137,7 @@ func (app *application) getApplicationNotes(w http.ResponseWriter, r *http.Reque // // @Summary Batch assign reviews (SuperAdmin) // @Description Finds all submitted applications needing more reviews and assigns them to admins using workload balancing -// @Tags superadmin/applications +// @Tags superadmin // @Produce json // @Success 200 {object} store.BatchAssignmentResult // @Failure 401 {object} object{error=string} @@ -167,7 +167,7 @@ func (app *application) batchAssignReviews(w http.ResponseWriter, r *http.Reques // // @Summary Get next review assignment (Admin) // @Description Automatically assigns the next submitted application needing review to the current admin and returns it -// @Tags admin/reviews +// @Tags admin // @Produce json // @Success 200 {object} ReviewResponse // @Failure 401 {object} object{error=string} @@ -209,7 +209,7 @@ func (app *application) getNextReview(w http.ResponseWriter, r *http.Request) { // // @Summary Submit vote on a review (Admin) // @Description Records the admin's vote (accept/reject/waitlist) on an assigned application review -// @Tags admin/reviews +// @Tags admin // @Accept json // @Produce json // @Param reviewID path string true "Review ID" diff --git a/cmd/api/scans.go b/cmd/api/scans.go index 62064c20..5071d303 100644 --- a/cmd/api/scans.go +++ b/cmd/api/scans.go @@ -57,7 +57,7 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque // // @Summary Create a scan (Admin) // @Description Records a scan for a user. Validates scan type exists and is active. Non-check_in scans require the user to have checked in first. -// @Tags admin/scans +// @Tags admin // @Accept json // @Produce json // @Param scan body CreateScanPayload true "Scan to create" @@ -157,7 +157,7 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request // // @Summary Get scans for a user (Admin) // @Description Returns all scan records for the specified user, ordered by most recent first -// @Tags admin/scans +// @Tags admin // @Produce json // @Param userID path string true "User ID" // @Success 200 {object} ScansResponse @@ -189,7 +189,7 @@ func (app *application) getUserScansHandler(w http.ResponseWriter, r *http.Reque // // @Summary Get scan statistics (Admin) // @Description Returns aggregate scan counts grouped by scan type -// @Tags admin/scans +// @Tags admin // @Produce json // @Success 200 {object} ScanStatsResponse // @Failure 401 {object} object{error=string} @@ -213,7 +213,7 @@ func (app *application) getScanStatsHandler(w http.ResponseWriter, r *http.Reque // // @Summary Update scan types (Super Admin) // @Description Replaces all scan types with the provided array. Must include at least one check_in category type. Names must be unique. -// @Tags superadmin/settings +// @Tags superadmin // @Accept json // @Produce json // @Param scan_types body UpdateScanTypesPayload true "Scan types to set" diff --git a/cmd/api/settings.go b/cmd/api/settings.go index 4a4163de..d2b550eb 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -20,7 +20,7 @@ type ShortAnswerQuestionsResponse struct { // // @Summary Get short answer questions (Super Admin) // @Description Returns all configurable short answer questions for hacker applications -// @Tags superadmin/settings +// @Tags superadmin // @Produce json // @Success 200 {object} ShortAnswerQuestionsResponse // @Failure 401 {object} object{error=string} @@ -48,7 +48,7 @@ func (app *application) getShortAnswerQuestions(w http.ResponseWriter, r *http.R // // @Summary Update short answer questions (Super Admin) // @Description Replaces all short answer questions with the provided array -// @Tags superadmin/settings +// @Tags superadmin // @Accept json // @Produce json // @Param questions body UpdateShortAnswerQuestionsPayload true "Questions to set" @@ -105,7 +105,7 @@ type ReviewsPerAppResponse struct { // // @Summary Get reviews per application (Super Admin) // @Description Returns the number of reviews required per application -// @Tags superadmin/settings +// @Tags superadmin // @Produce json // @Success 200 {object} ReviewsPerAppResponse // @Failure 401 {object} object{error=string} @@ -133,7 +133,7 @@ func (app *application) getReviewsPerApp(w http.ResponseWriter, r *http.Request) // // @Summary Set reviews per application (Super Admin) // @Description Sets the number of reviews required per application -// @Tags superadmin/settings +// @Tags superadmin // @Accept json // @Produce json // @Param reviews_per_application body SetReviewsPerAppPayload true "Reviews per application value" diff --git a/cmd/api/users.go b/cmd/api/users.go new file mode 100644 index 00000000..0f6264b6 --- /dev/null +++ b/cmd/api/users.go @@ -0,0 +1,73 @@ +package main + +import ( + "errors" + "net/http" + + "github.com/hackutd/portal/internal/store" +) + +type BatchUpdateRolesPayload struct { + UserIDs []string `json:"user_ids" validate:"required,min=1,max=50,dive,uuid"` + Role store.UserRole `json:"role" validate:"required,oneof=hacker admin"` +} + +type BatchUpdateRolesResponse struct { + Users []*store.User `json:"users"` +} + +// batchUpdateRolesHandler updates the role for a batch of users +// +// @Summary Batch update user roles (Super Admin) +// @Description Updates the role for up to 50 users. Cannot modify own role. +// @Tags superadmin +// @Accept json +// @Produce json +// @Param payload body BatchUpdateRolesPayload true "User IDs and target role" +// @Success 200 {object} BatchUpdateRolesResponse +// @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/users/role [patch] +func (app *application) batchUpdateRolesHandler(w http.ResponseWriter, r *http.Request) { + var req BatchUpdateRolesPayload + if err := readJSON(w, r, &req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + if err := Validate.Struct(req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + caller := getUserFromContext(r.Context()) + if caller == nil { + app.internalServerError(w, r, errors.New("user not in context")) + return + } + + for _, id := range req.UserIDs { + if id == caller.ID { + app.badRequestResponse(w, r, errors.New("cannot modify your own role")) + return + } + } + + users, err := app.store.Users.BatchUpdateRoles(r.Context(), req.UserIDs, req.Role) + if err != nil { + app.internalServerError(w, r, err) + return + } + + if len(users) != len(req.UserIDs) { + app.badRequestResponse(w, r, errors.New("one or more user IDs do not exist")) + return + } + + if err := app.jsonResponse(w, http.StatusOK, BatchUpdateRolesResponse{Users: users}); err != nil { + app.internalServerError(w, r, err) + } +} diff --git a/cmd/api/users_test.go b/cmd/api/users_test.go new file mode 100644 index 00000000..231431bd --- /dev/null +++ b/cmd/api/users_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/hackutd/portal/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testUUID1 = "11111111-1111-1111-1111-111111111111" + testUUID2 = "22222222-2222-2222-2222-222222222222" + superAdminUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + nonexistentUUID = "99999999-9999-9999-9999-999999999999" +) + +func newSuperAdminUserWithUUID() *store.User { + return &store.User{ + ID: superAdminUUID, + SuperTokensUserID: "st-superadmin-uuid", + Email: "superadmin@test.com", + Role: store.RoleSuperAdmin, + AuthMethod: store.AuthMethodPasswordless, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func TestBatchUpdateRoles(t *testing.T) { + app := newTestApplication(t) + mockUsers := app.store.Users.(*store.MockUsersStore) + + superAdmin := newSuperAdminUserWithUUID() + + t.Run("should update roles successfully", func(t *testing.T) { + updated := []*store.User{ + {ID: testUUID1, Email: "a@test.com", Role: store.RoleAdmin, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: testUUID2, Email: "b@test.com", Role: store.RoleAdmin, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + mockUsers.On("BatchUpdateRoles", []string{testUUID1, testUUID2}, store.RoleAdmin).Return(updated, nil).Once() + + body := `{"user_ids":["` + testUUID1 + `","` + testUUID2 + `"],"role":"admin"}` + req, err := http.NewRequest(http.MethodPatch, "/v1/superadmin/users/role", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, superAdmin) + + rr := executeRequest(req, http.HandlerFunc(app.batchUpdateRolesHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var resp struct { + Data BatchUpdateRolesResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Len(t, resp.Data.Users, 2) + + mockUsers.AssertExpectations(t) + }) + + t.Run("should return 400 when role is super_admin", func(t *testing.T) { + body := `{"user_ids":["` + testUUID1 + `"],"role":"super_admin"}` + req, err := http.NewRequest(http.MethodPatch, "/v1/superadmin/users/role", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, superAdmin) + + rr := executeRequest(req, http.HandlerFunc(app.batchUpdateRolesHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("should return 400 when user_ids is empty", func(t *testing.T) { + body := `{"user_ids":[],"role":"admin"}` + req, err := http.NewRequest(http.MethodPatch, "/v1/superadmin/users/role", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, superAdmin) + + rr := executeRequest(req, http.HandlerFunc(app.batchUpdateRolesHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("should return 400 when caller tries to modify own role", func(t *testing.T) { + body := `{"user_ids":["` + superAdminUUID + `","` + testUUID2 + `"],"role":"hacker"}` + req, err := http.NewRequest(http.MethodPatch, "/v1/superadmin/users/role", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, superAdmin) + + rr := executeRequest(req, http.HandlerFunc(app.batchUpdateRolesHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + var errBody struct { + Error string `json:"error"` + } + err = json.NewDecoder(rr.Body).Decode(&errBody) + require.NoError(t, err) + assert.Contains(t, errBody.Error, "own role") + }) + + t.Run("should return 400 when a user ID does not exist", func(t *testing.T) { + // Store returns fewer users than requested (one ID didn't match) + updated := []*store.User{ + {ID: testUUID1, Email: "a@test.com", Role: store.RoleHacker, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + mockUsers.On("BatchUpdateRoles", []string{testUUID1, nonexistentUUID}, store.RoleHacker).Return(updated, nil).Once() + + body := `{"user_ids":["` + testUUID1 + `","` + nonexistentUUID + `"],"role":"hacker"}` + req, err := http.NewRequest(http.MethodPatch, "/v1/superadmin/users/role", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, superAdmin) + + rr := executeRequest(req, http.HandlerFunc(app.batchUpdateRolesHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + mockUsers.AssertExpectations(t) + }) +} diff --git a/docs/docs.go b/docs/docs.go index 9ee77088..f896f599 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3139,6 +3139,89 @@ const docTemplate = `{ } } } + }, + "/superadmin/users/role": { + "patch": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Updates the role for up to 50 users. Cannot modify own role.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "superadmin" + ], + "summary": "Batch update user roles (Super Admin)", + "parameters": [ + { + "description": "User IDs and target role", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.BatchUpdateRolesPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.BatchUpdateRolesResponse" + } + }, + "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" + } + } + } + } + } + } } }, "definitions": { @@ -3326,6 +3409,45 @@ const docTemplate = `{ } } }, + "main.BatchUpdateRolesPayload": { + "type": "object", + "required": [ + "role", + "user_ids" + ], + "properties": { + "role": { + "enum": [ + "hacker", + "admin" + ], + "allOf": [ + { + "$ref": "#/definitions/store.UserRole" + } + ] + }, + "user_ids": { + "type": "array", + "maxItems": 50, + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "main.BatchUpdateRolesResponse": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/store.User" + } + } + } + }, "main.CheckEmailResponse": { "type": "object", "properties": { @@ -4344,29 +4466,55 @@ const docTemplate = `{ } } }, - "store.UserListItem": { + "store.User": { "type": "object", + "required": [ + "auth_method", + "email", + "role", + "supertokens_user_id" + ], "properties": { + "auth_method": { + "enum": [ + "passwordless", + "google" + ], + "allOf": [ + { + "$ref": "#/definitions/store.AuthMethod" + } + ] + }, "created_at": { "type": "string" }, "email": { "type": "string" }, - "first_name": { - "type": "string" - }, "id": { "type": "string" }, - "last_name": { - "type": "string" - }, "profile_picture_url": { "type": "string" }, "role": { - "$ref": "#/definitions/store.UserRole" + "enum": [ + "hacker", + "admin", + "super_admin" + ], + "allOf": [ + { + "$ref": "#/definitions/store.UserRole" + } + ] + }, + "supertokens_user_id": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index c01e027e..117dfb0e 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -45,6 +45,14 @@ func (m *MockUsersStore) UpdateProfilePicture(ctx context.Context, supertokensUs return args.Error(0) } +func (m *MockUsersStore) BatchUpdateRoles(ctx context.Context, userIDs []string, role UserRole) ([]*User, error) { + args := m.Called(userIDs, role) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*User), args.Error(1) +} + func (m *MockUsersStore) Search(ctx context.Context, query string, limit int, offset int) (*UserSearchResult, error) { args := m.Called(query, limit, offset) if args.Get(0) == nil { diff --git a/internal/store/storage.go b/internal/store/storage.go index e01a8965..c6bd4b67 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -22,6 +22,7 @@ type Storage struct { GetByEmail(ctx context.Context, email string) (*User, error) Create(ctx context.Context, user *User) error UpdateProfilePicture(ctx context.Context, supertokensUserID string, pictureURL *string) error + BatchUpdateRoles(ctx context.Context, userIDs []string, role UserRole) ([]*User, error) Search(ctx context.Context, query string, limit int, offset int) (*UserSearchResult, error) UpdateRole(ctx context.Context, userID string, role UserRole) (*User, error) GetByRole(ctx context.Context, role UserRole) ([]User, error) diff --git a/internal/store/users.go b/internal/store/users.go index ed7c5541..c3101298 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -225,6 +225,48 @@ func (s *UsersStore) GetByEmail(ctx context.Context, email string) (*User, error return &user, nil } +func (s *UsersStore) BatchUpdateRoles(ctx context.Context, userIDs []string, role UserRole) ([]*User, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + UPDATE users + SET role = $1, updated_at = NOW() + WHERE id = ANY($2) + RETURNING id, supertokens_user_id, email, role, auth_method, profile_picture_url, created_at, updated_at + ` + + rows, err := s.db.QueryContext(ctx, query, role, userIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []*User + for rows.Next() { + var u User + if err := rows.Scan( + &u.ID, + &u.SuperTokensUserID, + &u.Email, + &u.Role, + &u.AuthMethod, + &u.ProfilePictureURL, + &u.CreatedAt, + &u.UpdatedAt, + ); err != nil { + return nil, err + } + users = append(users, &u) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return users, nil +} + // UserListItem is a lightweight user view for search results type UserListItem struct { ID string `json:"id"`