Files
sultan/pkg/store/tenants.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

231 lines
7.0 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
)
// Tier ist die Lizenzstufe eines Mandanten.
type Tier string
const (
TierCommunity Tier = "community"
TierPro Tier = "pro"
TierEnterprise Tier = "enterprise"
)
// TenantStatus ist der Abo-Status eines Mandanten.
type TenantStatus string
const (
TenantActive TenantStatus = "active"
TenantSuspendedPayment TenantStatus = "suspended_payment"
TenantQuarantined TenantStatus = "quarantined"
TenantExpired TenantStatus = "expired"
)
func (t Tier) valid() bool {
return t == TierCommunity || t == TierPro || t == TierEnterprise
}
func (s TenantStatus) valid() bool {
switch s {
case TenantActive, TenantSuspendedPayment, TenantQuarantined, TenantExpired:
return true
}
return false
}
// Tenant ist ein Mandant mit seinem Abonnement.
type Tenant struct {
ID string
Name string
Email string
Tier Tier
MaxRoutes int // -1 = unbegrenzt
AllowedDomains []string
ValidUntil time.Time
Status TenantStatus
Features []string // freigeschaltete Lizenz-Features (z. B. custom_middleware)
// PaymentOverdueSince: seit wann eine Zahlung offen ist (Nullwert = nichts
// offen); daraus berechnet die Policy die Mahnstufe.
PaymentOverdueSince time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// normalize setzt Defaults (Tier community, Status active, leere
// Domainliste) und prüft die Eingabe.
func (t *Tenant) normalize() error {
if t.Tier == "" {
t.Tier = TierCommunity
}
if t.Status == "" {
t.Status = TenantActive
}
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
t.ValidUntil = normTime(t.ValidUntil)
switch {
case blank(t.ID):
return invalid("Mandanten-ID fehlt")
case blank(t.Name):
return invalid("Name fehlt")
case blank(t.Email):
return invalid("E-Mail fehlt")
case !t.Tier.valid():
return invalid("unbekannter Tier %q", t.Tier)
case !t.Status.valid():
return invalid("unbekannter Status %q", t.Status)
case t.MaxRoutes < -1:
return invalid("max_routes %d (erlaubt: -1 oder ≥ 0)", t.MaxRoutes)
case t.ValidUntil.IsZero():
return invalid("valid_until fehlt")
}
for _, d := range t.AllowedDomains {
if blank(d) {
return invalid("leerer Eintrag in allowed_domains")
}
}
for _, f := range t.Features {
if blank(f) {
return invalid("leerer Eintrag in features")
}
}
return nil
}
// CreateTenant legt einen Mandanten an und liefert ihn mit Defaults und
// Zeitstempeln zurück.
func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
now := normTime(s.now())
t.CreatedAt, t.UpdatedAt = now, now
t.PaymentOverdueSince = time.Time{}
_, err := s.db.ExecContext(ctx, `INSERT INTO tenants
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at, features)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now), string(features))
if err != nil {
return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
}
return t, nil
}
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at,
features, payment_overdue_since`
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
var t Tenant
var domains, features string
err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt},
&features, dbTime{&t.PaymentOverdueSince})
if err != nil {
return Tenant{}, err
}
if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
}
if err := json.Unmarshal([]byte(features), &t.Features); err != nil {
return Tenant{}, fmt.Errorf("store: features von %s: %w", t.ID, err)
}
if t.AllowedDomains == nil {
t.AllowedDomains = []string{}
}
if t.Features == nil {
t.Features = []string{}
}
return t, nil
}
// GetTenant liest einen Mandanten.
func (s *Store) GetTenant(ctx context.Context, id string) (Tenant, error) {
t, err := scanTenant(s.db.QueryRowContext(ctx, `SELECT `+tenantColumns+` FROM tenants WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Tenant{}, fmt.Errorf("Mandant %s: %w", id, ErrNotFound)
}
return t, err
}
// ListTenants liefert alle Mandanten, sortiert nach ID.
func (s *Store) ListTenants(ctx context.Context) ([]Tenant, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+tenantColumns+` FROM tenants ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
tenants := []Tenant{}
for rows.Next() {
t, err := scanTenant(rows)
if err != nil {
return nil, err
}
tenants = append(tenants, t)
}
return tenants, rows.Err()
}
// UpdateTenant überschreibt alle veränderlichen Felder des Mandanten t.ID.
// CreatedAt bleibt erhalten, UpdatedAt wird gesetzt.
func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
if err := t.normalize(); err != nil {
return Tenant{}, err
}
domains, _ := json.Marshal(t.AllowedDomains)
features, _ := json.Marshal(t.Features)
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?,
features = ?
WHERE id = ?`,
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
formatTime(s.now()), string(features), t.ID)
if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
return Tenant{}, err
}
return s.GetTenant(ctx, t.ID)
}
// SetTenantStatus ändert nur den Status, z. B. bei Zahlungsverzug.
func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantStatus) error {
if !status.valid() {
return invalid("unbekannter Status %q", status)
}
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET status = ?, updated_at = ? WHERE id = ?`,
status, formatTime(s.now()), id)
return requireAffected(res, err, "Mandant "+id)
}
// SetTenantPayment setzt, seit wann eine Zahlung offen ist (Nullwert =
// bezahlt). Grundlage der Mahnstufen.
func (s *Store) SetTenantPayment(ctx context.Context, id string, overdueSince time.Time) error {
var v any
if !overdueSince.IsZero() {
v = formatTime(overdueSince)
}
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET payment_overdue_since = ?, updated_at = ? WHERE id = ?`,
v, formatTime(s.now()), id)
return requireAffected(res, err, "Mandant "+id)
}
// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
return requireAffected(res, err, "Mandant "+id)
}