Files
warmbly/internal/app/organization/service.go
T

690 lines
23 KiB
Go

package organization
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
const (
// InvitationTTL is the default invitation expiration time
InvitationTTL = 7 * 24 * time.Hour // 7 days
// InvitationTokenLength is the length of invitation tokens
InvitationTokenLength = 32
)
// OrganizationService defines the interface for organization management
type OrganizationService interface {
// CRUD
Create(ctx context.Context, userID uuid.UUID, name string) (*models.Organization, *errx.Error)
Get(ctx context.Context, orgID uuid.UUID) (*models.Organization, *errx.Error)
GetBySlug(ctx context.Context, slug string) (*models.Organization, *errx.Error)
Update(ctx context.Context, orgID uuid.UUID, req *models.UpdateOrganizationRequest) (*models.Organization, *errx.Error)
Delete(ctx context.Context, orgID uuid.UUID) *errx.Error
// User's organizations
GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]models.OrganizationMember, *errx.Error)
GetUserDefaultOrganization(ctx context.Context, userID uuid.UUID) (*models.Organization, *errx.Error)
// Member management
GetMembers(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationMember, *errx.Error)
GetMembership(ctx context.Context, orgID, userID uuid.UUID) (*models.OrganizationMember, *errx.Error)
InviteMember(ctx context.Context, orgID uuid.UUID, inviterID uuid.UUID, req *models.InviteMemberRequest) (*models.OrganizationInvitation, *errx.Error)
AcceptInvitation(ctx context.Context, token string, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error)
UpdateMemberRole(ctx context.Context, orgID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error)
RemoveMember(ctx context.Context, orgID, memberUserID uuid.UUID) *errx.Error
// Invitations
GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, *errx.Error)
GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, *errx.Error)
CancelInvitation(ctx context.Context, invitationID uuid.UUID) *errx.Error
// Ownership transfer
TransferOwnership(ctx context.Context, orgID, newOwnerUserID uuid.UUID) *errx.Error
// Permission checks
HasPermission(ctx context.Context, orgID, userID uuid.UUID, perm models.OrganizationPermission) (bool, *errx.Error)
RequirePermission(ctx context.Context, orgID, userID uuid.UUID, perm models.OrganizationPermission) *errx.Error
// Limit checks
CanAddMember(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
CanAddCampaign(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
CanAddEmailAccount(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error)
GetCampaignCounts(ctx context.Context, orgID uuid.UUID) (total int, active int, err *errx.Error)
GetOrganizationLimits(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimits, *errx.Error)
GetOrganizationCounts(ctx context.Context, orgID uuid.UUID) (*models.OrganizationCounts, *errx.Error)
// Enterprise inquiries
CreateEnterpriseInquiry(ctx context.Context, inquiry *models.EnterpriseInquiry) (*models.EnterpriseInquiry, *errx.Error)
// Admin permissions (for admin middleware)
GetUserAdminPermissions(ctx context.Context, userID uuid.UUID) (uint32, error)
}
type organizationService struct {
orgRepo repository.OrganizationRepository
subRepo repository.SubscriptionRepository
userRepo repository.UserRepository
}
// NewService creates a new organization service
func NewService(
orgRepo repository.OrganizationRepository,
subRepo repository.SubscriptionRepository,
userRepo repository.UserRepository,
) OrganizationService {
return &organizationService{
orgRepo: orgRepo,
subRepo: subRepo,
userRepo: userRepo,
}
}
// Create creates a new organization and adds the user as owner
func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name string) (*models.Organization, *errx.Error) {
// Check organization limit
user, userErr := s.userRepo.GetUser(ctx, userID)
if userErr != nil {
sentry.CaptureException(userErr)
return nil, errx.New(errx.Internal, "failed to get user")
}
if user == nil {
return nil, errx.New(errx.NotFound, "user not found")
}
ownedCount, countErr := s.orgRepo.GetUserOwnedOrganizationCount(ctx, userID)
if countErr != nil {
sentry.CaptureException(countErr)
return nil, errx.New(errx.Internal, "failed to get organization count")
}
if ownedCount >= user.MaxOrganizations {
return nil, errx.New(errx.Forbidden, "maximum organization limit reached")
}
org := &models.Organization{
ID: uuid.New(),
Name: name,
OwnerUserID: userID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Generate slug from name
slug := generateSlug(name)
org.Slug = &slug
if err := s.orgRepo.Create(ctx, org); err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to create organization")
}
// Add creator as owner member
now := time.Now()
member := &models.OrganizationMember{
ID: uuid.New(),
OrganizationID: org.ID,
UserID: userID,
Role: string(models.RoleOwner),
Permissions: models.RolePermissions[models.RoleOwner],
InvitedAt: now,
AcceptedAt: &now,
}
if err := s.orgRepo.AddMember(ctx, member); err != nil {
sentry.CaptureException(err)
// Rollback org creation
_ = s.orgRepo.Delete(ctx, org.ID)
return nil, errx.New(errx.Internal, "failed to add owner member")
}
return org, nil
}
// Get retrieves an organization by ID
func (s *organizationService) Get(ctx context.Context, orgID uuid.UUID) (*models.Organization, *errx.Error) {
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get organization")
}
if org == nil {
return nil, errx.ErrNotFound
}
return org, nil
}
// GetBySlug retrieves an organization by slug
func (s *organizationService) GetBySlug(ctx context.Context, slug string) (*models.Organization, *errx.Error) {
org, err := s.orgRepo.GetBySlug(ctx, slug)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get organization")
}
if org == nil {
return nil, errx.ErrNotFound
}
return org, nil
}
// Update updates an organization
func (s *organizationService) Update(ctx context.Context, orgID uuid.UUID, req *models.UpdateOrganizationRequest) (*models.Organization, *errx.Error) {
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get organization")
}
if org == nil {
return nil, errx.ErrNotFound
}
if req.Name != nil {
org.Name = *req.Name
}
if req.Slug != nil {
// Validate slug uniqueness
existing, _ := s.orgRepo.GetBySlug(ctx, *req.Slug)
if existing != nil && existing.ID != orgID {
return nil, errx.New(errx.Conflict, "slug already in use")
}
org.Slug = req.Slug
}
org.UpdatedAt = time.Now()
if err := s.orgRepo.Update(ctx, org); err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to update organization")
}
return org, nil
}
// Delete deletes an organization
func (s *organizationService) Delete(ctx context.Context, orgID uuid.UUID) *errx.Error {
if err := s.orgRepo.Delete(ctx, orgID); err != nil {
sentry.CaptureException(err)
return errx.New(errx.Internal, "failed to delete organization")
}
return nil
}
// GetUserOrganizations retrieves all organizations a user is a member of
func (s *organizationService) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]models.OrganizationMember, *errx.Error) {
members, err := s.orgRepo.GetUserOrganizations(ctx, userID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get user organizations")
}
return members, nil
}
// GetUserDefaultOrganization retrieves the user's default (first owned) organization
func (s *organizationService) GetUserDefaultOrganization(ctx context.Context, userID uuid.UUID) (*models.Organization, *errx.Error) {
org, err := s.orgRepo.GetUserDefaultOrganization(ctx, userID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get default organization")
}
return org, nil
}
// GetMembers retrieves all members of an organization
func (s *organizationService) GetMembers(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationMember, *errx.Error) {
members, err := s.orgRepo.GetMembers(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get members")
}
return members, nil
}
// GetMembership retrieves a user's membership in an organization
func (s *organizationService) GetMembership(ctx context.Context, orgID, userID uuid.UUID) (*models.OrganizationMember, *errx.Error) {
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get membership")
}
return member, nil
}
// InviteMember invites a new member to the organization
func (s *organizationService) InviteMember(ctx context.Context, orgID uuid.UUID, inviterID uuid.UUID, req *models.InviteMemberRequest) (*models.OrganizationInvitation, *errx.Error) {
// Check member limit
canAdd, err := s.CanAddMember(ctx, orgID)
if err != nil {
return nil, err
}
if !canAdd {
return nil, errx.New(errx.Forbidden, "team member limit reached")
}
// Check if user is already a member
// First, we need to check if there's already a user with this email
// For now, we'll just create the invitation
// Determine role and permissions
role := string(models.RoleViewer)
if req.Role != "" && models.IsValidRole(req.Role) {
role = req.Role
}
var permissions models.OrganizationPermission
if req.Permissions != nil {
permissions = models.OrganizationPermission(*req.Permissions)
} else {
permissions = models.GetRolePermissions(models.Role(role))
}
// Generate invitation token
token, xerr := generateInvitationToken()
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.New(errx.Internal, "failed to generate invitation token")
}
inv := &models.OrganizationInvitation{
ID: uuid.New(),
OrganizationID: orgID,
Email: strings.ToLower(req.Email),
Role: role,
Permissions: permissions,
InvitedBy: inviterID,
Token: token,
ExpiresAt: time.Now().Add(InvitationTTL),
CreatedAt: time.Now(),
}
if err := s.orgRepo.CreateInvitation(ctx, inv); err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to create invitation")
}
return inv, nil
}
// AcceptInvitation accepts an invitation and adds the user as a member
func (s *organizationService) AcceptInvitation(ctx context.Context, token string, userID uuid.UUID, email string) (*models.OrganizationMember, *errx.Error) {
inv, err := s.orgRepo.GetInvitationByToken(ctx, token)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get invitation")
}
if inv == nil {
return nil, errx.New(errx.NotFound, "invitation not found")
}
// Verify email matches
if strings.ToLower(email) != strings.ToLower(inv.Email) {
return nil, errx.New(errx.Forbidden, "email does not match invitation")
}
// Check if invitation is expired
if inv.IsExpired() {
// Delete expired invitation
_ = s.orgRepo.DeleteInvitation(ctx, inv.ID)
return nil, errx.New(errx.BadRequest, "invitation has expired")
}
// Check if already a member
existing, _ := s.orgRepo.GetMember(ctx, inv.OrganizationID, userID)
if existing != nil {
_ = s.orgRepo.DeleteInvitation(ctx, inv.ID)
return existing, nil
}
// Add member
now := time.Now()
member := &models.OrganizationMember{
ID: uuid.New(),
OrganizationID: inv.OrganizationID,
UserID: userID,
Role: inv.Role,
Permissions: inv.Permissions,
InvitedBy: &inv.InvitedBy,
InvitedAt: inv.CreatedAt,
AcceptedAt: &now,
}
if err := s.orgRepo.AddMember(ctx, member); err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to add member")
}
// Delete the invitation
_ = s.orgRepo.DeleteInvitation(ctx, inv.ID)
return member, nil
}
// UpdateMemberRole updates a member's role and permissions
func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID, memberUserID uuid.UUID, req *models.UpdateMemberRequest) (*models.OrganizationMember, *errx.Error) {
member, err := s.orgRepo.GetMember(ctx, orgID, memberUserID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get member")
}
if member == nil {
return nil, errx.New(errx.NotFound, "member not found")
}
// Cannot change owner's role
if member.Role == string(models.RoleOwner) {
return nil, errx.New(errx.Forbidden, "cannot modify owner role")
}
if req.Role != nil {
if !models.IsValidRole(*req.Role) {
return nil, errx.New(errx.BadRequest, "invalid role")
}
// Cannot promote to owner
if *req.Role == string(models.RoleOwner) {
return nil, errx.New(errx.Forbidden, "cannot promote to owner, use transfer ownership")
}
member.Role = *req.Role
// Update permissions to match new role unless custom permissions provided
if req.Permissions == nil {
member.Permissions = models.GetRolePermissions(models.Role(*req.Role))
}
}
if req.Permissions != nil {
member.Permissions = models.OrganizationPermission(*req.Permissions)
}
if err := s.orgRepo.UpdateMember(ctx, member); err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to update member")
}
return member, nil
}
// RemoveMember removes a member from the organization
func (s *organizationService) RemoveMember(ctx context.Context, orgID, memberUserID uuid.UUID) *errx.Error {
member, err := s.orgRepo.GetMember(ctx, orgID, memberUserID)
if err != nil {
sentry.CaptureException(err)
return errx.New(errx.Internal, "failed to get member")
}
if member == nil {
return errx.New(errx.NotFound, "member not found")
}
// Cannot remove owner
if member.Role == string(models.RoleOwner) {
return errx.New(errx.Forbidden, "cannot remove organization owner")
}
if err := s.orgRepo.RemoveMember(ctx, orgID, memberUserID); err != nil {
sentry.CaptureException(err)
return errx.New(errx.Internal, "failed to remove member")
}
return nil
}
// GetPendingInvitations retrieves all pending invitations for an organization
func (s *organizationService) GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, *errx.Error) {
invitations, err := s.orgRepo.GetPendingInvitations(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get invitations")
}
return invitations, nil
}
// GetUserPendingInvitations retrieves all pending invitations for a user
func (s *organizationService) GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, *errx.Error) {
invitations, err := s.orgRepo.GetUserPendingInvitations(ctx, email)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get invitations")
}
return invitations, nil
}
// CancelInvitation cancels a pending invitation
func (s *organizationService) CancelInvitation(ctx context.Context, invitationID uuid.UUID) *errx.Error {
if err := s.orgRepo.DeleteInvitation(ctx, invitationID); err != nil {
sentry.CaptureException(err)
return errx.New(errx.Internal, "failed to cancel invitation")
}
return nil
}
// TransferOwnership transfers organization ownership
func (s *organizationService) TransferOwnership(ctx context.Context, orgID, newOwnerUserID uuid.UUID) *errx.Error {
// Verify new owner is a member
member, err := s.orgRepo.GetMember(ctx, orgID, newOwnerUserID)
if err != nil {
sentry.CaptureException(err)
return errx.New(errx.Internal, "failed to verify membership")
}
if member == nil {
return errx.New(errx.BadRequest, "new owner must be a member of the organization")
}
if err := s.orgRepo.TransferOwnership(ctx, orgID, newOwnerUserID); err != nil {
sentry.CaptureException(err)
return errx.New(errx.Internal, "failed to transfer ownership")
}
return nil
}
// HasPermission checks if a user has a specific permission
func (s *organizationService) HasPermission(ctx context.Context, orgID, userID uuid.UUID, perm models.OrganizationPermission) (bool, *errx.Error) {
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
sentry.CaptureException(err)
return false, errx.New(errx.Internal, "failed to check permission")
}
if member == nil {
return false, nil
}
return member.HasPermission(perm), nil
}
// RequirePermission checks permission and returns error if not granted
func (s *organizationService) RequirePermission(ctx context.Context, orgID, userID uuid.UUID, perm models.OrganizationPermission) *errx.Error {
has, err := s.HasPermission(ctx, orgID, userID, perm)
if err != nil {
return err
}
if !has {
return errx.ErrForbidden
}
return nil
}
// CanAddMember checks if the organization can add more members based on plan limits
func (s *organizationService) CanAddMember(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
limits, err := s.GetOrganizationLimits(ctx, orgID)
if err != nil {
return false, err
}
// No limit set = unlimited
if limits == nil || limits.MaxTeamMembers == nil {
return true, nil
}
count, xerr := s.orgRepo.GetMemberCount(ctx, orgID)
if xerr != nil {
sentry.CaptureException(xerr)
return false, errx.New(errx.Internal, "failed to get member count")
}
return count < *limits.MaxTeamMembers, nil
}
// CanAddCampaign checks if the organization can add more campaigns based on plan limits
func (s *organizationService) CanAddCampaign(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
limits, err := s.GetOrganizationLimits(ctx, orgID)
if err != nil {
return false, err
}
total, active, xerr := s.GetCampaignCounts(ctx, orgID)
if xerr != nil {
return false, xerr
}
// Check total campaign limit
if limits != nil && limits.MaxCampaigns != nil && total >= *limits.MaxCampaigns {
return false, nil
}
// Check active campaign limit
if limits != nil && limits.MaxActiveCampaigns != nil && active >= *limits.MaxActiveCampaigns {
return false, nil
}
return true, nil
}
// CanAddEmailAccount checks if the organization can add more email accounts based on plan limits
func (s *organizationService) CanAddEmailAccount(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) {
limits, err := s.GetOrganizationLimits(ctx, orgID)
if err != nil {
return false, err
}
// No limit set = unlimited
if limits == nil || limits.MaxEmailAccounts == nil {
return true, nil
}
count, xerr := s.orgRepo.GetEmailAccountCount(ctx, orgID)
if xerr != nil {
sentry.CaptureException(xerr)
return false, errx.New(errx.Internal, "failed to get email account count")
}
return count < *limits.MaxEmailAccounts, nil
}
// GetCampaignCounts returns total and active campaign counts
func (s *organizationService) GetCampaignCounts(ctx context.Context, orgID uuid.UUID) (total int, active int, err *errx.Error) {
t, a, xerr := s.orgRepo.GetCampaignCounts(ctx, orgID)
if xerr != nil {
sentry.CaptureException(xerr)
return 0, 0, errx.New(errx.Internal, "failed to get campaign counts")
}
return t, a, nil
}
// GetOrganizationLimits retrieves the organization's plan limits
func (s *organizationService) GetOrganizationLimits(ctx context.Context, orgID uuid.UUID) (*models.OrganizationLimits, *errx.Error) {
sub, err := s.subRepo.GetByOrganizationID(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get subscription")
}
if sub == nil || sub.Plan == nil {
return nil, nil
}
return &models.OrganizationLimits{
MaxCampaigns: sub.Plan.MaxCampaigns,
MaxActiveCampaigns: sub.Plan.MaxActiveCampaigns,
MaxTeamMembers: sub.Plan.MaxTeamMembers,
MaxEmailAccounts: sub.Plan.MaxEmailAccounts,
DailyCampaignLimit: sub.Plan.DailyCampaignLimit,
}, nil
}
// GetOrganizationCounts retrieves the organization's resource counts
func (s *organizationService) GetOrganizationCounts(ctx context.Context, orgID uuid.UUID) (*models.OrganizationCounts, *errx.Error) {
total, active, err := s.orgRepo.GetCampaignCounts(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get campaign counts")
}
members, err := s.orgRepo.GetMemberCount(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get member count")
}
emails, err := s.orgRepo.GetEmailAccountCount(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get email account count")
}
contacts, err := s.orgRepo.GetContactCount(ctx, orgID)
if err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get contact count")
}
sentToday, _ := s.orgRepo.GetEmailsSentTodayCount(ctx, orgID)
return &models.OrganizationCounts{
TotalCampaigns: total,
ActiveCampaigns: active,
TotalMembers: members,
EmailAccounts: emails,
TotalContacts: contacts,
EmailsSentToday: sentToday,
}, nil
}
// CreateEnterpriseInquiry creates an enterprise inquiry
func (s *organizationService) CreateEnterpriseInquiry(ctx context.Context, inquiry *models.EnterpriseInquiry) (*models.EnterpriseInquiry, *errx.Error) {
inquiry.ID = uuid.New()
inquiry.Status = string(models.EnterpriseInquiryStatusPending)
inquiry.CreatedAt = time.Now()
if err := s.orgRepo.CreateEnterpriseInquiry(ctx, inquiry); err != nil {
sentry.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to create enterprise inquiry")
}
return inquiry, nil
}
// Helper functions
func generateSlug(name string) string {
// Simple slug generation - lowercase, replace spaces with dashes
slug := strings.ToLower(name)
slug = strings.ReplaceAll(slug, " ", "-")
// Remove non-alphanumeric characters except dashes
var result strings.Builder
for _, r := range slug {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
result.WriteRune(r)
}
}
// Add random suffix to ensure uniqueness
suffix := make([]byte, 4)
rand.Read(suffix)
return result.String() + "-" + hex.EncodeToString(suffix)
}
func generateInvitationToken() (string, error) {
bytes := make([]byte, InvitationTokenLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// GetUserAdminPermissions retrieves the admin permissions for a user
func (s *organizationService) GetUserAdminPermissions(ctx context.Context, userID uuid.UUID) (uint32, error) {
return s.orgRepo.GetUserAdminPermissions(ctx, userID)
}