Yimaru-BackEnd/internal/web_server/handlers/team_invitation_handler.go
2026-05-22 06:49:28 -07:00

301 lines
10 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
type teamInvitationListItem struct {
ID int64 `json:"id"`
TeamMemberID int64 `json:"team_member_id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
TeamRole string `json:"team_role"`
Status string `json:"status"`
ExpiresAt string `json:"expires_at"`
CreatedAt string `json:"created_at"`
}
type listTeamInvitationsRes struct {
Invitations []teamInvitationListItem `json:"invitations"`
TotalCount int64 `json:"total_count"`
}
func mapTeamInvitationListItem(row domain.TeamInvitationWithMember) teamInvitationListItem {
firstName, lastName := row.FirstName, row.LastName
if domain.IsTeamInvitePlaceholderProfile(firstName, lastName) {
firstName, lastName = "", ""
}
return teamInvitationListItem{
ID: row.ID,
TeamMemberID: row.TeamMemberID,
Email: row.Email,
FirstName: firstName,
LastName: lastName,
TeamRole: string(row.TeamRole),
Status: string(row.Status),
ExpiresAt: row.ExpiresAt.Format(time.RFC3339),
CreatedAt: row.CreatedAt.Format(time.RFC3339),
}
}
// InviteTeamMember godoc
// @Summary Invite a team member by email
// @Description Creates a pending team member (email + team_role only) and sends an invitation email; profile is completed on accept
// @Tags team
// @Accept json
// @Produce json
// @Param body body domain.InviteTeamMemberReq true "Invite payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/team/members/invite [post]
func (h *Handler) InviteTeamMember(c *fiber.Ctx) error {
var req domain.InviteTeamMemberReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
inviterID, _ := c.Locals("user_id").(int64)
var invitedBy *int64
if inviterID > 0 {
invitedBy = &inviterID
}
res, err := h.teamSvc.InviteTeamMember(c.Context(), req, invitedBy)
if err != nil {
return h.teamInvitationError(c, err, "Failed to send team invitation")
}
actorRole := ""
if role, ok := c.Locals("role").(domain.Role); ok {
actorRole = string(role)
}
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"email": res.Email, "team_member_id": res.TeamMemberID})
go h.activityLogSvc.RecordAction(context.Background(), invitedBy, &actorRole, domain.ActionTeamMemberInvited, domain.ResourceTeamMember, &res.TeamMemberID, "Invited team member: "+res.Email, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Team invitation sent successfully",
Data: res,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ResendTeamInvitation godoc
// @Summary Resend team invitation
// @Description Revokes the current pending invite and sends a new invitation email
// @Tags team
// @Produce json
// @Param id path int true "Team member ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/team/members/{id}/resend-invite [post]
func (h *Handler) ResendTeamInvitation(c *fiber.Ctx) error {
memberID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || memberID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid team member ID",
})
}
inviterID, _ := c.Locals("user_id").(int64)
var invitedBy *int64
if inviterID > 0 {
invitedBy = &inviterID
}
res, err := h.teamSvc.ResendTeamInvitation(c.Context(), memberID, invitedBy)
if err != nil {
return h.teamInvitationError(c, err, "Failed to resend team invitation")
}
return c.JSON(domain.Response{
Message: "Team invitation resent successfully",
Data: res,
Success: true,
})
}
// ListTeamInvitations godoc
// @Summary List team invitations
// @Description Lists team member invitations with optional status filter
// @Tags team
// @Produce json
// @Param status query string false "pending, accepted, expired, or revoked"
// @Param limit query int false "Limit (default 20)"
// @Param offset query int false "Offset (default 0)"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations [get]
func (h *Handler) ListTeamInvitations(c *fiber.Ctx) error {
status := strings.TrimSpace(c.Query("status"))
var statusPtr *string
if status != "" {
statusPtr = &status
}
limit, _ := strconv.Atoi(c.Query("limit", "20"))
offset, _ := strconv.Atoi(c.Query("offset", "0"))
rows, total, err := h.teamSvc.ListTeamInvitations(c.Context(), statusPtr, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to list team invitations",
Error: err.Error(),
})
}
out := make([]teamInvitationListItem, 0, len(rows))
for _, row := range rows {
out = append(out, mapTeamInvitationListItem(row))
}
return c.JSON(domain.Response{
Message: "Team invitations retrieved successfully",
Data: listTeamInvitationsRes{
Invitations: out,
TotalCount: total,
},
Success: true,
})
}
// RevokeTeamInvitation godoc
// @Summary Revoke a pending team invitation
// @Description Revokes the invitation and removes the pending team member if not yet accepted
// @Tags team
// @Produce json
// @Param id path int true "Invitation ID"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations/{id}/revoke [post]
func (h *Handler) RevokeTeamInvitation(c *fiber.Ctx) error {
invitationID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || invitationID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid invitation ID",
})
}
if err := h.teamSvc.RevokeTeamInvitation(c.Context(), invitationID); err != nil {
return h.teamInvitationError(c, err, "Failed to revoke team invitation")
}
return c.JSON(domain.Response{
Message: "Team invitation revoked successfully",
Data: fiber.Map{"id": invitationID},
Success: true,
})
}
// VerifyTeamInvitation godoc
// @Summary Verify team invitation token
// @Description Public endpoint used by the admin panel accept-invite page
// @Tags team
// @Produce json
// @Param token query string true "Invitation token"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations/verify [get]
func (h *Handler) VerifyTeamInvitation(c *fiber.Ctx) error {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invitation token is required",
})
}
res, err := h.teamSvc.VerifyTeamInvitation(c.Context(), token)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to verify invitation",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Invitation verification completed",
Data: res,
Success: true,
})
}
// AcceptTeamInvitation godoc
// @Summary Accept team invitation and complete account setup
// @Description Public endpoint to set password and profile details after following the invite link
// @Tags team
// @Accept json
// @Produce json
// @Param body body domain.AcceptTeamInvitationReq true "Accept invitation payload"
// @Success 200 {object} domain.Response
// @Router /api/v1/team/invitations/accept [post]
func (h *Handler) AcceptTeamInvitation(c *fiber.Ctx) error {
var req domain.AcceptTeamInvitationReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
member, err := h.teamSvc.AcceptTeamInvitation(c.Context(), req)
if err != nil {
return h.teamInvitationError(c, err, "Failed to accept team invitation")
}
return c.JSON(domain.Response{
Message: "Team account activated successfully. You can now sign in.",
Data: toTeamMemberResponse(&member),
Success: true,
})
}
func (h *Handler) teamInvitationError(c *fiber.Ctx, err error, message string) error {
switch {
case errors.Is(err, domain.ErrTeamMemberEmailExists):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Email already exists"})
case errors.Is(err, domain.ErrInvalidTeamRole), errors.Is(err, domain.ErrInvalidEmploymentType):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
case errors.Is(err, domain.ErrTeamInviteBaseURLNotConfigured):
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: "TEAM_INVITE_BASE_URL is not configured"})
case errors.Is(err, domain.ErrTeamInvitationNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Invitation not found"})
case errors.Is(err, domain.ErrTeamInvitationExpired):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has expired"})
case errors.Is(err, domain.ErrTeamInvitationAlreadyUsed):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has already been accepted"})
case errors.Is(err, domain.ErrTeamInvitationRevoked):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: message, Error: "Invitation has been revoked"})
case errors.Is(err, domain.ErrTeamMemberNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: message, Error: "Team member not found"})
default:
h.mongoLoggerSvc.Error(message, zap.Error(err))
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: message, Error: err.Error()})
}
}