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) }