231 lines
7.0 KiB
Go
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)
|
|
}
|