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 { return teamInvitationListItem{ ID: row.ID, TeamMemberID: row.TeamMemberID, Email: row.Email, FirstName: row.FirstName, LastName: row.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 and sends an invitation email with a setup link // @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 set password // @Description Public endpoint to activate a team member account 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()}) } }