package handler import ( "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify/templates" ) // CreateOrganization creates a new organization func (h *Handler) CreateOrganization(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } var req models.CreateOrganizationRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) return } org, xerr := h.OrganizationService.Create(c.Request.Context(), userID, req.Name) if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionCreate, models.AuditEntityOrganization, &org.ID, nil, map[string]string{"name": org.Name}) c.JSON(http.StatusCreated, org) } // GetUserOrganizations returns all organizations the user is a member of func (h *Handler) GetUserOrganizations(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } members, xerr := h.OrganizationService.GetUserOrganizations(c.Request.Context(), userID) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"data": members}) } // SwitchOrganization switches the current organization in the session func (h *Handler) SwitchOrganization(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } orgIDStr := c.Param("id") orgID, err := uuid.Parse(orgIDStr) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid organization ID")) return } // Verify user is a member member, xerr := h.OrganizationService.GetMembership(c.Request.Context(), orgID, userID) if xerr != nil { errx.JSON(c, xerr) return } if member == nil { errx.JSON(c, errx.New(errx.Forbidden, "not a member of this organization")) return } // Get session from context session := middleware.GetSession(c) if session == nil { errx.JSON(c, errx.ErrUnauthorized) return } // Update session with new organization if xerr := h.TokenService.SwitchOrganization(c.Request.Context(), session.ID, &orgID); xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{ "message": "organization switched", "organization_id": orgID, }) } // GetCurrentOrganization returns the current organization from session func (h *Handler) GetCurrentOrganization(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } org, xerr := h.OrganizationService.Get(c.Request.Context(), *orgID) if xerr != nil { errx.JSON(c, xerr) return } // Get counts and limits counts, _ := h.OrganizationService.GetOrganizationCounts(c.Request.Context(), *orgID) limits, _ := h.OrganizationService.GetOrganizationLimits(c.Request.Context(), *orgID) result := models.OrganizationWithLimits{ Organization: *org, Limits: limits, Counts: counts, } c.JSON(http.StatusOK, result) } // UpdateOrganization updates the current organization func (h *Handler) UpdateOrganization(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } var req models.UpdateOrganizationRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) return } org, xerr := h.OrganizationService.Update(c.Request.Context(), *orgID, &req) if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityOrganization, orgID, nil, nil) // A presence privacy change re-gates connected sockets live (re-track / // untrack / strip activity) rather than waiting for members to reconnect. if req.PresenceShowOnline != nil || req.PresenceShowActivity != nil { h.StreamingPublisher.PublishPresencePolicy(c.Request.Context(), *orgID, org.PresenceShowOnline, org.PresenceShowActivity) } c.JSON(http.StatusOK, org) } // GetMembers returns all members of the current organization func (h *Handler) GetMembers(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } members, xerr := h.OrganizationService.GetMembers(c.Request.Context(), *orgID) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"data": members}) } // InviteMember invites a new member to the organization func (h *Handler) InviteMember(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } var req models.InviteMemberRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) return } inv, xerr := h.OrganizationService.InviteMember(c.Request.Context(), *orgID, userID, &req) if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionInvite, models.AuditEntityOrganizationMember, nil, nil, map[string]string{"email": inv.Email, "role": inv.Role}) // Get organization name for email org, _ := h.OrganizationService.Get(c.Request.Context(), *orgID) orgName := "your organization" if org != nil { orgName = org.Name } // Get inviter name inviter, _ := h.UserService.GetUser(c.Request.Context(), userID) inviterName := "A team member" if inviter != nil && inviter.FirstName != "" { inviterName = inviter.FirstName if inviter.LastName != "" { inviterName += " " + inviter.LastName } } // Send invitation email if h.EmailNotificationService != nil { subject := fmt.Sprintf("You've been invited to join %s on Warmbly", orgName) acceptURL := fmt.Sprintf("%s/invite?token=%s", templates.AppURL, inv.Token) // GenerateInvitationHTML reports its own render errors to Sentry. if body, gerr := templates.GenerateInvitationHTML(inviterName, orgName, acceptURL); gerr == nil { go h.EmailNotificationService.Send(c.Request.Context(), []string{req.Email}, nil, nil, subject, body) } } c.JSON(http.StatusCreated, gin.H{ "message": "invitation sent", "invitation": inv, }) } // UpdateMemberRole updates a member's role and permissions func (h *Handler) UpdateMemberRole(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } memberIDStr := c.Param("id") memberUserID, err := uuid.Parse(memberIDStr) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid member ID")) return } var req models.UpdateMemberRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) return } actorID, uerr := middleware.GetUserUUID(c) if uerr != nil { errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) return } member, xerr := h.OrganizationService.UpdateMemberRole(c.Request.Context(), *orgID, actorID, memberUserID, &req) if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityOrganizationMember, &memberUserID, nil, map[string]string{"role": member.Role}) c.JSON(http.StatusOK, member) } // RemoveMember removes a member from the organization func (h *Handler) RemoveMember(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } memberIDStr := c.Param("id") memberUserID, err := uuid.Parse(memberIDStr) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid member ID")) return } if xerr := h.OrganizationService.RemoveMember(c.Request.Context(), *orgID, memberUserID); xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionRemove, models.AuditEntityOrganizationMember, &memberUserID, nil, nil) c.JSON(http.StatusOK, gin.H{"message": "member removed"}) } // TransferOwnership transfers organization ownership to another member func (h *Handler) TransferOwnership(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } var req models.TransferOwnershipRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) return } if xerr := h.OrganizationService.TransferOwnership(c.Request.Context(), *orgID, req.NewOwnerUserID); xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionTransfer, models.AuditEntityOrganization, orgID, nil, map[string]string{"new_owner": req.NewOwnerUserID.String()}) c.JSON(http.StatusOK, gin.H{"message": "ownership transferred"}) } // GetPendingInvitations returns pending invitations for the organization func (h *Handler) GetPendingInvitations(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } invitations, xerr := h.OrganizationService.GetPendingInvitations(c.Request.Context(), *orgID) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"data": invitations}) } // CancelInvitation cancels a pending invitation func (h *Handler) CancelInvitation(c *gin.Context) { invIDStr := c.Param("id") invID, err := uuid.Parse(invIDStr) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid invitation ID")) return } if xerr := h.OrganizationService.CancelInvitation(c.Request.Context(), invID); xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionDelete, models.AuditEntityInvitation, &invID, nil, nil) c.JSON(http.StatusOK, gin.H{"message": "invitation cancelled"}) } // PreviewInvitation is the public landing-page lookup for the /invite link. // No auth: anyone holding the secret token can see who invited them where. func (h *Handler) PreviewInvitation(c *gin.Context) { token := c.Query("token") if token == "" { errx.JSON(c, errx.New(errx.BadRequest, "token is required")) return } preview, xerr := h.OrganizationService.PreviewInvitation(c.Request.Context(), token) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, preview) } // GetInvitationLink returns the shareable /invite token for a pending // invitation so a team manager can copy a real accept link. func (h *Handler) GetInvitationLink(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } invitationID, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.ErrUuid) return } token, xerr := h.OrganizationService.GetInvitationToken(c.Request.Context(), *orgID, invitationID) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"token": token}) } // AcceptInvitation accepts an invitation (public endpoint - can be called before login or after) func (h *Handler) AcceptInvitation(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } var req models.AcceptInvitationRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) return } // Get user email from user service user, xerr := h.UserService.GetUser(c.Request.Context(), userID) if xerr != nil { errx.JSON(c, xerr) return } var member *models.OrganizationMember if req.Token != "" { member, xerr = h.OrganizationService.AcceptInvitation(c.Request.Context(), req.Token, userID, user.Email) } else if req.InvitationID != nil { member, xerr = h.OrganizationService.AcceptInvitationByID(c.Request.Context(), *req.InvitationID, userID, user.Email) } else { errx.JSON(c, errx.New(errx.BadRequest, "token or invitation_id is required")) return } if xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionCreate, models.AuditEntityOrganizationMember, &member.UserID, nil, map[string]string{"via": "invitation"}) c.JSON(http.StatusOK, gin.H{ "message": "invitation accepted", "member": member, }) } // GetMyPendingInvitations returns pending invitations for the current user func (h *Handler) GetMyPendingInvitations(c *gin.Context) { userID, err := uuid.Parse(middleware.GetUserID(c)) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } // Get user email user, xerr := h.UserService.GetUser(c.Request.Context(), userID) if xerr != nil { errx.JSON(c, xerr) return } invitations, xerr := h.OrganizationService.GetUserPendingInvitations(c.Request.Context(), user.Email) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"data": invitations}) } // GetOrganizationLimits returns the organization's limits and current usage func (h *Handler) GetOrganizationLimits(c *gin.Context) { orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return } limits, xerr := h.OrganizationService.GetOrganizationLimits(c.Request.Context(), *orgID) if xerr != nil { errx.JSON(c, xerr) return } counts, xerr := h.OrganizationService.GetOrganizationCounts(c.Request.Context(), *orgID) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{ "limits": limits, "counts": counts, }) }