erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
1 parent
9d5789c5d7
commit
c8df9d7651
41 files changed
+6455
-36
No files matched your search
@@ -0,0 +1,200 @@
|
||||
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
|
||||
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{}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
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, err := json.Marshal(t.AllowedDomains)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
now := normTime(s.now())
|
||||
t.CreatedAt, t.UpdatedAt = now, now
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO tenants
|
||||
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
|
||||
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
|
||||
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`
|
||||
|
||||
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
|
||||
var t Tenant
|
||||
var domains 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})
|
||||
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 t.AllowedDomains == nil {
|
||||
t.AllowedDomains = []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, err := json.Marshal(t.AllowedDomains)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
|
||||
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
|
||||
formatTime(s.now()), 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user