Admin bulk deactivate/reactivate accepts decimal path segments matching GET /rbac/roles IDs, resolving RoleRecord.name to the platform key. Document 404 when id is unknown. Add Cursor rule: on push, commit dirty tree first without secrets. Co-authored-by: Cursor <cursoragent@cursor.com>
672 lines
23 KiB
Go
672 lines
23 KiB
Go
package handlers
|
|
|
|
import (
|
|
"Yimaru-Backend/internal/domain"
|
|
"Yimaru-Backend/internal/services/authentication"
|
|
"Yimaru-Backend/internal/web_server/response"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/jackc/pgx/v5"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
type CreateAdminReq struct {
|
|
FirstName string `json:"first_name" example:"John"`
|
|
LastName string `json:"last_name" example:"Doe"`
|
|
Email string `json:"email" example:"john.doe@example.com"`
|
|
PhoneNumber string `json:"phone_number" example:"1234567890"`
|
|
Password string `json:"password" example:"password123"`
|
|
}
|
|
|
|
// CreateAdmin godoc
|
|
// @Summary Create Admin
|
|
// @Description Create Admin
|
|
// @Tags admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param manger body CreateAdminReq true "Create admin"
|
|
// @Success 200 {object} response.APIResponse
|
|
// @Failure 400 {object} response.APIResponse
|
|
// @Failure 401 {object} response.APIResponse
|
|
// @Failure 500 {object} response.APIResponse
|
|
// @Router /api/v1/admin [post]
|
|
func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
|
|
// var OrganizationID domain.ValidInt64
|
|
var req CreateAdminReq
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
h.mongoLoggerSvc.Info("failed to parse CreateAdmin request",
|
|
zap.Int64("status_code", fiber.StatusBadRequest),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid request:"+err.Error())
|
|
}
|
|
|
|
valErrs, ok := h.validator.Validate(c, req)
|
|
if !ok {
|
|
var errMsg string
|
|
for field, msg := range valErrs {
|
|
errMsg += fmt.Sprintf("%s: %s; ", field, msg)
|
|
}
|
|
h.mongoLoggerSvc.Error("validation failed for CreateAdmin request",
|
|
zap.Int64("status_code", fiber.StatusBadRequest),
|
|
zap.Any("validation_errors", valErrs),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, errMsg)
|
|
}
|
|
|
|
// if req.OrganizationID == nil {
|
|
// OrganizationID = domain.ValidInt64{
|
|
// Value: 0,
|
|
// Valid: false,
|
|
// }
|
|
// } else {
|
|
// // _, err := h.companySvc.GetCompanyByID(c.Context(), *req.OrganizationID)
|
|
// // if err != nil {
|
|
// // h.mongoLoggerSvc.Error("invalid company ID for CreateAdmin",
|
|
// // zap.Int64("status_code", fiber.StatusInternalServerError),
|
|
// // zap.Int64("company_id", *req.OrganizationID),
|
|
// // zap.Error(err),
|
|
// // zap.Time("timestamp", time.Now()),
|
|
// // )
|
|
// // return fiber.NewError(fiber.StatusInternalServerError, "Company ID is invalid:"+err.Error())
|
|
// // }
|
|
// OrganizationID = domain.ValidInt64{
|
|
// Value: *req.OrganizationID,
|
|
// Valid: true,
|
|
// }
|
|
// }
|
|
|
|
user := domain.CreateUserReq{
|
|
FirstName: req.FirstName,
|
|
LastName: req.LastName,
|
|
Email: req.Email,
|
|
PhoneNumber: req.PhoneNumber,
|
|
Password: req.Password,
|
|
Role: string(domain.RoleAdmin),
|
|
}
|
|
|
|
newUser, err := h.userSvc.CreateUser(c.Context(), user, true)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("failed to create admin user",
|
|
zap.Int64("status_code", fiber.StatusInternalServerError),
|
|
zap.Any("request", req),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create admin:"+err.Error())
|
|
}
|
|
|
|
// if req.OrganizationID != nil {
|
|
// _, err := h.companySvc.UpdateCompany(c.Context(), domain.UpdateCompany{
|
|
// ID: *req.OrganizationID,
|
|
// AdminID: domain.ValidInt64{
|
|
// Value: newUser.ID,
|
|
// Valid: true,
|
|
// },
|
|
// })
|
|
// if err != nil {
|
|
// h.mongoLoggerSvc.Error("failed to update company with new admin",
|
|
// zap.Int64("status_code", fiber.StatusInternalServerError),
|
|
// zap.Int64("company_id", *req.OrganizationID),
|
|
// zap.Int64("admin_id", newUser.ID),
|
|
// zap.Error(err),
|
|
// zap.Time("timestamp", time.Now()),
|
|
// )
|
|
// return fiber.NewError(fiber.StatusInternalServerError, "Failed to update company"+err.Error())
|
|
// }
|
|
// }
|
|
|
|
h.mongoLoggerSvc.Info("admin created successfully",
|
|
zap.Int64("admin_id", newUser.ID),
|
|
zap.String("email", newUser.Email),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
actorID := c.Locals("user_id").(int64)
|
|
actorRole := string(c.Locals("role").(domain.Role))
|
|
ip := c.IP()
|
|
ua := c.Get("User-Agent")
|
|
meta, _ := json.Marshal(map[string]interface{}{"email": req.Email, "admin_id": newUser.ID})
|
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionAdminCreated, domain.ResourceAdmin, &newUser.ID, "Created admin: "+req.Email, meta, &ip, &ua)
|
|
|
|
h.sendInAppNotification(newUser.ID, domain.NOTIFICATION_TYPE_ADMIN_CREATED, "Welcome to Yimaru", "Your admin account has been created successfully.")
|
|
|
|
return response.WriteJSON(c, fiber.StatusOK, "Admin created successfully", nil, nil)
|
|
}
|
|
|
|
type AdminRes struct {
|
|
ID int64 `json:"id"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Email string `json:"email"`
|
|
PhoneNumber string `json:"phone_number"`
|
|
Role domain.Role `json:"role"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
PhoneVerified bool `json:"phone_verified"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
LastLogin time.Time `json:"last_login"`
|
|
SuspendedAt time.Time `json:"suspended_at"`
|
|
Suspended bool `json:"suspended"`
|
|
}
|
|
|
|
// GetAllAdmins godoc
|
|
// @Summary Get all Admins
|
|
// @Description Get all Admins
|
|
// @Tags admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param page query int false "Page number"
|
|
// @Param page_size query int false "Page size"
|
|
// @Success 200 {object} AdminRes
|
|
// @Failure 400 {object} response.APIResponse
|
|
// @Failure 401 {object} response.APIResponse
|
|
// @Failure 500 {object} response.APIResponse
|
|
// @Router /api/v1/admin [get]
|
|
func (h *Handler) GetAllAdmins(c *fiber.Ctx) error {
|
|
searchQuery := c.Query("query")
|
|
searchString := domain.ValidString{
|
|
Value: searchQuery,
|
|
// Valid: searchQuery != "",
|
|
}
|
|
|
|
createdBeforeQuery := c.Query("created_before")
|
|
var createdBefore domain.ValidTime
|
|
if createdBeforeQuery != "" {
|
|
parsed, err := time.Parse(time.RFC3339, createdBeforeQuery)
|
|
if err != nil {
|
|
h.logger.Info("invalid created_before format", "error", err)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid created_before format")
|
|
}
|
|
createdBefore = domain.ValidTime{Value: parsed, Valid: true}
|
|
}
|
|
|
|
createdAfterQuery := c.Query("created_after")
|
|
var createdAfter domain.ValidTime
|
|
if createdAfterQuery != "" {
|
|
parsed, err := time.Parse(time.RFC3339, createdAfterQuery)
|
|
if err != nil {
|
|
h.logger.Info("invalid created_after format", "error", err)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid created_after format")
|
|
}
|
|
createdAfter = domain.ValidTime{Value: parsed, Valid: true}
|
|
}
|
|
|
|
// companyID := int64(c.QueryInt("company_id"))
|
|
filter := domain.UserFilter{
|
|
Role: string(domain.RoleAdmin),
|
|
// OrganizationID: domain.ValidInt64{
|
|
// Value: companyID,
|
|
// Valid: companyID != 0,
|
|
// },
|
|
Page: int64(c.QueryInt("page", 1) - 1),
|
|
PageSize: int64(c.QueryInt("page_size", 10)),
|
|
Query: searchString.Value,
|
|
CreatedBefore: createdBefore,
|
|
CreatedAfter: createdAfter,
|
|
}
|
|
|
|
if valErrs, ok := h.validator.Validate(c, filter); !ok {
|
|
var errMsg string
|
|
for f, msg := range valErrs {
|
|
errMsg += fmt.Sprintf("%s: %s; ", f, msg)
|
|
}
|
|
h.mongoLoggerSvc.Info("invalid filter values in GetAllAdmins request",
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Any("validation_errors", valErrs),
|
|
zap.Time("timestamp", time.Now()))
|
|
return fiber.NewError(fiber.StatusBadRequest, errMsg)
|
|
}
|
|
|
|
admins, total, err := h.userSvc.GetAllUsers(c.Context(), filter)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("failed to get admins",
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Any("filter", filter),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()))
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get admins: "+err.Error())
|
|
}
|
|
|
|
result := make([]AdminRes, len(admins))
|
|
for i, admin := range admins {
|
|
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), admin.ID)
|
|
if err != nil && err != authentication.ErrRefreshTokenNotFound {
|
|
h.mongoLoggerSvc.Error("failed to get last login",
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Int64("admin_id", admin.ID),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()))
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get last login: "+err.Error())
|
|
}
|
|
if err == authentication.ErrRefreshTokenNotFound {
|
|
lastLogin = &admin.CreatedAt
|
|
}
|
|
|
|
result[i] = AdminRes{
|
|
ID: admin.ID,
|
|
FirstName: admin.FirstName,
|
|
LastName: admin.LastName,
|
|
Email: admin.Email,
|
|
PhoneNumber: admin.PhoneNumber,
|
|
Role: admin.Role,
|
|
EmailVerified: admin.EmailVerified,
|
|
PhoneVerified: admin.PhoneVerified,
|
|
CreatedAt: admin.CreatedAt,
|
|
LastLogin: *lastLogin,
|
|
}
|
|
}
|
|
|
|
h.mongoLoggerSvc.Info("admins retrieved successfully",
|
|
zap.Int("status_code", fiber.StatusOK),
|
|
zap.Int("count", len(result)),
|
|
zap.Int("page", int(filter.Page+1)),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, int(filter.Page+1), int(total))
|
|
}
|
|
|
|
// GetAdminByID godoc
|
|
// @Summary Get admin by id
|
|
// @Description Get a single admin by id
|
|
// @Tags admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path int true "User ID"
|
|
// @Success 200 {object} AdminRes
|
|
// @Failure 400 {object} response.APIResponse
|
|
// @Failure 401 {object} response.APIResponse
|
|
// @Failure 500 {object} response.APIResponse
|
|
// @Router /api/v1/admin/{id} [get]
|
|
func (h *Handler) GetAdminByID(c *fiber.Ctx) error {
|
|
idStr := c.Params("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid admin ID")
|
|
}
|
|
|
|
user, err := h.userSvc.GetUserByID(c.Context(), id)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get admin: "+err.Error())
|
|
}
|
|
|
|
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
|
|
if err != nil && err != authentication.ErrRefreshTokenNotFound {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get last login: "+err.Error())
|
|
}
|
|
if err == authentication.ErrRefreshTokenNotFound {
|
|
lastLogin = &user.CreatedAt
|
|
}
|
|
|
|
res := AdminRes{
|
|
ID: user.ID,
|
|
FirstName: user.FirstName,
|
|
LastName: user.LastName,
|
|
Email: user.Email,
|
|
PhoneNumber: user.PhoneNumber,
|
|
Role: user.Role,
|
|
EmailVerified: user.EmailVerified,
|
|
PhoneVerified: user.PhoneVerified,
|
|
CreatedAt: user.CreatedAt,
|
|
LastLogin: *lastLogin,
|
|
}
|
|
|
|
return response.WriteJSON(c, fiber.StatusOK, "Admin retrieved successfully", res, nil)
|
|
}
|
|
|
|
type updateAdminReq struct {
|
|
FirstName string `json:"first_name" example:"John"`
|
|
LastName string `json:"last_name" example:"Doe"`
|
|
Suspended bool `json:"suspended" example:"false"`
|
|
}
|
|
|
|
// UpdateAdmin godoc
|
|
// @Summary Update Admin
|
|
// @Description Update Admin
|
|
// @Tags admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param admin body updateAdminReq true "Update Admin"
|
|
// @Success 200 {object} response.APIResponse
|
|
// @Failure 400 {object} response.APIResponse
|
|
// @Failure 401 {object} response.APIResponse
|
|
// @Failure 500 {object} response.APIResponse
|
|
// @Router /api/v1/admin/{id} [put]
|
|
func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
|
|
var req updateAdminReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body: "+err.Error())
|
|
}
|
|
|
|
adminIDStr := c.Params("id")
|
|
adminID, err := strconv.ParseInt(adminIDStr, 10, 64)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid admin ID")
|
|
}
|
|
|
|
// var orgID domain.ValidInt64
|
|
// if req.OrganizationID != nil {
|
|
// orgID = domain.ValidInt64{
|
|
// Value: *req.OrganizationID,
|
|
// Valid: true,
|
|
// }
|
|
// }
|
|
|
|
err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{
|
|
UserID: adminID,
|
|
FirstName: req.FirstName,
|
|
LastName: req.LastName,
|
|
// OrganizationID: orgID,
|
|
})
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update admin: "+err.Error())
|
|
}
|
|
|
|
actorID := c.Locals("user_id").(int64)
|
|
actorRole := string(c.Locals("role").(domain.Role))
|
|
ip := c.IP()
|
|
ua := c.Get("User-Agent")
|
|
meta, _ := json.Marshal(map[string]interface{}{"admin_id": adminID, "first_name": req.FirstName, "last_name": req.LastName})
|
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionAdminUpdated, domain.ResourceAdmin, &adminID, fmt.Sprintf("Updated admin ID: %d", adminID), meta, &ip, &ua)
|
|
|
|
return response.WriteJSON(c, fiber.StatusOK, "Admin updated successfully", nil, nil)
|
|
}
|
|
|
|
// bulkAccountsRoleFromPath resolves admin bulk :role: decimal digits → rbac roles.id lookup (same ids as GET /api/v1/rbac/roles); otherwise uppercase role key.
|
|
func (h *Handler) bulkAccountsRoleFromPath(c *fiber.Ctx) (roleKey string, ok bool) {
|
|
raw := strings.TrimSpace(c.Params("role"))
|
|
if raw == "" {
|
|
_ = c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid role",
|
|
Error: "role path parameter is required",
|
|
})
|
|
return "", false
|
|
}
|
|
if rbacID, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil {
|
|
if rbacID <= 0 {
|
|
_ = c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid role",
|
|
Error: "numeric role path must be a positive RBAC roles.id (see GET /api/v1/rbac/roles)",
|
|
})
|
|
return "", false
|
|
}
|
|
rec, err := h.rbacSvc.GetRoleByID(c.Context(), rbacID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
_ = c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
|
Message: "Invalid role",
|
|
Error: "RBAC role id not found",
|
|
})
|
|
return "", false
|
|
}
|
|
_ = c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "RBAC lookup failed",
|
|
Error: err.Error(),
|
|
})
|
|
return "", false
|
|
}
|
|
return strings.ToUpper(strings.TrimSpace(rec.Name)), true
|
|
}
|
|
return strings.ToUpper(raw), true
|
|
}
|
|
|
|
// BulkDeactivateAccountsByRole godoc
|
|
// @Summary Bulk deactivate accounts by role (SUPER_ADMIN or ADMIN platform users 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. Path :role may be a role key (e.g. INSTRUCTOR, ADMIN) or a decimal RBAC roles.id from GET /api/v1/rbac/roles (resolved to RoleRecord.name uppercased). SUPER_ADMIN cannot be bulk-deactivated. ADMIN platform users must use SUPER_ADMIN to bulk change other platform ADMIN users (team_members with team_role ADMIN under path ADMIN remain allowed). 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 (INSTRUCTOR etc.) or RBAC roles.id (integer string)"
|
|
// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 403 {object} domain.ErrorResponse
|
|
// @Failure 404 {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 && callerRole != domain.RoleAdmin {
|
|
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
|
Message: "Forbidden",
|
|
Error: "only SUPER_ADMIN or 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, rpOK := h.bulkAccountsRoleFromPath(c)
|
|
if !rpOK {
|
|
return nil
|
|
}
|
|
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",
|
|
})
|
|
}
|
|
|
|
// Non-super-admins cannot bulk change other platform ADMIN users (same role); team_members ADMIN is still allowed.
|
|
if callerRole != domain.RoleSuperAdmin && roleKey == string(domain.RoleAdmin) {
|
|
validUserRole = false
|
|
}
|
|
|
|
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 or ADMIN platform users 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 may be a role key or decimal RBAC roles.id (see bulk-deactivate). Path role must correspond to valid platform users.role or team_members.team_role (after resolving id → name). SUPER_ADMIN cannot be bulk changed. ADMIN callers cannot bulk change other platform ADMIN users (team_members ADMIN under path ADMIN is allowed). 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 (INSTRUCTOR etc.) or RBAC roles.id (integer string)"
|
|
// @Param body body domain.BulkAccountsByRoleRequest false "Optional exclusions"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 403 {object} domain.ErrorResponse
|
|
// @Failure 404 {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 && callerRole != domain.RoleAdmin {
|
|
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
|
Message: "Forbidden",
|
|
Error: "only SUPER_ADMIN or 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, rpOK := h.bulkAccountsRoleFromPath(c)
|
|
if !rpOK {
|
|
return nil
|
|
}
|
|
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",
|
|
})
|
|
}
|
|
|
|
// Non-super-admins cannot bulk change other platform ADMIN users; team_members ADMIN is still allowed.
|
|
if callerRole != domain.RoleSuperAdmin && roleKey == string(domain.RoleAdmin) {
|
|
validUserRole = false
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|