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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-19 00:52:14 -07:00
parent a80db8afd9
commit ecad91d89e
16 changed files with 840 additions and 0 deletions

View File

@ -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

View File

@ -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,

View File

@ -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": [

View File

@ -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": [

View File

@ -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

View File

@ -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

View File

@ -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 (

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}

View File

@ -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,

View File

@ -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 {

View File

@ -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)

View File

@ -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,
})
}

View File

@ -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()))