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 DELETE FROM team_members
WHERE id = $1; 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 -- name: CheckTeamMemberEmailExists :one
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM team_members WHERE email = $1 SELECT 1 FROM team_members WHERE email = $1

View File

@ -421,6 +421,26 @@ SET
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $2; 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 -- name: GetUserSummary :one
SELECT SELECT
COUNT(*) AS total_users, 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": { "/api/v1/admin/users/deletion-requests": {
"get": { "get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination", "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": { "domain.CreateCourseInput": {
"type": "object", "type": "object",
"required": [ "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": { "/api/v1/admin/users/deletion-requests": {
"get": { "get": {
"description": "Returns account deletion requests for admin panel tracking with filtering and pagination", "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": { "domain.CreateCourseInput": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@ -327,6 +327,11 @@ definitions:
total_users: total_users:
type: integer type: integer
type: object type: object
domain.BulkAccountsByRoleRequest:
properties:
exclude_team_member_id:
type: integer
type: object
domain.CreateCourseInput: domain.CreateCourseInput:
properties: properties:
description: description:
@ -2868,6 +2873,95 @@ paths:
summary: Update FAQ summary: Update FAQ
tags: tags:
- faqs - 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: /api/v1/admin/users/{user_id}/lms-learning-activity:
get: get:
description: Returns programs, courses, modules, and lessons with completion description: Returns programs, courses, modules, and lessons with completion

View File

@ -11,6 +11,60 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 const CheckTeamMemberEmailExists = `-- name: CheckTeamMemberEmailExists :one
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM team_members WHERE email = $1 SELECT 1 FROM team_members WHERE email = $1

View File

@ -11,6 +11,54 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 const CheckPhoneEmailExist = `-- name: CheckPhoneEmailExist :one
SELECT SELECT
EXISTS ( 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 CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error
GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error) GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error)
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) 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) GetUserDeviceTokens(ctx context.Context, userID int64) ([]string, error)
DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error DeactivateDevice(ctx context.Context, userID int64, deviceToken string) error
DeactivateAllUserDevices(ctx context.Context, userID int64) 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 { type SmsGateway interface {
SendSMSOTP(ctx context.Context, phoneNumber, otp string) error 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) 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) { func (s *Store) CheckTeamMemberEmailExists(ctx context.Context, email string) (bool, error) {
return s.queries.CheckTeamMemberEmailExists(ctx, email) 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( func (s *Store) CreateUserWithoutOtp(
ctx context.Context, ctx context.Context,
user domain.User, user domain.User,

View File

@ -119,6 +119,14 @@ func (s *Service) GetTeamMemberStats(ctx context.Context) (domain.TeamMemberStat
return s.teamStore.CountTeamMembersByStatus(ctx) 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) { func (s *Service) Login(ctx context.Context, req domain.TeamMemberLoginReq) (domain.TeamMember, error) {
member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email) member, err := s.teamStore.GetTeamMemberByEmail(ctx, req.Email)
if err != nil { 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) 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) { func (s *Service) GetUserById(ctx context.Context, id int64) (domain.User, error) {
return s.userStore.GetUserByID(ctx, id) return s.userStore.GetUserByID(ctx, id)

View File

@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "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) 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.Get("/admin/:id", a.authMiddleware, a.RequirePermission("admins.get"), h.GetAdminByID)
groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin) groupV1.Post("/admin", a.authMiddleware, a.RequirePermission("admins.create"), h.CreateAdmin)
groupV1.Put("/admin/:id", a.authMiddleware, a.RequirePermission("admins.update"), h.UpdateAdmin) 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 // Logs
groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background())) groupV1.Get("/logs", a.authMiddleware, a.RequirePermission("logs.list"), handlers.GetLogsHandler(context.Background()))