backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.
The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
454 lines
13 KiB
Go
454 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"veza-backend-api/internal/core/social"
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/pagination"
|
|
"veza-backend-api/internal/utils"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// GroupHandler handles social group operations
|
|
type GroupHandler struct {
|
|
service *social.Service
|
|
commonHandler *CommonHandler
|
|
}
|
|
|
|
// NewGroupHandler creates a new GroupHandler
|
|
func NewGroupHandler(service *social.Service, logger *zap.Logger) *GroupHandler {
|
|
return &GroupHandler{
|
|
service: service,
|
|
commonHandler: NewCommonHandler(logger),
|
|
}
|
|
}
|
|
|
|
// CreateGroupRequest is the DTO for group creation
|
|
type CreateGroupRequest struct {
|
|
Name string `json:"name" binding:"required,min=1,max=255" validate:"required,min=1,max=255"`
|
|
Description string `json:"description" binding:"max=2000" validate:"max=2000"`
|
|
IsPublic *bool `json:"is_public"`
|
|
}
|
|
|
|
// CreateGroup creates a new social group
|
|
func (h *GroupHandler) CreateGroup(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateGroupRequest
|
|
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
req.Name = utils.SanitizeText(req.Name, 255)
|
|
req.Description = utils.SanitizeText(req.Description, 2000)
|
|
|
|
isPublic := true
|
|
if req.IsPublic != nil {
|
|
isPublic = *req.IsPublic
|
|
}
|
|
|
|
group, err := h.service.CreateGroup(c.Request.Context(), userID, req.Name, req.Description, isPublic)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create group", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusCreated, group)
|
|
}
|
|
|
|
// ListGroups returns all public groups with standard pagination ?page=1&limit=20
|
|
func (h *GroupHandler) ListGroups(c *gin.Context) {
|
|
params, appErr := pagination.ParseParams(c)
|
|
if appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
groups, total, err := h.service.ListGroups(c.Request.Context(), params.Limit, params.Offset())
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list groups", err))
|
|
return
|
|
}
|
|
|
|
meta := pagination.BuildMeta(params.Page, params.Limit, total)
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"groups": groups,
|
|
"pagination": meta,
|
|
})
|
|
}
|
|
|
|
// GetGroup returns a group by ID. When authenticated, includes user_status (is_member, role, has_pending_request).
|
|
func (h *GroupHandler) GetGroup(c *gin.Context) {
|
|
groupID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID"))
|
|
return
|
|
}
|
|
|
|
group, err := h.service.GetGroup(c.Request.Context(), groupID)
|
|
if err != nil {
|
|
if errors.Is(err, social.ErrGroupNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("Group"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get group", err))
|
|
return
|
|
}
|
|
|
|
resp := gin.H{
|
|
"id": group.ID,
|
|
"name": group.Name,
|
|
"description": group.Description,
|
|
"creator_id": group.CreatorID,
|
|
"avatar_url": group.AvatarURL,
|
|
"is_public": group.IsPublic,
|
|
"member_count": group.MemberCount,
|
|
"created_at": group.CreatedAt,
|
|
"updated_at": group.UpdatedAt,
|
|
}
|
|
|
|
if userID, ok := GetUserIDUUID(c); ok {
|
|
isMember, role, hasPendingRequest, err := h.service.GetGroupMemberStatus(c.Request.Context(), userID, groupID)
|
|
if err == nil {
|
|
resp["user_status"] = gin.H{
|
|
"is_member": isMember,
|
|
"role": role,
|
|
"has_pending_request": hasPendingRequest,
|
|
}
|
|
}
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, resp)
|
|
}
|
|
|
|
// JoinGroup adds the authenticated user to a group
|
|
func (h *GroupHandler) JoinGroup(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
groupID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID"))
|
|
return
|
|
}
|
|
|
|
if err := h.service.JoinGroup(c.Request.Context(), userID, groupID); err != nil {
|
|
if errors.Is(err, social.ErrGroupNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("Group"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrAlreadyMember) {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "Already a member"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to join group", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Joined group successfully"})
|
|
}
|
|
|
|
// LeaveGroup removes the authenticated user from a group
|
|
func (h *GroupHandler) LeaveGroup(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
groupID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID"))
|
|
return
|
|
}
|
|
|
|
if err := h.service.LeaveGroup(c.Request.Context(), userID, groupID); err != nil {
|
|
if errors.Is(err, social.ErrGroupNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("Group"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrNotMember) {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Not a member of this group"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrCannotLeaveOwned) {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("Creator cannot leave group"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to leave group", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Left group successfully"})
|
|
}
|
|
|
|
// RequestToJoin creates a join request for private groups (S2.1)
|
|
func (h *GroupHandler) RequestToJoin(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
groupID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID"))
|
|
return
|
|
}
|
|
|
|
req, err := h.service.RequestToJoin(c.Request.Context(), userID, groupID)
|
|
if err != nil {
|
|
if errors.Is(err, social.ErrGroupNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("Group"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrAlreadyMember) {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "Already a member"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrRequestAlreadyExist) {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "Join request already pending"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to request join", err))
|
|
return
|
|
}
|
|
|
|
if req == nil {
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Joined group successfully"})
|
|
return
|
|
}
|
|
RespondSuccess(c, http.StatusCreated, req)
|
|
}
|
|
|
|
// ListJoinRequests returns pending join requests (admin/modo only) (S2.1)
|
|
func (h *GroupHandler) ListJoinRequests(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
groupID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID"))
|
|
return
|
|
}
|
|
|
|
requests, err := h.service.ListJoinRequests(c.Request.Context(), groupID, userID)
|
|
if err != nil {
|
|
if errors.Is(err, social.ErrForbidden) {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("Forbidden"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list requests", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"requests": requests})
|
|
}
|
|
|
|
// ApproveJoinRequest approves a join request (S2.1)
|
|
func (h *GroupHandler) ApproveJoinRequest(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
requestID, err := uuid.Parse(c.Param("request_id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid request ID"))
|
|
return
|
|
}
|
|
|
|
if err := h.service.ApproveJoinRequest(c.Request.Context(), requestID, userID); err != nil {
|
|
if errors.Is(err, social.ErrRequestNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("Request"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrForbidden) {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("Forbidden"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to approve request", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Request approved"})
|
|
}
|
|
|
|
// RejectJoinRequest rejects a join request (S2.1)
|
|
func (h *GroupHandler) RejectJoinRequest(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
requestID, err := uuid.Parse(c.Param("request_id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid request ID"))
|
|
return
|
|
}
|
|
|
|
if err := h.service.RejectJoinRequest(c.Request.Context(), requestID, userID); err != nil {
|
|
if errors.Is(err, social.ErrRequestNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("Request"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrForbidden) {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("Forbidden"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to reject request", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Request rejected"})
|
|
}
|
|
|
|
// InviteMemberRequest is the DTO for inviting a member
|
|
type InviteMemberRequest struct {
|
|
Email *string `json:"email"`
|
|
UserID *uuid.UUID `json:"user_id"`
|
|
}
|
|
|
|
// InviteMember invites a user to join a group (S2.2)
|
|
func (h *GroupHandler) InviteMember(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
groupID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID"))
|
|
return
|
|
}
|
|
|
|
var req InviteMemberRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
|
|
return
|
|
}
|
|
|
|
var inviteeID uuid.UUID
|
|
if req.UserID != nil {
|
|
inviteeID = *req.UserID
|
|
} else if req.Email != nil {
|
|
inviteeID, err = h.service.ResolveInviteeByEmailOrID(c.Request.Context(), *req.Email)
|
|
if err != nil {
|
|
if errors.Is(err, social.ErrUserNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("User"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to resolve user", err))
|
|
return
|
|
}
|
|
} else {
|
|
RespondWithAppError(c, apperrors.NewValidationError("email or user_id required"))
|
|
return
|
|
}
|
|
|
|
if err := h.service.InviteMember(c.Request.Context(), userID, groupID, inviteeID); err != nil {
|
|
if errors.Is(err, social.ErrForbidden) {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("Forbidden"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrAlreadyMember) {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "User is already a member"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrUserNotFound) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("User"))
|
|
return
|
|
}
|
|
if err.Error() == "invitation already pending" {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "Invitation already pending"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to invite member", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusCreated, gin.H{"message": "Invitation sent"})
|
|
}
|
|
|
|
// UpdateMemberRoleRequest is the DTO for updating member role
|
|
type UpdateMemberRoleRequest struct {
|
|
Role string `json:"role" binding:"required,oneof=admin moderator member"`
|
|
}
|
|
|
|
// UpdateMemberRole updates a member's role (S2.3)
|
|
func (h *GroupHandler) UpdateMemberRole(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
groupID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid group ID"))
|
|
return
|
|
}
|
|
|
|
targetUserID, err := uuid.Parse(c.Param("user_id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid user ID"))
|
|
return
|
|
}
|
|
|
|
var req UpdateMemberRoleRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid request body"))
|
|
return
|
|
}
|
|
|
|
if err := h.service.UpdateMemberRole(c.Request.Context(), userID, groupID, targetUserID, req.Role); err != nil {
|
|
if errors.Is(err, social.ErrForbidden) {
|
|
RespondWithAppError(c, apperrors.NewForbiddenError("Forbidden"))
|
|
return
|
|
}
|
|
if errors.Is(err, social.ErrNotMember) {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("User"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update role", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Role updated"})
|
|
}
|
|
|
|
// ListMyGroups returns groups the user is a member of (S2.5) with standard pagination ?page=1&limit=20
|
|
func (h *GroupHandler) ListMyGroups(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
params, appErr := pagination.ParseParams(c)
|
|
if appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
groups, total, err := h.service.ListMyGroups(c.Request.Context(), userID, params.Limit, params.Offset())
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list groups", err))
|
|
return
|
|
}
|
|
|
|
meta := pagination.BuildMeta(params.Page, params.Limit, total)
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"groups": groups,
|
|
"pagination": meta,
|
|
})
|
|
}
|