feat: add tenant + auth backend endpoints (Phase 1A)

Tenant management:
- POST /api/tenants — create tenant (creator becomes owner)
- GET /api/tenants — list tenants for authenticated user
- GET /api/tenants/:id — tenant details with access check
- POST /api/tenants/:id/invite — invite user by email (owner/admin)
- DELETE /api/tenants/:id/members/:uid — remove member
- GET /api/tenants/:id/members — list members

New packages:
- internal/services/tenant_service.go — CRUD on tenants + user_tenants
- internal/handlers/tenant_handler.go — HTTP handlers with auth checks
- internal/auth/tenant_resolver.go — X-Tenant-ID header middleware,
  defaults to user's first tenant for scoped routes

Authorization: owners/admins can invite and remove members. Cannot
remove the last owner. Users can remove themselves. TenantResolver
applies to resource routes (cases, deadlines, etc.) but not tenant
management routes.
This commit is contained in:
m
2026-03-25 13:27:39 +01:00
parent 8049ea3c63
commit 0b6bab8512
7 changed files with 808 additions and 6 deletions

View File

@@ -0,0 +1,211 @@
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
}