From ecad91d89e15ffea8d09a70b0c5c0d87c922edb0 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 19 May 2026 00:52:14 -0700 Subject: [PATCH] Add SUPER_ADMIN bulk deactivate and reactivate by role. Expose POST /admin/roles/:role/bulk-deactivate and bulk-reactivate for platform users and team_members, mirroring deactivate/reactivate semantics and optional team member exclusions. Co-authored-by: Cursor --- db/query/team.sql | 26 +++ db/query/user.sql | 20 ++ docs/docs.go | 134 +++++++++++ docs/swagger.json | 134 +++++++++++ docs/swagger.yaml | 94 ++++++++ gen/db/team.sql.go | 54 +++++ gen/db/user.sql.go | 48 ++++ internal/domain/bulk_deactivate_accounts.go | 27 +++ internal/ports/team.go | 2 + internal/ports/user.go | 2 + internal/repository/team.go | 22 ++ internal/repository/user.go | 14 ++ internal/services/team/team.go | 8 + internal/services/user/direct.go | 8 + internal/web_server/handlers/admin.go | 245 ++++++++++++++++++++ internal/web_server/routes.go | 2 + 16 files changed, 840 insertions(+) create mode 100644 internal/domain/bulk_deactivate_accounts.go diff --git a/db/query/team.sql b/db/query/team.sql index 23bde2f..de078cc 100644 --- a/db/query/team.sql +++ b/db/query/team.sql @@ -140,6 +140,32 @@ WHERE id = $1; DELETE FROM team_members WHERE id = $1; +-- name: BulkDeactivateTeamMembersByRole :execrows +UPDATE team_members +SET + status = 'inactive', + updated_at = CURRENT_TIMESTAMP +WHERE + team_role = $1 + AND ( + sqlc.narg('exclude_team_member_id')::BIGINT IS NULL + OR id <> sqlc.narg('exclude_team_member_id')::BIGINT + ) + AND status = 'active'; + +-- name: BulkReactivateTeamMembersByRole :execrows +UPDATE team_members +SET + status = 'active', + updated_at = CURRENT_TIMESTAMP +WHERE + team_role = $1 + AND ( + sqlc.narg('exclude_team_member_id')::BIGINT IS NULL + OR id <> sqlc.narg('exclude_team_member_id')::BIGINT + ) + AND status = 'inactive'; + -- name: CheckTeamMemberEmailExists :one SELECT EXISTS ( SELECT 1 FROM team_members WHERE email = $1 diff --git a/db/query/user.sql b/db/query/user.sql index 51dd3c7..e18220f 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -421,6 +421,26 @@ SET updated_at = CURRENT_TIMESTAMP WHERE id = $2; +-- name: BulkDeactivateUsersByRole :execrows +UPDATE users +SET + status = 'DEACTIVATED', + updated_at = CURRENT_TIMESTAMP +WHERE + role = $1 + AND id <> $2 + AND status <> 'DEACTIVATED'; + +-- name: BulkReactivateUsersByRole :execrows +UPDATE users +SET + status = 'ACTIVE', + updated_at = CURRENT_TIMESTAMP +WHERE + role = $1 + AND id <> $2 + AND status = 'DEACTIVATED'; + -- name: GetUserSummary :one SELECT COUNT(*) AS total_users, diff --git a/docs/docs.go b/docs/docs.go index 6b34972..9022e6d 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -505,6 +505,132 @@ const docTemplate = `{ } } }, + "/api/v1/admin/roles/{role}/bulk-deactivate": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Sets all platform users with the given users.role to DEACTIVATED (except the caller) and all team_members with the given team_role to inactive. The path role must match a valid learner role or team role string (e.g. STUDENT, INSTRUCTOR, ADMIN, CONTENT_MANAGER). SUPER_ADMIN cannot be bulk-deactivated. Empty body allowed; optionally pass exclude_team_member_id to skip one team_members row (e.g. yourself).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Bulk deactivate accounts by role (SUPER_ADMIN only)", + "parameters": [ + { + "type": "string", + "description": "Role key (matches users.role and/or team_members.team_role)", + "name": "role", + "in": "path", + "required": true + }, + { + "description": "Optional exclusions", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/domain.BulkAccountsByRoleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/roles/{role}/bulk-reactivate": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Sets all platform users with the given role from DEACTIVATED to ACTIVE (except the caller) and all team_members with the given team_role from inactive to active. Path role must be a valid platform or team role. SUPER_ADMIN cannot be bulk changed. Matches only users currently DEACTIVATED and team rows currently inactive.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Bulk reactivate accounts by role (SUPER_ADMIN only)", + "parameters": [ + { + "type": "string", + "description": "Role key (matches users.role and/or team_members.team_role)", + "name": "role", + "in": "path", + "required": true + }, + { + "description": "Optional exclusions", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/domain.BulkAccountsByRoleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/users/deletion-requests": { "get": { "description": "Returns account deletion requests for admin panel tracking with filtering and pagination", @@ -10206,6 +10332,14 @@ const docTemplate = `{ } } }, + "domain.BulkAccountsByRoleRequest": { + "type": "object", + "properties": { + "exclude_team_member_id": { + "type": "integer" + } + } + }, "domain.CreateCourseInput": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index b832559..50cdbdc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -497,6 +497,132 @@ } } }, + "/api/v1/admin/roles/{role}/bulk-deactivate": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Sets all platform users with the given users.role to DEACTIVATED (except the caller) and all team_members with the given team_role to inactive. The path role must match a valid learner role or team role string (e.g. STUDENT, INSTRUCTOR, ADMIN, CONTENT_MANAGER). SUPER_ADMIN cannot be bulk-deactivated. Empty body allowed; optionally pass exclude_team_member_id to skip one team_members row (e.g. yourself).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Bulk deactivate accounts by role (SUPER_ADMIN only)", + "parameters": [ + { + "type": "string", + "description": "Role key (matches users.role and/or team_members.team_role)", + "name": "role", + "in": "path", + "required": true + }, + { + "description": "Optional exclusions", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/domain.BulkAccountsByRoleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/roles/{role}/bulk-reactivate": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Sets all platform users with the given role from DEACTIVATED to ACTIVE (except the caller) and all team_members with the given team_role from inactive to active. Path role must be a valid platform or team role. SUPER_ADMIN cannot be bulk changed. Matches only users currently DEACTIVATED and team rows currently inactive.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Bulk reactivate accounts by role (SUPER_ADMIN only)", + "parameters": [ + { + "type": "string", + "description": "Role key (matches users.role and/or team_members.team_role)", + "name": "role", + "in": "path", + "required": true + }, + { + "description": "Optional exclusions", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/domain.BulkAccountsByRoleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/admin/users/deletion-requests": { "get": { "description": "Returns account deletion requests for admin panel tracking with filtering and pagination", @@ -10198,6 +10324,14 @@ } } }, + "domain.BulkAccountsByRoleRequest": { + "type": "object", + "properties": { + "exclude_team_member_id": { + "type": "integer" + } + } + }, "domain.CreateCourseInput": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 780aa52..f497b0a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -327,6 +327,11 @@ definitions: total_users: type: integer type: object + domain.BulkAccountsByRoleRequest: + properties: + exclude_team_member_id: + type: integer + type: object domain.CreateCourseInput: properties: description: @@ -2868,6 +2873,95 @@ paths: summary: Update FAQ tags: - faqs + /api/v1/admin/roles/{role}/bulk-deactivate: + post: + consumes: + - application/json + description: Sets all platform users with the given users.role to DEACTIVATED + (except the caller) and all team_members with the given team_role to inactive. + The path role must match a valid learner role or team role string (e.g. STUDENT, + INSTRUCTOR, ADMIN, CONTENT_MANAGER). SUPER_ADMIN cannot be bulk-deactivated. + Empty body allowed; optionally pass exclude_team_member_id to skip one team_members + row (e.g. yourself). + parameters: + - description: Role key (matches users.role and/or team_members.team_role) + in: path + name: role + required: true + type: string + - description: Optional exclusions + in: body + name: body + schema: + $ref: '#/definitions/domain.BulkAccountsByRoleRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Bulk deactivate accounts by role (SUPER_ADMIN only) + tags: + - admin + /api/v1/admin/roles/{role}/bulk-reactivate: + post: + consumes: + - application/json + description: Sets all platform users with the given role from DEACTIVATED to + ACTIVE (except the caller) and all team_members with the given team_role from + inactive to active. Path role must be a valid platform or team role. SUPER_ADMIN + cannot be bulk changed. Matches only users currently DEACTIVATED and team + rows currently inactive. + parameters: + - description: Role key (matches users.role and/or team_members.team_role) + in: path + name: role + required: true + type: string + - description: Optional exclusions + in: body + name: body + schema: + $ref: '#/definitions/domain.BulkAccountsByRoleRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + security: + - Bearer: [] + summary: Bulk reactivate accounts by role (SUPER_ADMIN only) + tags: + - admin /api/v1/admin/users/{user_id}/lms-learning-activity: get: description: Returns programs, courses, modules, and lessons with completion diff --git a/gen/db/team.sql.go b/gen/db/team.sql.go index 2eec032..a65aa33 100644 --- a/gen/db/team.sql.go +++ b/gen/db/team.sql.go @@ -11,6 +11,60 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const BulkDeactivateTeamMembersByRole = `-- name: BulkDeactivateTeamMembersByRole :execrows +UPDATE team_members +SET + status = 'inactive', + updated_at = CURRENT_TIMESTAMP +WHERE + team_role = $1 + AND ( + $2::BIGINT IS NULL + OR id <> $2::BIGINT + ) + AND status = 'active' +` + +type BulkDeactivateTeamMembersByRoleParams struct { + TeamRole string `json:"team_role"` + ExcludeTeamMemberID pgtype.Int8 `json:"exclude_team_member_id"` +} + +func (q *Queries) BulkDeactivateTeamMembersByRole(ctx context.Context, arg BulkDeactivateTeamMembersByRoleParams) (int64, error) { + result, err := q.db.Exec(ctx, BulkDeactivateTeamMembersByRole, arg.TeamRole, arg.ExcludeTeamMemberID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const BulkReactivateTeamMembersByRole = `-- name: BulkReactivateTeamMembersByRole :execrows +UPDATE team_members +SET + status = 'active', + updated_at = CURRENT_TIMESTAMP +WHERE + team_role = $1 + AND ( + $2::BIGINT IS NULL + OR id <> $2::BIGINT + ) + AND status = 'inactive' +` + +type BulkReactivateTeamMembersByRoleParams struct { + TeamRole string `json:"team_role"` + ExcludeTeamMemberID pgtype.Int8 `json:"exclude_team_member_id"` +} + +func (q *Queries) BulkReactivateTeamMembersByRole(ctx context.Context, arg BulkReactivateTeamMembersByRoleParams) (int64, error) { + result, err := q.db.Exec(ctx, BulkReactivateTeamMembersByRole, arg.TeamRole, arg.ExcludeTeamMemberID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const CheckTeamMemberEmailExists = `-- name: CheckTeamMemberEmailExists :one SELECT EXISTS ( SELECT 1 FROM team_members WHERE email = $1 diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 419aff2..95f6cb8 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -11,6 +11,54 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const BulkDeactivateUsersByRole = `-- name: BulkDeactivateUsersByRole :execrows +UPDATE users +SET + status = 'DEACTIVATED', + updated_at = CURRENT_TIMESTAMP +WHERE + role = $1 + AND id <> $2 + AND status <> 'DEACTIVATED' +` + +type BulkDeactivateUsersByRoleParams struct { + Role string `json:"role"` + ID int64 `json:"id"` +} + +func (q *Queries) BulkDeactivateUsersByRole(ctx context.Context, arg BulkDeactivateUsersByRoleParams) (int64, error) { + result, err := q.db.Exec(ctx, BulkDeactivateUsersByRole, arg.Role, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const BulkReactivateUsersByRole = `-- name: BulkReactivateUsersByRole :execrows +UPDATE users +SET + status = 'ACTIVE', + updated_at = CURRENT_TIMESTAMP +WHERE + role = $1 + AND id <> $2 + AND status = 'DEACTIVATED' +` + +type BulkReactivateUsersByRoleParams struct { + Role string `json:"role"` + ID int64 `json:"id"` +} + +func (q *Queries) BulkReactivateUsersByRole(ctx context.Context, arg BulkReactivateUsersByRoleParams) (int64, error) { + result, err := q.db.Exec(ctx, BulkReactivateUsersByRole, arg.Role, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const CheckPhoneEmailExist = `-- name: CheckPhoneEmailExist :one SELECT EXISTS ( diff --git a/internal/domain/bulk_deactivate_accounts.go b/internal/domain/bulk_deactivate_accounts.go new file mode 100644 index 0000000..1fc8f8d --- /dev/null +++ b/internal/domain/bulk_deactivate_accounts.go @@ -0,0 +1,27 @@ +package domain + +// BulkAccountsByRoleRequest is optional JSON for POST /admin/roles/:role/bulk-{deactivate,reactivate}. +// Optional exclusions apply to team_members bulk updates only. +type BulkAccountsByRoleRequest struct { + ExcludeTeamMemberID *int64 `json:"exclude_team_member_id,omitempty"` +} + +// BulkDeactivateAccountsByRoleRequest aliases the shared shape (backward compatible name for Swagger). +type BulkDeactivateAccountsByRoleRequest = BulkAccountsByRoleRequest + +// BulkReactivateAccountsByRoleRequest aliases the shared shape. +type BulkReactivateAccountsByRoleRequest = BulkAccountsByRoleRequest + +// BulkDeactivateAccountsByRoleResult reports how many rows were updated per table. +type BulkDeactivateAccountsByRoleResult struct { + Role string `json:"role"` + UsersDeactivated int64 `json:"users_deactivated"` + TeamMembersDeactivated int64 `json:"team_members_deactivated"` +} + +// BulkReactivateAccountsByRoleResult reports how many rows were reactivated per table. +type BulkReactivateAccountsByRoleResult struct { + Role string `json:"role"` + UsersReactivated int64 `json:"users_reactivated"` + TeamMembersReactivated int64 `json:"team_members_reactivated"` +} diff --git a/internal/ports/team.go b/internal/ports/team.go index 7d352eb..ab63845 100644 --- a/internal/ports/team.go +++ b/internal/ports/team.go @@ -36,4 +36,6 @@ type TeamStore interface { CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error + BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) + BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) } diff --git a/internal/ports/user.go b/internal/ports/user.go index 4f2d1ef..ab8e72d 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -85,6 +85,8 @@ type UserStore interface { GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error) DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error DeactivateAllUserDevices(ctx context.Context, userID int64) error + BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) + BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) } type SmsGateway interface { SendSMSOTP(ctx context.Context, phoneNumber, otp string) error diff --git a/internal/repository/team.go b/internal/repository/team.go index 9317fb0..c6f7bc0 100644 --- a/internal/repository/team.go +++ b/internal/repository/team.go @@ -219,6 +219,28 @@ func (s *Store) DeleteTeamMember(ctx context.Context, memberID int64) error { return s.queries.DeleteTeamMember(ctx, memberID) } +func (s *Store) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) { + var ex pgtype.Int8 + if excludeTeamMemberID != nil { + ex = pgtype.Int8{Int64: *excludeTeamMemberID, Valid: true} + } + return s.queries.BulkDeactivateTeamMembersByRole(ctx, dbgen.BulkDeactivateTeamMembersByRoleParams{ + TeamRole: teamRole, + ExcludeTeamMemberID: ex, + }) +} + +func (s *Store) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) { + var ex pgtype.Int8 + if excludeTeamMemberID != nil { + ex = pgtype.Int8{Int64: *excludeTeamMemberID, Valid: true} + } + return s.queries.BulkReactivateTeamMembersByRole(ctx, dbgen.BulkReactivateTeamMembersByRoleParams{ + TeamRole: teamRole, + ExcludeTeamMemberID: ex, + }) +} + func (s *Store) CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) { return s.queries.CheckTeamMemberEmailExists(ctx, email) } diff --git a/internal/repository/user.go b/internal/repository/user.go index c6ae93a..69f9e99 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -126,6 +126,20 @@ func (s *Store) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatu }) } +func (s *Store) BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) { + return s.queries.BulkDeactivateUsersByRole(ctx, dbgen.BulkDeactivateUsersByRoleParams{ + Role: role, + ID: excludeUserID, + }) +} + +func (s *Store) BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) { + return s.queries.BulkReactivateUsersByRole(ctx, dbgen.BulkReactivateUsersByRoleParams{ + Role: role, + ID: excludeUserID, + }) +} + func (s *Store) CreateUserWithoutOtp( ctx context.Context, user domain.User, diff --git a/internal/services/team/team.go b/internal/services/team/team.go index 6b26f28..b934a6f 100644 --- a/internal/services/team/team.go +++ b/internal/services/team/team.go @@ -119,6 +119,14 @@ func (s *Service) GetTeamMemberStats(ctx context.Context) (domain.TeamMemberStat return s.teamStore.CountTeamMembersByStatus(ctx) } +func (s *Service) BulkDeactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) { + return s.teamStore.BulkDeactivateTeamMembersByRole(ctx, teamRole, excludeTeamMemberID) +} + +func (s *Service) BulkReactivateTeamMembersByRole(ctx context.Context, teamRole string, excludeTeamMemberID *int64) (int64, error) { + return s.teamStore.BulkReactivateTeamMembersByRole(ctx, teamRole, excludeTeamMemberID) +} + func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (domain.TeamMember, error) { member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email) if err != nil { diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 603d9de..8618d1d 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -133,6 +133,14 @@ func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserSta return s.userStore.UpdateUserStatus(ctx, req) } +func (s *Service) BulkDeactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) { + return s.userStore.BulkDeactivateUsersByRole(ctx, role, excludeUserID) +} + +func (s *Service) BulkReactivateUsersByRole(ctx context.Context, role string, excludeUserID int64) (int64, error) { + return s.userStore.BulkReactivateUsersByRole(ctx, role, excludeUserID) +} + func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) { return s.userStore.GetUserByID(ctx, id) diff --git a/internal/web_server/handlers/admin.go b/internal/web_server/handlers/admin.go index b74bc11..afff6a1 100644 --- a/internal/web_server/handlers/admin.go +++ b/internal/web_server/handlers/admin.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" "time" "github.com/gofiber/fiber/v2" @@ -379,3 +380,247 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Admin updated successfully", nil, nil) } + +// BulkDeactivateAccountsByRole godoc +// @Summary Bulk deactivate accounts by role (SUPER_ADMIN only) +// @Description Sets all platform users with the given users.role to DEACTIVATED (except the caller) and all team_members with the given team_role to inactive. The path role must match a valid learner role or team role string (e.g. STUDENT, INSTRUCTOR, ADMIN, CONTENT_MANAGER). SUPER_ADMIN cannot be bulk-deactivated. Empty body allowed; optionally pass exclude_team_member_id to skip one team_members row (e.g. yourself). +// @Tags admin +// @Accept json +// @Produce json +// @Security Bearer +// @Param role path string true "Role key (matches users.role and/or team_members.team_role)" +// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/admin/roles/{role}/bulk-deactivate [post] +func (h *Handler) BulkDeactivateAccountsByRole(c *fiber.Ctx) error { + callerRole, ok := c.Locals("role").(domain.Role) + if !ok { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Forbidden", + Error: "role not found in context", + }) + } + if callerRole != domain.RoleSuperAdmin { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Forbidden", + Error: "only SUPER_ADMIN platform users may bulk deactivate by role", + }) + } + + actorID, ok := c.Locals("user_id").(int64) + if !ok || actorID <= 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + Error: "user id not found in context", + }) + } + + roleKey := strings.ToUpper(strings.TrimSpace(c.Params("role"))) + if roleKey == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role", + Error: "role path parameter is required", + }) + } + if roleKey == string(domain.RoleSuperAdmin) || roleKey == string(domain.TeamRoleSuperAdmin) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Refusing bulk deactivate", + Error: "SUPER_ADMIN cannot be bulk deactivated", + }) + } + + validUserRole := domain.Role(roleKey).IsValid() + validTeamRole := domain.TeamRole(roleKey).IsValid() + if !validUserRole && !validTeamRole { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role", + Error: "role is not a valid platform users.role nor team_members.team_role", + }) + } + + var req domain.BulkAccountsByRoleRequest + if len(c.Body()) > 0 { + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid body", + Error: err.Error(), + }) + } + } + if req.ExcludeTeamMemberID != nil && *req.ExcludeTeamMemberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid exclude_team_member_id", + Error: "exclude_team_member_id must be positive when set", + }) + } + + var usersN, teamN int64 + var err error + if validUserRole { + usersN, err = h.userSvc.BulkDeactivateUsersByRole(c.Context(), roleKey, actorID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Bulk user deactivation failed", + Error: err.Error(), + }) + } + } + if validTeamRole { + teamN, err = h.teamSvc.BulkDeactivateTeamMembersByRole(c.Context(), roleKey, req.ExcludeTeamMemberID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Bulk team member deactivation failed", + Error: err.Error(), + }) + } + } + + out := domain.BulkDeactivateAccountsByRoleResult{ + Role: roleKey, + UsersDeactivated: usersN, + TeamMembersDeactivated: teamN, + } + + actorRole := string(callerRole) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{ + "role": roleKey, + "users_deactivated": usersN, + "team_members_deactivated": teamN, + "exclude_team_member_id": req.ExcludeTeamMemberID, + }) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionUserUpdated, domain.ResourceUser, &actorID, fmt.Sprintf("Bulk deactivated role %s (%d users, %d team members)", roleKey, usersN, teamN), meta, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Bulk deactivation completed", + Data: out, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// BulkReactivateAccountsByRole godoc +// @Summary Bulk reactivate accounts by role (SUPER_ADMIN only) +// @Description Sets all platform users with the given role from DEACTIVATED to ACTIVE (except the caller) and all team_members with the given team_role from inactive to active. Path role must be a valid platform or team role. SUPER_ADMIN cannot be bulk changed. Matches only users currently DEACTIVATED and team rows currently inactive. +// @Tags admin +// @Accept json +// @Produce json +// @Security Bearer +// @Param role path string true "Role key (matches users.role and/or team_members.team_role)" +// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/admin/roles/{role}/bulk-reactivate [post] +func (h *Handler) BulkReactivateAccountsByRole(c *fiber.Ctx) error { + callerRole, ok := c.Locals("role").(domain.Role) + if !ok { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Forbidden", + Error: "role not found in context", + }) + } + if callerRole != domain.RoleSuperAdmin { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Forbidden", + Error: "only SUPER_ADMIN platform users may bulk reactivate by role", + }) + } + + actorID, ok := c.Locals("user_id").(int64) + if !ok || actorID <= 0 { + return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{ + Message: "Unauthorized", + Error: "user id not found in context", + }) + } + + roleKey := strings.ToUpper(strings.TrimSpace(c.Params("role"))) + if roleKey == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role", + Error: "role path parameter is required", + }) + } + if roleKey == string(domain.RoleSuperAdmin) || roleKey == string(domain.TeamRoleSuperAdmin) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Refusing bulk reactivate", + Error: "SUPER_ADMIN role cannot be bulk reactivated via this endpoint", + }) + } + + validUserRole := domain.Role(roleKey).IsValid() + validTeamRole := domain.TeamRole(roleKey).IsValid() + if !validUserRole && !validTeamRole { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid role", + Error: "role is not a valid platform users.role nor team_members.team_role", + }) + } + + var req domain.BulkAccountsByRoleRequest + if len(c.Body()) > 0 { + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid body", + Error: err.Error(), + }) + } + } + if req.ExcludeTeamMemberID != nil && *req.ExcludeTeamMemberID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid exclude_team_member_id", + Error: "exclude_team_member_id must be positive when set", + }) + } + + var usersN, teamN int64 + var err error + if validUserRole { + usersN, err = h.userSvc.BulkReactivateUsersByRole(c.Context(), roleKey, actorID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Bulk user reactivation failed", + Error: err.Error(), + }) + } + } + if validTeamRole { + teamN, err = h.teamSvc.BulkReactivateTeamMembersByRole(c.Context(), roleKey, req.ExcludeTeamMemberID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Bulk team member reactivation failed", + Error: err.Error(), + }) + } + } + + out := domain.BulkReactivateAccountsByRoleResult{ + Role: roleKey, + UsersReactivated: usersN, + TeamMembersReactivated: teamN, + } + + actorRoleStr := string(callerRole) + ip := c.IP() + ua := c.Get("User-Agent") + meta, _ := json.Marshal(map[string]interface{}{ + "role": roleKey, + "users_reactivated": usersN, + "team_members_reactivated": teamN, + "exclude_team_member_id": req.ExcludeTeamMemberID, + }) + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRoleStr, domain.ActionUserUpdated, domain.ResourceUser, &actorID, fmt.Sprintf("Bulk reactivated role %s (%d users, %d team members)", roleKey, usersN, teamN), meta, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Bulk reactivation completed", + Data: out, + Success: true, + StatusCode: fiber.StatusOK, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 04f4926..88c8197 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -305,6 +305,8 @@ func (a *App) initAppRoutes() { groupV1.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID) groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin) groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin) + groupV1.Post("/admin/roles/:role/bulk-deactivate", a.authMiddleware, h.BulkDeactivateAccountsByRole) + groupV1.Post("/admin/roles/:role/bulk-reactivate", a.authMiddleware, h.BulkReactivateAccountsByRole) // Logs groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background()))