301 lines
10 KiB
Go
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()})
|
|
}
|
|
}
|