Files
Enterprise-Knowledge-Base/internal/application/service/organization.go
T
wizardchen 5ab20a9148 feat(i18n): update localization for knowledge bases and organization features
- Added new localization strings for 'Mine' in English and Chinese.
- Enhanced organization subtitles to include agents in shared spaces.
- Updated permission descriptions to clarify roles related to knowledge bases and agents.
- Improved UI components to reflect new labels and permissions for better user experience.

This update strengthens the localization support, ensuring clarity in knowledge base and organization functionalities across multiple languages.
2026-02-09 11:40:25 +08:00

776 lines
25 KiB
Go

package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
)
// Default invite code validity in days; allowed values: 0 (never), 1, 7, 30
const DefaultInviteCodeValidityDays = 7
// DefaultMemberLimit is the default max members per organization (0 = unlimited)
const DefaultMemberLimit = 200
// ValidInviteCodeValidityDays are the allowed values for invite_code_validity_days
var ValidInviteCodeValidityDays = map[int]bool{0: true, 1: true, 7: true, 30: true}
var (
ErrOrgNotFound = errors.New("organization not found")
ErrOrgPermissionDenied = errors.New("permission denied for this organization")
ErrCannotRemoveOwner = errors.New("cannot remove organization owner")
ErrCannotChangeOwnerRole = errors.New("cannot change organization owner role")
ErrUserNotInOrg = errors.New("user is not a member of this organization")
ErrInvalidRole = errors.New("invalid role")
ErrInviteCodeExpired = errors.New("invite code has expired")
ErrInvalidValidityDays = errors.New("invite_code_validity_days must be 0, 1, 7, or 30")
ErrOrgMemberLimitReached = errors.New("organization member limit reached")
ErrOrgMemberLimitTooLow = errors.New("member limit cannot be lower than current member count")
)
// organizationService implements OrganizationService interface
type organizationService struct {
orgRepo interfaces.OrganizationRepository
userRepo interfaces.UserRepository
shareRepo interfaces.KBShareRepository
agentShareRepo interfaces.AgentShareRepository
}
// NewOrganizationService creates a new organization service
func NewOrganizationService(
orgRepo interfaces.OrganizationRepository,
userRepo interfaces.UserRepository,
shareRepo interfaces.KBShareRepository,
agentShareRepo interfaces.AgentShareRepository,
) interfaces.OrganizationService {
return &organizationService{
orgRepo: orgRepo,
userRepo: userRepo,
shareRepo: shareRepo,
agentShareRepo: agentShareRepo,
}
}
// resolveInviteExpiry returns expiresAt for the given validity days (0 = never, nil expiresAt).
func resolveInviteExpiry(validityDays int, now time.Time) *time.Time {
if validityDays == 0 {
return nil
}
t := now.AddDate(0, 0, validityDays)
return &t
}
// CreateOrganization creates a new organization
func (s *organizationService) CreateOrganization(ctx context.Context, userID string, tenantID uint64, req *types.CreateOrganizationRequest) (*types.Organization, error) {
logger.Infof(ctx, "Creating organization: %s by user: %s", req.Name, userID)
validityDays := DefaultInviteCodeValidityDays
if req.InviteCodeValidityDays != nil {
if !ValidInviteCodeValidityDays[*req.InviteCodeValidityDays] {
return nil, ErrInvalidValidityDays
}
validityDays = *req.InviteCodeValidityDays
}
memberLimit := DefaultMemberLimit
if req.MemberLimit != nil {
if *req.MemberLimit < 0 {
return nil, errors.New("member_limit must be >= 0")
}
memberLimit = *req.MemberLimit
}
now := time.Now()
org := &types.Organization{
ID: uuid.New().String(),
Name: req.Name,
Description: req.Description,
Avatar: strings.TrimSpace(req.Avatar),
OwnerID: userID,
InviteCode: generateInviteCode(),
InviteCodeExpiresAt: resolveInviteExpiry(validityDays, now),
InviteCodeValidityDays: validityDays,
MemberLimit: memberLimit,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.orgRepo.Create(ctx, org); err != nil {
logger.Errorf(ctx, "Failed to create organization: %v", err)
return nil, err
}
// Add the creator as admin member
member := &types.OrganizationMember{
ID: uuid.New().String(),
OrganizationID: org.ID,
UserID: userID,
TenantID: tenantID,
Role: types.OrgRoleAdmin,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.orgRepo.AddMember(ctx, member); err != nil {
logger.Errorf(ctx, "Failed to add creator as member: %v", err)
// Rollback organization creation
_ = s.orgRepo.Delete(ctx, org.ID)
return nil, err
}
logger.Infof(ctx, "Organization created successfully: %s", org.ID)
return org, nil
}
// GetOrganization gets an organization by ID
func (s *organizationService) GetOrganization(ctx context.Context, id string) (*types.Organization, error) {
org, err := s.orgRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrOrganizationNotFound) {
return nil, ErrOrgNotFound
}
return nil, err
}
return org, nil
}
// GetOrganizationByInviteCode gets an organization by invite code
func (s *organizationService) GetOrganizationByInviteCode(ctx context.Context, inviteCode string) (*types.Organization, error) {
org, err := s.orgRepo.GetByInviteCode(ctx, inviteCode)
if err != nil {
if errors.Is(err, repository.ErrInviteCodeNotFound) {
return nil, ErrOrgNotFound
}
if errors.Is(err, repository.ErrInviteCodeExpired) {
return nil, ErrInviteCodeExpired
}
return nil, err
}
return org, nil
}
// ListUserOrganizations lists all organizations that a user belongs to
func (s *organizationService) ListUserOrganizations(ctx context.Context, userID string) ([]*types.Organization, error) {
return s.orgRepo.ListByUserID(ctx, userID)
}
// UpdateOrganization updates an organization
func (s *organizationService) UpdateOrganization(ctx context.Context, id string, userID string, req *types.UpdateOrganizationRequest) (*types.Organization, error) {
// Check if user is admin
isAdmin, err := s.IsOrgAdmin(ctx, id, userID)
if err != nil {
return nil, err
}
if !isAdmin {
return nil, ErrOrgPermissionDenied
}
org, err := s.orgRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
org.Name = *req.Name
}
if req.Description != nil {
org.Description = *req.Description
}
if req.Avatar != nil {
org.Avatar = strings.TrimSpace(*req.Avatar)
}
if req.RequireApproval != nil {
org.RequireApproval = *req.RequireApproval
}
if req.Searchable != nil {
org.Searchable = *req.Searchable
}
if req.InviteCodeValidityDays != nil {
if !ValidInviteCodeValidityDays[*req.InviteCodeValidityDays] {
return nil, ErrInvalidValidityDays
}
org.InviteCodeValidityDays = *req.InviteCodeValidityDays
}
if req.MemberLimit != nil {
if *req.MemberLimit < 0 {
return nil, errors.New("member_limit must be >= 0")
}
if *req.MemberLimit > 0 {
count, err := s.orgRepo.CountMembers(ctx, id)
if err != nil {
return nil, err
}
if int64(*req.MemberLimit) < count {
return nil, ErrOrgMemberLimitTooLow
}
}
org.MemberLimit = *req.MemberLimit
}
org.UpdatedAt = time.Now()
if err := s.orgRepo.Update(ctx, org); err != nil {
return nil, err
}
return org, nil
}
// SearchSearchableOrganizations returns searchable (discoverable) organizations for the current user
func (s *organizationService) SearchSearchableOrganizations(ctx context.Context, userID string, query string, limit int) (*types.ListSearchableOrganizationsResponse, error) {
if limit <= 0 {
limit = 20
}
orgs, err := s.orgRepo.ListSearchable(ctx, query, limit)
if err != nil {
return nil, err
}
memberCounts := make(map[string]int64)
shareCounts := make(map[string]int64)
agentShareCounts := make(map[string]int)
memberOrgIDs := make(map[string]bool)
for _, org := range orgs {
if mc, err := s.orgRepo.CountMembers(ctx, org.ID); err == nil {
memberCounts[org.ID] = mc
}
shares, _ := s.shareRepo.ListByOrganization(ctx, org.ID)
shareCounts[org.ID] = int64(len(shares))
if agentShares, err := s.agentShareRepo.ListByOrganization(ctx, org.ID); err == nil {
agentShareCounts[org.ID] = len(agentShares)
}
_, err := s.orgRepo.GetMember(ctx, org.ID, userID)
memberOrgIDs[org.ID] = (err == nil)
}
items := make([]types.SearchableOrganizationItem, 0, len(orgs))
for _, org := range orgs {
items = append(items, types.SearchableOrganizationItem{
ID: org.ID,
Name: org.Name,
Description: org.Description,
Avatar: org.Avatar,
MemberCount: int(memberCounts[org.ID]),
MemberLimit: org.MemberLimit,
ShareCount: int(shareCounts[org.ID]),
AgentShareCount: agentShareCounts[org.ID],
IsAlreadyMember: memberOrgIDs[org.ID],
RequireApproval: org.RequireApproval,
})
}
return &types.ListSearchableOrganizationsResponse{
Organizations: items,
Total: int64(len(items)),
}, nil
}
// JoinByOrganizationID joins a searchable organization by ID (no invite code required)
func (s *organizationService) JoinByOrganizationID(ctx context.Context, orgID string, userID string, tenantID uint64, message string, requestedRole types.OrgMemberRole) (*types.Organization, error) {
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
if errors.Is(err, repository.ErrOrganizationNotFound) {
return nil, ErrOrgNotFound
}
return nil, err
}
if !org.Searchable {
return nil, ErrOrgPermissionDenied // or a dedicated "org not discoverable" error
}
_, err = s.orgRepo.GetMember(ctx, orgID, userID)
if err == nil {
return org, nil // already member
}
// Validate requested role if provided
if requestedRole != "" && !requestedRole.IsValid() {
return nil, ErrInvalidRole
}
// Default to viewer if not specified
if requestedRole == "" {
requestedRole = types.OrgRoleViewer
}
if org.RequireApproval {
_, err = s.SubmitJoinRequest(ctx, orgID, userID, tenantID, message, requestedRole)
if err != nil {
return nil, err
}
return org, nil
}
// Direct join using invite code flow logic (add member)
_, err = s.JoinByInviteCode(ctx, org.InviteCode, userID, tenantID)
if err != nil {
return nil, err
}
return org, nil
}
// DeleteOrganization deletes an organization
func (s *organizationService) DeleteOrganization(ctx context.Context, id string, userID string) error {
org, err := s.orgRepo.GetByID(ctx, id)
if err != nil {
return err
}
// Only owner can delete organization
if org.OwnerID != userID {
return ErrOrgPermissionDenied
}
// Remove all KB shares for this org so members no longer see associated knowledge bases
if err := s.shareRepo.DeleteByOrganizationID(ctx, id); err != nil {
logger.Warnf(ctx, "Failed to delete KB shares for organization %s: %v", id, err)
}
if err := s.agentShareRepo.DeleteByOrganizationID(ctx, id); err != nil {
logger.Warnf(ctx, "Failed to delete agent shares for organization %s: %v", id, err)
}
return s.orgRepo.Delete(ctx, id)
}
// AddMember adds a member to an organization
func (s *organizationService) AddMember(ctx context.Context, orgID string, userID string, tenantID uint64, role types.OrgMemberRole) error {
if !role.IsValid() {
return ErrInvalidRole
}
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
return err
}
if org.MemberLimit > 0 {
count, errCount := s.orgRepo.CountMembers(ctx, orgID)
if errCount != nil {
return errCount
}
if count >= int64(org.MemberLimit) {
return ErrOrgMemberLimitReached
}
}
member := &types.OrganizationMember{
ID: uuid.New().String(),
OrganizationID: orgID,
UserID: userID,
TenantID: tenantID,
Role: role,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return s.orgRepo.AddMember(ctx, member)
}
// RemoveMember removes a member from an organization.
// When operatorUserID == memberUserID, it is "leave" (self-removal) and does not require admin.
// When removing another member, operator must be admin.
func (s *organizationService) RemoveMember(ctx context.Context, orgID string, memberUserID string, operatorUserID string) error {
// Check if trying to remove owner
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
return err
}
if org.OwnerID == memberUserID {
return ErrCannotRemoveOwner
}
// Self-removal (leave): allow any member to leave
if operatorUserID == memberUserID {
return s.orgRepo.RemoveMember(ctx, orgID, memberUserID)
}
// Removing another member: require operator to be admin
isAdmin, err := s.IsOrgAdmin(ctx, orgID, operatorUserID)
if err != nil {
return err
}
if !isAdmin {
return ErrOrgPermissionDenied
}
return s.orgRepo.RemoveMember(ctx, orgID, memberUserID)
}
// UpdateMemberRole updates a member's role
func (s *organizationService) UpdateMemberRole(ctx context.Context, orgID string, memberUserID string, role types.OrgMemberRole, operatorUserID string) error {
if !role.IsValid() {
return ErrInvalidRole
}
// Check if operator is admin
isAdmin, err := s.IsOrgAdmin(ctx, orgID, operatorUserID)
if err != nil {
return err
}
if !isAdmin {
return ErrOrgPermissionDenied
}
// Check if trying to change owner's role
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
return err
}
if org.OwnerID == memberUserID {
return ErrCannotChangeOwnerRole
}
return s.orgRepo.UpdateMemberRole(ctx, orgID, memberUserID, role)
}
// ListMembers lists all members of an organization
func (s *organizationService) ListMembers(ctx context.Context, orgID string) ([]*types.OrganizationMember, error) {
return s.orgRepo.ListMembers(ctx, orgID)
}
// GetMember gets a specific member of an organization
func (s *organizationService) GetMember(ctx context.Context, orgID string, userID string) (*types.OrganizationMember, error) {
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
if errors.Is(err, repository.ErrOrgMemberNotFound) {
return nil, ErrUserNotInOrg
}
return nil, err
}
return member, nil
}
// GenerateInviteCode generates a new invite code for an organization
func (s *organizationService) GenerateInviteCode(ctx context.Context, orgID string, userID string) (string, error) {
// Check if user is admin
isAdmin, err := s.IsOrgAdmin(ctx, orgID, userID)
if err != nil {
return "", err
}
if !isAdmin {
return "", ErrOrgPermissionDenied
}
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
return "", err
}
validityDays := org.InviteCodeValidityDays
if validityDays != 0 && !ValidInviteCodeValidityDays[validityDays] {
validityDays = DefaultInviteCodeValidityDays
}
// 0 = never expire (expiresAt nil); 1/7/30 = that many days
inviteCode := generateInviteCode()
now := time.Now()
expiresAt := resolveInviteExpiry(validityDays, now)
if err := s.orgRepo.UpdateInviteCode(ctx, orgID, inviteCode, expiresAt); err != nil {
return "", err
}
return inviteCode, nil
}
// JoinByInviteCode allows a user to join an organization via invite code
func (s *organizationService) JoinByInviteCode(ctx context.Context, inviteCode string, userID string, tenantID uint64) (*types.Organization, error) {
org, err := s.orgRepo.GetByInviteCode(ctx, inviteCode)
if err != nil {
if errors.Is(err, repository.ErrInviteCodeNotFound) {
return nil, ErrOrgNotFound
}
if errors.Is(err, repository.ErrInviteCodeExpired) {
return nil, ErrInviteCodeExpired
}
return nil, err
}
// check if the organization need approval
if org.RequireApproval {
logger.Infof(ctx, "Organization %s requires approval", org.ID)
return nil, ErrOrgPermissionDenied
}
// Check if user is already a member
_, err = s.orgRepo.GetMember(ctx, org.ID, userID)
if err == nil {
// User is already a member, just return the organization
return org, nil
}
if !errors.Is(err, repository.ErrOrgMemberNotFound) {
return nil, err
}
// Check member limit (0 = unlimited)
if org.MemberLimit > 0 {
count, errCount := s.orgRepo.CountMembers(ctx, org.ID)
if errCount != nil {
return nil, errCount
}
if count >= int64(org.MemberLimit) {
return nil, ErrOrgMemberLimitReached
}
}
// Add user as viewer by default
member := &types.OrganizationMember{
ID: uuid.New().String(),
OrganizationID: org.ID,
UserID: userID,
TenantID: tenantID,
Role: types.OrgRoleViewer,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.orgRepo.AddMember(ctx, member); err != nil {
return nil, err
}
logger.Infof(ctx, "User %s joined organization %s via invite code", userID, org.ID)
return org, nil
}
// IsOrgAdmin checks if a user is an admin of an organization
func (s *organizationService) IsOrgAdmin(ctx context.Context, orgID string, userID string) (bool, error) {
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
if errors.Is(err, repository.ErrOrgMemberNotFound) {
return false, nil
}
return false, err
}
return member.Role == types.OrgRoleAdmin, nil
}
// GetUserRoleInOrg gets a user's role in an organization
func (s *organizationService) GetUserRoleInOrg(ctx context.Context, orgID string, userID string) (types.OrgMemberRole, error) {
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
if errors.Is(err, repository.ErrOrgMemberNotFound) {
return "", ErrUserNotInOrg
}
return "", err
}
return member.Role, nil
}
// generateInviteCode generates a random 16-character invite code
func generateInviteCode() string {
bytes := make([]byte, 8)
_, _ = rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// ----------------
// Join Requests
// ----------------
var (
ErrPendingRequestExists = errors.New("pending request already exists")
ErrJoinRequestNotFound = errors.New("join request not found")
ErrCannotUpgradeToSameRole = errors.New("cannot request upgrade to same or lower role")
ErrAlreadyAdmin = errors.New("user is already an admin")
)
// SubmitJoinRequest submits a request to join an organization
func (s *organizationService) SubmitJoinRequest(ctx context.Context, orgID string, userID string, tenantID uint64, message string, requestedRole types.OrgMemberRole) (*types.OrganizationJoinRequest, error) {
logger.Infof(ctx, "User %s submitting join request for organization %s", userID, orgID)
// Check if there's already a pending join request
existing, err := s.orgRepo.GetPendingRequestByType(ctx, orgID, userID, types.JoinRequestTypeJoin)
if err == nil && existing != nil {
return nil, ErrPendingRequestExists
}
// Reject if organization is already at member limit
org, err := s.orgRepo.GetByID(ctx, orgID)
if err != nil {
if errors.Is(err, repository.ErrOrganizationNotFound) {
return nil, ErrOrgNotFound
}
return nil, err
}
if org.MemberLimit > 0 {
count, errCount := s.orgRepo.CountMembers(ctx, orgID)
if errCount != nil {
return nil, errCount
}
if count >= int64(org.MemberLimit) {
return nil, ErrOrgMemberLimitReached
}
}
// Default to viewer if role is empty or invalid
if requestedRole == "" || !requestedRole.IsValid() {
requestedRole = types.OrgRoleViewer
}
request := &types.OrganizationJoinRequest{
ID: uuid.New().String(),
OrganizationID: orgID,
UserID: userID,
TenantID: tenantID,
RequestType: types.JoinRequestTypeJoin,
RequestedRole: requestedRole,
Status: types.JoinRequestStatusPending,
Message: message,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.orgRepo.CreateJoinRequest(ctx, request); err != nil {
return nil, err
}
logger.Infof(ctx, "Join request %s created for organization %s by user %s", request.ID, orgID, userID)
return request, nil
}
// ListJoinRequests lists all join requests for an organization
func (s *organizationService) ListJoinRequests(ctx context.Context, orgID string) ([]*types.OrganizationJoinRequest, error) {
return s.orgRepo.ListJoinRequests(ctx, orgID, "")
}
// CountPendingJoinRequests returns the number of pending join requests for an organization
func (s *organizationService) CountPendingJoinRequests(ctx context.Context, orgID string) (int64, error) {
return s.orgRepo.CountJoinRequests(ctx, orgID, types.JoinRequestStatusPending)
}
// ReviewJoinRequest reviews a join request or upgrade request (approve or reject).
// When approving, assignRole overrides the applicant's requested role if set; otherwise uses request.RequestedRole or viewer.
func (s *organizationService) ReviewJoinRequest(ctx context.Context, orgID string, requestID string, approved bool, reviewerID string, message string, assignRole *types.OrgMemberRole) error {
request, err := s.orgRepo.GetJoinRequestByID(ctx, requestID)
if err != nil {
return ErrJoinRequestNotFound
}
if request.OrganizationID != orgID {
return ErrJoinRequestNotFound
}
if request.Status != types.JoinRequestStatusPending {
return errors.New("request has already been reviewed")
}
var status types.JoinRequestStatus
if approved {
status = types.JoinRequestStatusApproved
// Role to assign: admin override > applicant's requested role > viewer
role := types.OrgRoleViewer
if assignRole != nil && assignRole.IsValid() {
role = *assignRole
} else if request.RequestedRole != "" && request.RequestedRole.IsValid() {
role = request.RequestedRole
}
// Handle based on request type
if request.RequestType == types.JoinRequestTypeUpgrade {
// Upgrade: update existing member's role
if err := s.orgRepo.UpdateMemberRole(ctx, request.OrganizationID, request.UserID, role); err != nil {
return err
}
logger.Infof(ctx, "Upgrade request %s approved, user %s role updated to %s in organization %s", requestID, request.UserID, role, request.OrganizationID)
} else {
// Join: check member limit then add new member
org, errOrg := s.orgRepo.GetByID(ctx, request.OrganizationID)
if errOrg != nil {
return errOrg
}
if org.MemberLimit > 0 {
count, errCount := s.orgRepo.CountMembers(ctx, request.OrganizationID)
if errCount != nil {
return errCount
}
if count >= int64(org.MemberLimit) {
return ErrOrgMemberLimitReached
}
}
member := &types.OrganizationMember{
ID: uuid.New().String(),
OrganizationID: request.OrganizationID,
UserID: request.UserID,
TenantID: request.TenantID,
Role: role,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.orgRepo.AddMember(ctx, member); err != nil {
return err
}
logger.Infof(ctx, "Join request %s approved, user %s added to organization %s with role %s", requestID, request.UserID, request.OrganizationID, role)
}
} else {
status = types.JoinRequestStatusRejected
logger.Infof(ctx, "Request %s rejected for user %s", requestID, request.UserID)
}
return s.orgRepo.UpdateJoinRequestStatus(ctx, requestID, status, reviewerID, message)
}
// RequestRoleUpgrade submits a request to upgrade role in an organization
func (s *organizationService) RequestRoleUpgrade(ctx context.Context, orgID string, userID string, tenantID uint64, requestedRole types.OrgMemberRole, message string) (*types.OrganizationJoinRequest, error) {
logger.Infof(ctx, "User %s submitting role upgrade request for organization %s to role %s", userID, orgID, requestedRole)
// Check if user is a member
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
if errors.Is(err, repository.ErrOrgMemberNotFound) {
return nil, ErrUserNotInOrg
}
return nil, err
}
// Validate the requested role
if !requestedRole.IsValid() {
return nil, ErrInvalidRole
}
// Check if already admin
if member.Role == types.OrgRoleAdmin {
return nil, ErrAlreadyAdmin
}
// Check if requested role is higher than current role
if !requestedRole.HasPermission(member.Role) || requestedRole == member.Role {
return nil, ErrCannotUpgradeToSameRole
}
// Check if there's already a pending upgrade request
existing, err := s.orgRepo.GetPendingRequestByType(ctx, orgID, userID, types.JoinRequestTypeUpgrade)
if err == nil && existing != nil {
return nil, ErrPendingRequestExists
}
request := &types.OrganizationJoinRequest{
ID: uuid.New().String(),
OrganizationID: orgID,
UserID: userID,
TenantID: tenantID,
RequestType: types.JoinRequestTypeUpgrade,
PrevRole: member.Role,
RequestedRole: requestedRole,
Status: types.JoinRequestStatusPending,
Message: message,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.orgRepo.CreateJoinRequest(ctx, request); err != nil {
return nil, err
}
logger.Infof(ctx, "Role upgrade request %s created for organization %s by user %s (from %s to %s)", request.ID, orgID, userID, member.Role, requestedRole)
return request, nil
}
// GetPendingUpgradeRequest gets a pending upgrade request for a user in an organization
func (s *organizationService) GetPendingUpgradeRequest(ctx context.Context, orgID string, userID string) (*types.OrganizationJoinRequest, error) {
request, err := s.orgRepo.GetPendingRequestByType(ctx, orgID, userID, types.JoinRequestTypeUpgrade)
if err != nil {
if errors.Is(err, repository.ErrJoinRequestNotFound) {
return nil, ErrJoinRequestNotFound
}
return nil, err
}
return request, nil
}