From fe9a0ec2b4b44ac83978296f4c255c5e14a3700c Mon Sep 17 00:00:00 2001 From: Tharun Sevvel Date: Thu, 26 Mar 2026 00:13:10 -0500 Subject: [PATCH 1/2] Added super admin batch update route --- cmd/api/api.go | 5 +- cmd/api/applications.go | 196 +++++++++++++++++------------------ cmd/api/auth.go | 34 +++--- cmd/api/reviews.go | 136 ++++++++++++------------ cmd/api/scans.go | 98 +++++++++--------- cmd/api/settings.go | 92 ++++++++-------- cmd/api/users.go | 73 +++++++++++++ cmd/api/users_test.go | 105 +++++++++++++++++++ docs/docs.go | 174 +++++++++++++++++++++++++++++++ internal/store/mock_store.go | 8 ++ internal/store/storage.go | 1 + internal/store/users.go | 42 ++++++++ 12 files changed, 685 insertions(+), 279 deletions(-) create mode 100644 cmd/api/users.go create mode 100644 cmd/api/users_test.go diff --git a/cmd/api/api.go b/cmd/api/api.go index b9ee86cd..4364228f 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -87,7 +87,7 @@ func (app *application) mount() http.Handler { if app.config.frontendURL != app.config.appURL { r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{app.config.frontendURL}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: append([]string{"Content-Type"}, supertokens.GetAllCORSHeaders()...), AllowCredentials: true, })) @@ -174,6 +174,9 @@ func (app *application) mount() http.Handler { // Scans Config r.Put("/settings/scan-types", app.updateScanTypesHandler) + // Users + r.Patch("/users/role", app.batchUpdateRolesHandler) + // Emails r.Post("/emails/qr", app.sendQREmailsHandler) }) diff --git a/cmd/api/applications.go b/cmd/api/applications.go index 33e90f28..9786d342 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -55,16 +55,16 @@ type ApplicationWithQuestions struct { // getOrCreateApplicationHandler returns or creates the user's hackathon application // -// @Summary Get or create application -// @Description Returns the authenticated user's hackathon application. If no application exists, creates a new draft application. -// @Tags applications -// @Accept json -// @Produce json -// @Success 200 {object} store.Application -// @Failure 401 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /applications/me [get] +// @Summary Get or create application +// @Description Returns the authenticated user's hackathon application. If no application exists, creates a new draft application. +// @Tags applications +// @Accept json +// @Produce json +// @Success 200 {object} store.Application +// @Failure 401 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /applications/me [get] func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -116,19 +116,19 @@ func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r * // updateApplicationHandler partially updates the authenticated user's application // -// @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 applications -// @Accept json -// @Produce json -// @Param application body UpdateApplicationPayload true "Fields to update" -// @Success 200 {object} store.Application -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 409 {object} object{error=string} "Application not in draft status" -// @Security CookieAuth -// @Router /applications/me [patch] +// @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 applications +// @Accept json +// @Produce json +// @Param application body UpdateApplicationPayload true "Fields to update" +// @Success 200 {object} store.Application +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 409 {object} object{error=string} "Application not in draft status" +// @Security CookieAuth +// @Router /applications/me [patch] func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -251,17 +251,17 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. // submitApplicationHandler submits the authenticated user's application for review // -// @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 applications -// @Produce json -// @Success 200 {object} store.Application -// @Failure 400 {object} object{error=string} "Missing required fields" -// @Failure 401 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 409 {object} object{error=string} "Application not in draft status" -// @Security CookieAuth -// @Router /applications/me/submit [post] +// @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 applications +// @Produce json +// @Success 200 {object} store.Application +// @Failure 400 {object} object{error=string} "Missing required fields" +// @Failure 401 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 409 {object} object{error=string} "Application not in draft status" +// @Security CookieAuth +// @Router /applications/me/submit [post] func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -388,16 +388,16 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. // getApplicationStatsHandler returns aggregated statistics for all applications // -// @Summary Get application stats (Admin) -// @Description Returns aggregated statistics for all applications -// @Tags admin -// @Produce json -// @Success 200 {object} store.ApplicationStats -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/applications/stats [get] +// @Summary Get application stats (Admin) +// @Description Returns aggregated statistics for all applications +// @Tags admin +// @Produce json +// @Success 200 {object} store.ApplicationStats +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/applications/stats [get] func (app *application) getApplicationStatsHandler(w http.ResponseWriter, r *http.Request) { stats, err := app.store.Application.GetStats(r.Context()) if err != nil { @@ -412,21 +412,21 @@ func (app *application) getApplicationStatsHandler(w http.ResponseWriter, r *htt // listApplicationsHandler lists all applications with cursor-based pagination // -// @Summary List applications (Admin) -// @Description Lists all applications with cursor-based pagination and optional status filter -// @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" -// @Success 200 {object} store.ApplicationListResult -// @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 /admin/applications [get] +// @Summary List applications (Admin) +// @Description Lists all applications with cursor-based pagination and optional status filter +// @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" +// @Success 200 {object} store.ApplicationListResult +// @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 /admin/applications [get] func (app *application) listApplicationsHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() @@ -504,21 +504,21 @@ type EmailListResponse struct { // setApplicationStatus sets the final status on an application // -// @Summary Set application status (Super Admin) -// @Description Sets the final status (accepted, rejected, or waitlisted) on an application -// @Tags superadmin -// @Accept json -// @Produce json -// @Param applicationID path string true "Application ID" -// @Param status body SetStatusPayload true "New status" -// @Success 200 {object} ApplicationResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/applications/{applicationID}/status [patch] +// @Summary Set application status (Super Admin) +// @Description Sets the final status (accepted, rejected, or waitlisted) on an application +// @Tags superadmin +// @Accept json +// @Produce json +// @Param applicationID path string true "Application ID" +// @Param status body SetStatusPayload true "New status" +// @Success 200 {object} ApplicationResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/applications/{applicationID}/status [patch] func (app *application) setApplicationStatus(w http.ResponseWriter, r *http.Request) { applicationID := chi.URLParam(r, "applicationID") if applicationID == "" { @@ -554,18 +554,18 @@ func (app *application) setApplicationStatus(w http.ResponseWriter, r *http.Requ // getApplication returns a single application by ID with embedded questions // -// @Summary Get application by ID (Admin) -// @Description Returns a single application by its ID with embedded short answer questions -// @Tags admin -// @Produce json -// @Param applicationID path string true "Application ID" -// @Success 200 {object} ApplicationWithQuestions -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/applications/{applicationID} [get] +// @Summary Get application by ID (Admin) +// @Description Returns a single application by its ID with embedded short answer questions +// @Tags admin +// @Produce json +// @Param applicationID path string true "Application ID" +// @Success 200 {object} ApplicationWithQuestions +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/applications/{applicationID} [get] func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { applicationID := chi.URLParam(r, "applicationID") if applicationID == "" { @@ -603,18 +603,18 @@ func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { // getApplicantEmailsByStatusHandler returns applicant emails filtered by status // -// @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 -// @Produce json -// @Param status query string true "Application status (accepted, rejected, or waitlisted)" -// @Success 200 {object} EmailListResponse -// @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/applications/emails [get] +// @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 +// @Produce json +// @Param status query string true "Application status (accepted, rejected, or waitlisted)" +// @Success 200 {object} EmailListResponse +// @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/applications/emails [get] func (app *application) getApplicantEmailsByStatusHandler(w http.ResponseWriter, r *http.Request) { statusStr := r.URL.Query().Get("status") if statusStr == "" { diff --git a/cmd/api/auth.go b/cmd/api/auth.go index 94bfd0ca..0013bbd8 100644 --- a/cmd/api/auth.go +++ b/cmd/api/auth.go @@ -20,14 +20,14 @@ type UserResponse struct { // getCurrentUserHandler returns the authenticated user's profile // -// @Summary Get current user -// @Description Returns the authenticated user's profile -// @Tags auth -// @Accept json -// @Produce json -// @Success 200 {object} UserResponse -// @Failure 401 {object} object{error=string} -// @Router /auth/me [get] +// @Summary Get current user +// @Description Returns the authenticated user's profile +// @Tags auth +// @Accept json +// @Produce json +// @Success 200 {object} UserResponse +// @Failure 401 {object} object{error=string} +// @Router /auth/me [get] func (app *application) getCurrentUserHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -57,15 +57,15 @@ type CheckEmailResponse struct { // checkEmailAuthMethodHandler checks if an email is registered and returns the auth method // -// @Summary Check email auth method -// @Description Checks if an email is registered and returns the auth method used -// @Tags auth -// @Accept json -// @Produce json -// @Param email query string true "Email address to check" -// @Success 200 {object} CheckEmailResponse -// @Failure 400 {object} object{error=string} -// @Router /auth/check-email [get] +// @Summary Check email auth method +// @Description Checks if an email is registered and returns the auth method used +// @Tags auth +// @Accept json +// @Produce json +// @Param email query string true "Email address to check" +// @Success 200 {object} CheckEmailResponse +// @Failure 400 {object} object{error=string} +// @Router /auth/check-email [get] func (app *application) checkEmailAuthMethodHandler(w http.ResponseWriter, r *http.Request) { email := r.URL.Query().Get("email") if email == "" { diff --git a/cmd/api/reviews.go b/cmd/api/reviews.go index df55b87c..624dc309 100644 --- a/cmd/api/reviews.go +++ b/cmd/api/reviews.go @@ -31,16 +31,16 @@ type NotesListResponse struct { // getPendingReviews returns all pending reviews assigned to the current admin // -// @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 -// @Produce json -// @Success 200 {object} PendingReviewsListResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/reviews/pending [get] +// @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 +// @Produce json +// @Success 200 {object} PendingReviewsListResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/reviews/pending [get] func (app *application) getPendingReviews(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) @@ -61,16 +61,16 @@ func (app *application) getPendingReviews(w http.ResponseWriter, r *http.Request // getCompletedReviews returns all reviews the current admin has completed // -// @Summary Get completed reviews (Admin) -// @Description Returns all reviews the current admin has completed (voted on), including application details -// @Tags admin -// @Produce json -// @Success 200 {object} CompletedReviewsListResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/reviews/completed [get] +// @Summary Get completed reviews (Admin) +// @Description Returns all reviews the current admin has completed (voted on), including application details +// @Tags admin +// @Produce json +// @Success 200 {object} CompletedReviewsListResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/reviews/completed [get] func (app *application) getCompletedReviews(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) @@ -91,18 +91,18 @@ func (app *application) getCompletedReviews(w http.ResponseWriter, r *http.Reque // getApplicationNotes returns all reviewer notes for a specific application // -// @Summary Get notes for an application (Admin) -// @Description Returns all reviewer notes for a specific application without exposing votes -// @Tags admin -// @Produce json -// @Param applicationID path string true "Application ID" -// @Success 200 {object} NotesListResponse -// @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 /admin/applications/{applicationID}/notes [get] +// @Summary Get notes for an application (Admin) +// @Description Returns all reviewer notes for a specific application without exposing votes +// @Tags admin +// @Produce json +// @Param applicationID path string true "Application ID" +// @Success 200 {object} NotesListResponse +// @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 /admin/applications/{applicationID}/notes [get] func (app *application) getApplicationNotes(w http.ResponseWriter, r *http.Request) { applicationID := chi.URLParam(r, "applicationID") if applicationID == "" { @@ -127,16 +127,16 @@ func (app *application) getApplicationNotes(w http.ResponseWriter, r *http.Reque // batchAssignReviews assigns submitted applications to admins using workload balancing // -// @Summary Batch assign reviews (SuperAdmin) -// @Description Finds all submitted applications needing more reviews and assigns them to admins using workload balancing -// @Tags superadmin -// @Produce json -// @Success 200 {object} store.BatchAssignmentResult -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/applications/assign [post] +// @Summary Batch assign reviews (SuperAdmin) +// @Description Finds all submitted applications needing more reviews and assigns them to admins using workload balancing +// @Tags superadmin +// @Produce json +// @Success 200 {object} store.BatchAssignmentResult +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/applications/assign [post] func (app *application) batchAssignReviews(w http.ResponseWriter, r *http.Request) { reviewsPerApp, err := app.store.Settings.GetReviewsPerApplication(r.Context()) if err != nil { @@ -157,17 +157,17 @@ func (app *application) batchAssignReviews(w http.ResponseWriter, r *http.Reques // getNextReview assigns and returns the next application needing review // -// @Summary Get next review assignment (Admin) -// @Description Automatically assigns the next submitted application needing review to the current admin and returns it -// @Tags admin -// @Produce json -// @Success 200 {object} ReviewResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} "No applications need review" -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/reviews/next [get] +// @Summary Get next review assignment (Admin) +// @Description Automatically assigns the next submitted application needing review to the current admin and returns it +// @Tags admin +// @Produce json +// @Success 200 {object} ReviewResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} "No applications need review" +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/reviews/next [get] func (app *application) getNextReview(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) @@ -199,21 +199,21 @@ func (app *application) getNextReview(w http.ResponseWriter, r *http.Request) { // submitVote records the admin's vote on an assigned application review // -// @Summary Submit vote on a review (Admin) -// @Description Records the admin's vote (accept/reject/waitlist) on an assigned application review -// @Tags admin -// @Accept json -// @Produce json -// @Param reviewID path string true "Review ID" -// @Param vote body SubmitVotePayload true "Vote and optional notes" -// @Success 200 {object} ReviewResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/reviews/{reviewID} [put] +// @Summary Submit vote on a review (Admin) +// @Description Records the admin's vote (accept/reject/waitlist) on an assigned application review +// @Tags admin +// @Accept json +// @Produce json +// @Param reviewID path string true "Review ID" +// @Param vote body SubmitVotePayload true "Vote and optional notes" +// @Success 200 {object} ReviewResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/reviews/{reviewID} [put] func (app *application) submitVote(w http.ResponseWriter, r *http.Request) { reviewID := chi.URLParam(r, "reviewID") if reviewID == "" { diff --git a/cmd/api/scans.go b/cmd/api/scans.go index 7a4d2a45..d83b60a4 100644 --- a/cmd/api/scans.go +++ b/cmd/api/scans.go @@ -55,20 +55,20 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque // createScanHandler records a scan for a user // -// @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 -// @Accept json -// @Produce json -// @Param scan body CreateScanPayload true "Scan to create" -// @Success 201 {object} store.Scan -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 409 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/scans [post] +// @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 +// @Accept json +// @Produce json +// @Param scan body CreateScanPayload true "Scan to create" +// @Success 201 {object} store.Scan +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 409 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/scans [post] func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request) { var req CreateScanPayload if err := readJSON(w, r, &req); err != nil { @@ -155,18 +155,18 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request // getUserScansHandler returns all scan records for a specified user // -// @Summary Get scans for a user (Admin) -// @Description Returns all scan records for the specified user, ordered by most recent first -// @Tags admin -// @Produce json -// @Param userID path string true "User ID" -// @Success 200 {object} ScansResponse -// @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 /admin/scans/user/{userID} [get] +// @Summary Get scans for a user (Admin) +// @Description Returns all scan records for the specified user, ordered by most recent first +// @Tags admin +// @Produce json +// @Param userID path string true "User ID" +// @Success 200 {object} ScansResponse +// @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 /admin/scans/user/{userID} [get] func (app *application) getUserScansHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if userID == "" { @@ -187,16 +187,16 @@ func (app *application) getUserScansHandler(w http.ResponseWriter, r *http.Reque // getScanStatsHandler returns aggregate scan counts grouped by scan type // -// @Summary Get scan statistics (Admin) -// @Description Returns aggregate scan counts grouped by scan type -// @Tags admin -// @Produce json -// @Success 200 {object} ScanStatsResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/scans/stats [get] +// @Summary Get scan statistics (Admin) +// @Description Returns aggregate scan counts grouped by scan type +// @Tags admin +// @Produce json +// @Success 200 {object} ScanStatsResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/scans/stats [get] func (app *application) getScanStatsHandler(w http.ResponseWriter, r *http.Request) { stats, err := app.store.Scans.GetStats(r.Context()) if err != nil { @@ -211,19 +211,19 @@ func (app *application) getScanStatsHandler(w http.ResponseWriter, r *http.Reque // updateScanTypesHandler replaces all scan types with the provided array // -// @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 -// @Accept json -// @Produce json -// @Param scan_types body UpdateScanTypesPayload true "Scan types to set" -// @Success 200 {object} ScanTypesResponse -// @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/scan-types [put] +// @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 +// @Accept json +// @Produce json +// @Param scan_types body UpdateScanTypesPayload true "Scan types to set" +// @Success 200 {object} ScanTypesResponse +// @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/scan-types [put] func (app *application) updateScanTypesHandler(w http.ResponseWriter, r *http.Request) { var req UpdateScanTypesPayload if err := readJSON(w, r, &req); err != nil { diff --git a/cmd/api/settings.go b/cmd/api/settings.go index 2b1e5285..7a7776fc 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -17,16 +17,16 @@ type ShortAnswerQuestionsResponse struct { // getShortAnswerQuestions returns all configurable short answer questions // -// @Summary Get short answer questions (Super Admin) -// @Description Returns all configurable short answer questions for hacker applications -// @Tags superadmin -// @Produce json -// @Success 200 {object} ShortAnswerQuestionsResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/settings/saquestions [get] +// @Summary Get short answer questions (Super Admin) +// @Description Returns all configurable short answer questions for hacker applications +// @Tags superadmin +// @Produce json +// @Success 200 {object} ShortAnswerQuestionsResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/saquestions [get] func (app *application) getShortAnswerQuestions(w http.ResponseWriter, r *http.Request) { questions, err := app.store.Settings.GetShortAnswerQuestions(r.Context()) if err != nil { @@ -45,19 +45,19 @@ func (app *application) getShortAnswerQuestions(w http.ResponseWriter, r *http.R // updateShortAnswerQuestions replaces all short answer questions // -// @Summary Update short answer questions (Super Admin) -// @Description Replaces all short answer questions with the provided array -// @Tags superadmin -// @Accept json -// @Produce json -// @Param questions body UpdateShortAnswerQuestionsPayload true "Questions to set" -// @Success 200 {object} ShortAnswerQuestionsResponse -// @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/saquestions [put] +// @Summary Update short answer questions (Super Admin) +// @Description Replaces all short answer questions with the provided array +// @Tags superadmin +// @Accept json +// @Produce json +// @Param questions body UpdateShortAnswerQuestionsPayload true "Questions to set" +// @Success 200 {object} ShortAnswerQuestionsResponse +// @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/saquestions [put] func (app *application) updateShortAnswerQuestions(w http.ResponseWriter, r *http.Request) { var req UpdateShortAnswerQuestionsPayload if err := readJSON(w, r, &req); err != nil { @@ -102,16 +102,16 @@ type ReviewsPerAppResponse struct { // getReviewsPerApp returns the number of reviews required per application // -// @Summary Get reviews per application (Super Admin) -// @Description Returns the number of reviews required per application -// @Tags superadmin -// @Produce json -// @Success 200 {object} ReviewsPerAppResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/settings/reviews-per-app [get] +// @Summary Get reviews per application (Super Admin) +// @Description Returns the number of reviews required per application +// @Tags superadmin +// @Produce json +// @Success 200 {object} ReviewsPerAppResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/reviews-per-app [get] func (app *application) getReviewsPerApp(w http.ResponseWriter, r *http.Request) { count, err := app.store.Settings.GetReviewsPerApplication(r.Context()) if err != nil { @@ -130,19 +130,19 @@ func (app *application) getReviewsPerApp(w http.ResponseWriter, r *http.Request) // setReviewsPerApp sets the number of reviews required per application // -// @Summary Set reviews per application (Super Admin) -// @Description Sets the number of reviews required per application -// @Tags superadmin -// @Accept json -// @Produce json -// @Param reviews_per_application body SetReviewsPerAppPayload true "Reviews per application value" -// @Success 200 {object} ReviewsPerAppResponse -// @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/reviews-per-app [post] +// @Summary Set reviews per application (Super Admin) +// @Description Sets the number of reviews required per application +// @Tags superadmin +// @Accept json +// @Produce json +// @Param reviews_per_application body SetReviewsPerAppPayload true "Reviews per application value" +// @Success 200 {object} ReviewsPerAppResponse +// @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/reviews-per-app [post] func (app *application) setReviewsPerApp(w http.ResponseWriter, r *http.Request) { var req SetReviewsPerAppPayload if err := readJSON(w, r, &req); err != nil { 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..f69f4fd9 --- /dev/null +++ b/cmd/api/users_test.go @@ -0,0 +1,105 @@ +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" +) + +func TestBatchUpdateRoles(t *testing.T) { + app := newTestApplication(t) + mockUsers := app.store.Users.(*store.MockUsersStore) + + superAdmin := newSuperAdminUser() + + t.Run("should update roles successfully", func(t *testing.T) { + updated := []*store.User{ + {ID: "user-uuid-1", Email: "a@test.com", Role: store.RoleAdmin, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: "user-uuid-2", Email: "b@test.com", Role: store.RoleAdmin, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + mockUsers.On("BatchUpdateRoles", []string{"user-uuid-1", "user-uuid-2"}, store.RoleAdmin).Return(updated, nil).Once() + + body := `{"user_ids":["user-uuid-1","user-uuid-2"],"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":["user-uuid-1"],"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":["superadmin-1","user-uuid-2"],"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: "user-uuid-1", Email: "a@test.com", Role: store.RoleHacker, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + mockUsers.On("BatchUpdateRoles", []string{"user-uuid-1", "nonexistent-uuid"}, store.RoleHacker).Return(updated, nil).Once() + + body := `{"user_ids":["user-uuid-1","nonexistent-uuid"],"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 ba79a0fa..79f02da5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2009,6 +2009,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": { @@ -2160,6 +2243,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": { @@ -3055,6 +3177,58 @@ const docTemplate = `{ } } }, + "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" + }, + "id": { + "type": "string" + }, + "profile_picture_url": { + "type": "string" + }, + "role": { + "enum": [ + "hacker", + "admin", + "super_admin" + ], + "allOf": [ + { + "$ref": "#/definitions/store.UserRole" + } + ] + }, + "supertokens_user_id": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "store.UserRole": { "type": "string", "enum": [ diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index ca2d04a2..503fba06 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) +} + // mock implementation of the Application interface type MockApplicationStore struct { mock.Mock diff --git a/internal/store/storage.go b/internal/store/storage.go index a9c1be26..c9207cd0 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) } Application interface { GetByUserID(ctx context.Context, userID string) (*Application, error) diff --git a/internal/store/users.go b/internal/store/users.go index 0b313523..c31e73bb 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -229,6 +229,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 +} + func (s *UsersStore) UpdateProfilePicture(ctx context.Context, supertokensUserID string, pictureURL *string) error { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) defer cancel() From 7dc70f98f0970b84c4ee09e6b8ce069e2ee83213 Mon Sep 17 00:00:00 2001 From: Tharun Sevvel Date: Thu, 26 Mar 2026 09:29:11 -0500 Subject: [PATCH 2/2] fix: complete BatchUpdateRoles implementation and fix merge conflicts --- cmd/api/api.go | 2 -- cmd/api/users_test.go | 39 ++++++++++++++++++++-------- internal/store/mock_store.go | 32 +++++++++++++++++++++++ internal/store/users.go | 49 ++++++++++++++++++++++-------------- 4 files changed, 91 insertions(+), 31 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 4ee58097..976ad672 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -255,8 +255,6 @@ func (app *application) mount() http.Handler { // Users r.Patch("/users/role", app.batchUpdateRolesHandler) - // Emails - r.Post("/emails/qr", app.sendQREmailsHandler) }) }) }) diff --git a/cmd/api/users_test.go b/cmd/api/users_test.go index f69f4fd9..231431bd 100644 --- a/cmd/api/users_test.go +++ b/cmd/api/users_test.go @@ -12,20 +12,39 @@ import ( "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 := newSuperAdminUser() + superAdmin := newSuperAdminUserWithUUID() t.Run("should update roles successfully", func(t *testing.T) { updated := []*store.User{ - {ID: "user-uuid-1", Email: "a@test.com", Role: store.RoleAdmin, CreatedAt: time.Now(), UpdatedAt: time.Now()}, - {ID: "user-uuid-2", Email: "b@test.com", Role: store.RoleAdmin, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {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{"user-uuid-1", "user-uuid-2"}, store.RoleAdmin).Return(updated, nil).Once() + mockUsers.On("BatchUpdateRoles", []string{testUUID1, testUUID2}, store.RoleAdmin).Return(updated, nil).Once() - body := `{"user_ids":["user-uuid-1","user-uuid-2"],"role":"admin"}` + 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") @@ -45,7 +64,7 @@ func TestBatchUpdateRoles(t *testing.T) { }) t.Run("should return 400 when role is super_admin", func(t *testing.T) { - body := `{"user_ids":["user-uuid-1"],"role":"super_admin"}` + 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") @@ -67,7 +86,7 @@ func TestBatchUpdateRoles(t *testing.T) { }) t.Run("should return 400 when caller tries to modify own role", func(t *testing.T) { - body := `{"user_ids":["superadmin-1","user-uuid-2"],"role":"hacker"}` + 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") @@ -87,11 +106,11 @@ func TestBatchUpdateRoles(t *testing.T) { 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: "user-uuid-1", Email: "a@test.com", Role: store.RoleHacker, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: testUUID1, Email: "a@test.com", Role: store.RoleHacker, CreatedAt: time.Now(), UpdatedAt: time.Now()}, } - mockUsers.On("BatchUpdateRoles", []string{"user-uuid-1", "nonexistent-uuid"}, store.RoleHacker).Return(updated, nil).Once() + mockUsers.On("BatchUpdateRoles", []string{testUUID1, nonexistentUUID}, store.RoleHacker).Return(updated, nil).Once() - body := `{"user_ids":["user-uuid-1","nonexistent-uuid"],"role":"hacker"}` + 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") diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index 508433c2..117dfb0e 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -53,6 +53,38 @@ func (m *MockUsersStore) BatchUpdateRoles(ctx context.Context, userIDs []string, 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 { + return nil, args.Error(1) + } + return args.Get(0).(*UserSearchResult), args.Error(1) +} + +func (m *MockUsersStore) UpdateRole(ctx context.Context, userID string, role UserRole) (*User, error) { + args := m.Called(userID, role) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*User), args.Error(1) +} + +func (m *MockUsersStore) GetByRole(ctx context.Context, role UserRole) ([]User, error) { + args := m.Called(role) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]User), args.Error(1) +} + +func (m *MockUsersStore) ListUsers(ctx context.Context, filters UserListFilters, cursor *UserCursor, direction PaginationDirection, limit int) (*UserListResult, error) { + args := m.Called(filters, cursor, direction, limit) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*UserListResult), args.Error(1) +} + // mock implementation of the Application interface type MockApplicationStore struct { mock.Mock diff --git a/internal/store/users.go b/internal/store/users.go index 407acc53..c3101298 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -237,6 +237,36 @@ func (s *UsersStore) BatchUpdateRoles(ctx context.Context, userIDs []string, rol ` 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"` @@ -299,24 +329,6 @@ func (s *UsersStore) Search(ctx context.Context, query string, limit int, offset } 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) - } - users := make([]UserListItem, 0, limit) for rows.Next() { var u UserListItem @@ -329,7 +341,6 @@ func (s *UsersStore) Search(ctx context.Context, query string, limit int, offset return nil, err } - return users, nil return &UserSearchResult{Users: users, TotalCount: totalCount}, nil }