feat(PROJ-34): Retention-Tab + pro-Mandant Aufbewahrungsfristen

- tenantstore: retention_days Spalte, GetRetentionDays/SetRetentionDays
- storage.Save(): per-tenant retention überschreibt globale config
- API: GET /api/admin/retention, PUT /api/admin/tenant/{id}/retention
- Frontend: RetentionTab mit globaler Policy-Anzeige, Mandanten-Tabelle,
  Bearbeiten-Dialog und Purge-Button (superadmin only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 10:37:15 +02:00
parent 5f0c7a7e6d
commit 5bbf6d0ff3
8 changed files with 399 additions and 16 deletions
+37 -7
View File
@@ -20,11 +20,12 @@ type Tenant struct {
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
// Computed fields populated by List.
DomainCount int `json:"domain_count,omitempty"`
UserCount int `json:"user_count,omitempty"`
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
LDAPURL string `json:"ldap_url,omitempty"`
HasLogo bool `json:"has_logo,omitempty"`
DomainCount int `json:"domain_count,omitempty"`
UserCount int `json:"user_count,omitempty"`
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
LDAPURL string `json:"ldap_url,omitempty"`
HasLogo bool `json:"has_logo,omitempty"`
RetentionDays int `json:"retention_days"` // 0 = use global config
}
// TenantDomain is an e-mail domain assigned to a tenant.
@@ -75,6 +76,7 @@ ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenan
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA;
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT '';
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal';
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS retention_days INT NOT NULL DEFAULT 0;
`
// New connects to PostgreSQL and initialises the tenant schema.
@@ -125,7 +127,8 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
COUNT(DISTINCT u.id) AS user_count,
tl.enabled AS ldap_enabled,
tl.url AS ldap_url,
(t.logo_data IS NOT NULL) AS has_logo
(t.logo_data IS NOT NULL) AS has_logo,
t.retention_days
FROM tenants t
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
LEFT JOIN users u ON u.tenant_id = t.id
@@ -141,7 +144,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
var tenants []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays); err != nil {
return nil, fmt.Errorf("tenantstore: scan: %w", err)
}
tenants = append(tenants, t)
@@ -348,3 +351,30 @@ func (s *Store) SetIMAPMode(ctx context.Context, tenantID int64, mode string) er
_, err := s.pool.Exec(ctx, `UPDATE tenants SET imap_mode = $1 WHERE id = $2`, mode, tenantID)
return err
}
// ── Retention ─────────────────────────────────────────────────────────────────
// GetRetentionDays returns the per-tenant retention_days (0 = use global config).
func (s *Store) GetRetentionDays(ctx context.Context, tenantID int64) (int, error) {
var days int
err := s.pool.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id = $1`, tenantID).Scan(&days)
if err != nil {
return 0, nil // safe default: fall back to global
}
return days, nil
}
// SetRetentionDays sets the per-tenant retention_days. 0 means "use global config".
func (s *Store) SetRetentionDays(ctx context.Context, tenantID int64, days int) error {
if days < 0 {
return fmt.Errorf("tenantstore: retention_days must be >= 0")
}
tag, err := s.pool.Exec(ctx, `UPDATE tenants SET retention_days = $1 WHERE id = $2`, days, tenantID)
if err != nil {
return fmt.Errorf("tenantstore: set retention: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("tenantstore: tenant %d not found", tenantID)
}
return nil
}