veza/veza-backend-api/internal/handlers/social_group_handler.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
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.
2026-04-14 12:22:14 +02:00

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