package services import ( "context" "database/sql" "fmt" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" ) type TenantService struct { db *sqlx.DB } func NewTenantService(db *sqlx.DB) *TenantService { return &TenantService{db: db} } // Create creates a new tenant and assigns the creator as owner. func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug string) (*models.Tenant, error) { tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin transaction: %w", err) } defer tx.Rollback() var tenant models.Tenant err = tx.QueryRowxContext(ctx, `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id, name, slug, settings, created_at, updated_at`, name, slug, ).StructScan(&tenant) if err != nil { return nil, fmt.Errorf("insert tenant: %w", err) } _, err = tx.ExecContext(ctx, `INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, 'owner')`, userID, tenant.ID, ) if err != nil { return nil, fmt.Errorf("assign owner: %w", err) } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit: %w", err) } return &tenant, nil } // ListForUser returns all tenants the user belongs to. func (s *TenantService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.TenantWithRole, error) { var tenants []models.TenantWithRole err := s.db.SelectContext(ctx, &tenants, `SELECT t.id, t.name, t.slug, t.settings, t.created_at, t.updated_at, ut.role FROM tenants t JOIN user_tenants ut ON ut.tenant_id = t.id WHERE ut.user_id = $1 ORDER BY t.name`, userID, ) if err != nil { return nil, fmt.Errorf("list tenants: %w", err) } return tenants, nil } // GetByID returns a single tenant. The caller must verify the user has access. func (s *TenantService) GetByID(ctx context.Context, tenantID uuid.UUID) (*models.Tenant, error) { var tenant models.Tenant err := s.db.GetContext(ctx, &tenant, `SELECT id, name, slug, settings, created_at, updated_at FROM tenants WHERE id = $1`, tenantID, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("get tenant: %w", err) } return &tenant, nil } // GetUserRole returns the user's role in a tenant, or empty string if not a member. func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { var role string err := s.db.GetContext(ctx, &role, `SELECT role FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`, userID, tenantID, ) if err == sql.ErrNoRows { return "", nil } if err != nil { return "", fmt.Errorf("get user role: %w", err) } return role, nil } // FirstTenantForUser returns the user's first tenant (by name), used as default. func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { var tenantID uuid.UUID err := s.db.GetContext(ctx, &tenantID, `SELECT t.id FROM tenants t JOIN user_tenants ut ON ut.tenant_id = t.id WHERE ut.user_id = $1 ORDER BY t.name LIMIT 1`, userID, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("first tenant: %w", err) } return &tenantID, nil } // ListMembers returns all members of a tenant. func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) { var members []models.UserTenant err := s.db.SelectContext(ctx, &members, `SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`, tenantID, ) if err != nil { return nil, fmt.Errorf("list members: %w", err) } return members, nil } // InviteByEmail looks up a user by email in auth.users and adds them to the tenant. func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, email, role string) (*models.UserTenant, error) { // Look up user in Supabase auth.users var userID uuid.UUID err := s.db.GetContext(ctx, &userID, `SELECT id FROM auth.users WHERE email = $1`, email, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("no user found with email %s", email) } if err != nil { return nil, fmt.Errorf("lookup user: %w", err) } // Check if already a member var exists bool err = s.db.GetContext(ctx, &exists, `SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`, userID, tenantID, ) if err != nil { return nil, fmt.Errorf("check membership: %w", err) } if exists { return nil, fmt.Errorf("user is already a member of this tenant") } var ut models.UserTenant err = s.db.QueryRowxContext(ctx, `INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, $3) RETURNING user_id, tenant_id, role, created_at`, userID, tenantID, role, ).StructScan(&ut) if err != nil { return nil, fmt.Errorf("invite user: %w", err) } return &ut, nil } // RemoveMember removes a user from a tenant. Cannot remove the last owner. func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error { // Check if the user being removed is an owner role, err := s.GetUserRole(ctx, userID, tenantID) if err != nil { return fmt.Errorf("check role: %w", err) } if role == "" { return fmt.Errorf("user is not a member of this tenant") } if role == "owner" { // Count owners — prevent removing the last one var ownerCount int err := s.db.GetContext(ctx, &ownerCount, `SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`, tenantID, ) if err != nil { return fmt.Errorf("count owners: %w", err) } if ownerCount <= 1 { return fmt.Errorf("cannot remove the last owner of a tenant") } } _, err = s.db.ExecContext(ctx, `DELETE FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`, userID, tenantID, ) if err != nil { return fmt.Errorf("remove member: %w", err) } return nil }