Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

152
migrations/001_core.sql Normal file
View file

@ -0,0 +1,152 @@
-- Kundencenter Grundschema (MariaDB). utf8mb4_unicode_ci.
CREATE TABLE settings (
`key` VARCHAR(100) PRIMARY KEY,
`value` VARCHAR(1000) NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);
INSERT INTO settings (`key`,`value`) VALUES ('registration_enabled','false');
CREATE TABLE users (
id CHAR(36) PRIMARY KEY,
email VARCHAR(254) NOT NULL UNIQUE,
name VARCHAR(150) NOT NULL,
password_hash VARCHAR(255) NULL,
kind ENUM('customer','staff') NOT NULL DEFAULT 'customer',
staff_role ENUM('support','accounting','admin','superadmin') NULL,
status ENUM('active','disabled','invited') NOT NULL DEFAULT 'invited',
email_verified_at DATETIME(3) NULL,
failed_logins INT NOT NULL DEFAULT 0,
locked_until DATETIME(3) NULL,
locale VARCHAR(10) NOT NULL DEFAULT 'de',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CHECK ((kind = 'staff' AND staff_role IS NOT NULL) OR (kind = 'customer' AND staff_role IS NULL))
);
CREATE TABLE sessions (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
csrf_token CHAR(43) NOT NULL,
mfa_verified TINYINT(1) NOT NULL DEFAULT 0,
pending_mfa TINYINT(1) NOT NULL DEFAULT 0,
ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
last_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
expires_at DATETIME(3) NOT NULL,
revoked_at DATETIME(3) NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX (user_id)
);
CREATE TABLE mfa_totp (
user_id CHAR(36) PRIMARY KEY,
secret_enc VARCHAR(512) NOT NULL,
confirmed_at DATETIME(3) NULL,
last_step BIGINT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE recovery_codes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id CHAR(36) NOT NULL,
code_hash CHAR(64) NOT NULL,
used_at DATETIME(3) NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX (user_id)
);
CREATE TABLE organizations (
id CHAR(36) PRIMARY KEY,
customer_number VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
status ENUM('active','suspended','closed') NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);
CREATE TABLE customer_sequences (id TINYINT PRIMARY KEY, next_value INT NOT NULL);
INSERT INTO customer_sequences VALUES (1, 10000);
CREATE TABLE billing_profiles (
org_id CHAR(36) PRIMARY KEY,
company VARCHAR(200) NULL,
contact_name VARCHAR(150) NULL,
street VARCHAR(200) NULL,
zip VARCHAR(20) NULL,
city VARCHAR(100) NULL,
country CHAR(2) NOT NULL DEFAULT 'DE',
vat_id VARCHAR(30) NULL,
billing_email VARCHAR(254) NULL,
phone VARCHAR(50) NULL,
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE
);
CREATE TABLE memberships (
org_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
role ENUM('owner','admin','member') NOT NULL DEFAULT 'member',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (org_id, user_id),
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Einmal-Token: Einladung, Passwort-Reset, E-Mail-Verifizierung
CREATE TABLE user_tokens (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
purpose ENUM('invite','password_reset','verify_email') NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME(3) NOT NULL,
used_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE audit_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
ts DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
actor_type ENUM('user','system','anonymous') NOT NULL,
actor_id CHAR(36) NULL,
org_id CHAR(36) NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NULL,
resource_id VARCHAR(64) NULL,
result ENUM('success','denied','failure') NOT NULL,
error_class VARCHAR(100) NULL,
connector VARCHAR(50) NULL,
correlation_id CHAR(36) NULL,
ip VARCHAR(45) NULL,
before_json JSON NULL,
after_json JSON NULL,
prev_hash CHAR(64) NOT NULL,
hash CHAR(64) NOT NULL,
INDEX (ts), INDEX (actor_id), INDEX (org_id), INDEX (action)
);
-- Persistente Aufträge (Outbox), Basis für Worker/Discord; später Provider-Jobs
CREATE TABLE jobs (
id CHAR(36) PRIMARY KEY,
type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
idempotency_key VARCHAR(150) NULL UNIQUE,
status ENUM('scheduled','running','waiting_external','succeeded','partial','failed','retrying','needs_review') NOT NULL DEFAULT 'scheduled',
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
run_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
locked_at DATETIME(3) NULL,
last_error VARCHAR(500) NULL,
correlation_id CHAR(36) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX (status, run_at)
);
CREATE TABLE mail_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
to_email VARCHAR(254) NOT NULL,
template VARCHAR(60) NOT NULL,
status ENUM('sent','failed','not_configured') NOT NULL,
error VARCHAR(300) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);

View file

@ -0,0 +1,40 @@
-- Connector-Instanzen (Verbindungen zu Providern) und synchronisierte Ressourcen
CREATE TABLE connector_instances (
id CHAR(36) PRIMARY KEY,
connector_key VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL UNIQUE,
config_json JSON NOT NULL,
secrets_enc TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sync_interval_sec INT NOT NULL DEFAULT 300,
health ENUM('unknown','ok','down') NOT NULL DEFAULT 'unknown',
health_message VARCHAR(300) NULL,
capabilities_json JSON NULL,
last_ok_at DATETIME(3) NULL,
last_sync_at DATETIME(3) NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);
-- Ressource = lokaler Spiegel eines Provider-Objekts (Lizenz, Hosting-Konto, ...). Provider-Rohdaten bleiben im Connector.
CREATE TABLE resources (
id CHAR(36) PRIMARY KEY,
instance_id CHAR(36) NOT NULL,
external_ref VARCHAR(100) NOT NULL,
type VARCHAR(30) NOT NULL,
name VARCHAR(300) NOT NULL,
state ENUM('active','suspended','expired','error','unknown') NOT NULL DEFAULT 'unknown',
org_id CHAR(36) NULL,
valid_from DATETIME(3) NULL,
valid_until DATETIME(3) NULL,
data_json JSON NULL,
customer_actions JSON NOT NULL DEFAULT '[]',
synced_at DATETIME(3) NOT NULL,
missing_since DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_instance_ref (instance_id, external_ref),
INDEX (org_id),
FOREIGN KEY (instance_id) REFERENCES connector_instances(id) ON DELETE CASCADE,
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE SET NULL
);

View file

@ -0,0 +1,3 @@
-- Kundenart: Privatkunde oder Geschäftskunde (bestehende Kunden gelten als Geschäftskunden)
ALTER TABLE organizations ADD COLUMN customer_type ENUM('private','business') NOT NULL DEFAULT 'business' AFTER name;
ALTER TABLE organizations ADD INDEX idx_customer_type (customer_type);

View file

@ -0,0 +1,105 @@
-- Produktkatalog, Bestellungen (mit unveränderlichem Preis-Snapshot) und Verträge
CREATE TABLE number_sequences (name VARCHAR(30) PRIMARY KEY, next_value INT NOT NULL);
INSERT INTO number_sequences VALUES ('order', 20000), ('contract', 30000);
CREATE TABLE tax_rates (
id CHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
rate_bp INT NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);
INSERT INTO tax_rates (id, name, rate_bp) VALUES (UUID(), 'Regelsteuersatz 19 %', 1900), (UUID(), 'Ermäßigter Steuersatz 7 %', 700), (UUID(), 'Steuerfrei 0 %', 0);
CREATE TABLE products (
id CHAR(36) PRIMARY KEY,
sku VARCHAR(50) NOT NULL UNIQUE,
category ENUM('hosting','license','addon','service') NOT NULL,
status ENUM('draft','active','retired') NOT NULL DEFAULT 'draft',
connector_instance_id CHAR(36) NULL,
orderable_by_customer TINYINT(1) NOT NULL DEFAULT 0,
requires_approval TINYINT(1) NOT NULL DEFAULT 1,
customer_actions JSON NOT NULL DEFAULT '[]',
current_version_id CHAR(36) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
FOREIGN KEY (connector_instance_id) REFERENCES connector_instances(id) ON DELETE SET NULL
);
-- Versionen sind unveränderlich; jede Preis-/Vertragsänderung erzeugt eine neue Version.
CREATE TABLE product_versions (
id CHAR(36) PRIMARY KEY,
product_id CHAR(36) NOT NULL,
version INT NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT NULL,
tax_rate_id CHAR(36) NOT NULL,
tax_bp INT NOT NULL,
setup_net_cents INT NOT NULL DEFAULT 0,
recurring_net_cents INT NOT NULL DEFAULT 0,
currency CHAR(3) NOT NULL DEFAULT 'EUR',
billing_interval ENUM('once','monthly','yearly') NOT NULL,
term_months INT NOT NULL DEFAULT 0,
renewal ENUM('auto','none') NOT NULL DEFAULT 'none',
renewal_term_months INT NOT NULL DEFAULT 0,
notice_days INT NOT NULL DEFAULT 30,
provisioning_json JSON NOT NULL DEFAULT '{}',
created_by CHAR(36) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_product_version (product_id, version),
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
FOREIGN KEY (tax_rate_id) REFERENCES tax_rates(id)
);
CREATE TABLE orders (
id CHAR(36) PRIMARY KEY,
number VARCHAR(20) NOT NULL UNIQUE,
org_id CHAR(36) NOT NULL,
status ENUM('pending_approval','approved','provisioning','completed','failed','rejected','cancelled') NOT NULL,
placed_by CHAR(36) NULL,
placed_via ENUM('customer','staff') NOT NULL,
approval_required TINYINT(1) NOT NULL DEFAULT 1,
approved_by CHAR(36) NULL,
approved_at DATETIME(3) NULL,
note VARCHAR(500) NULL,
failure_note VARCHAR(500) NULL,
failure_ambiguous TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX (org_id), INDEX (status),
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE order_items (
id CHAR(36) PRIMARY KEY,
order_id CHAR(36) NOT NULL,
product_version_id CHAR(36) NOT NULL,
quantity INT NOT NULL DEFAULT 1,
discount_bp INT NOT NULL DEFAULT 0,
snapshot_json JSON NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (product_version_id) REFERENCES product_versions(id)
);
CREATE TABLE contracts (
id CHAR(36) PRIMARY KEY,
number VARCHAR(20) NOT NULL UNIQUE,
org_id CHAR(36) NOT NULL,
order_item_id CHAR(36) NOT NULL UNIQUE,
product_version_id CHAR(36) NOT NULL,
status ENUM('pending','active','suspended','cancelled','expired','failed') NOT NULL DEFAULT 'pending',
started_at DATETIME(3) NULL,
term_end DATETIME(3) NULL,
renewal ENUM('auto','none') NOT NULL DEFAULT 'none',
renewal_term_months INT NOT NULL DEFAULT 0,
notice_days INT NOT NULL DEFAULT 30,
cancel_requested_at DATETIME(3) NULL,
cancel_effective_at DATETIME(3) NULL,
cancelled_at DATETIME(3) NULL,
resource_id CHAR(36) NULL,
price_snapshot_json JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX (org_id), INDEX (status),
FOREIGN KEY (org_id) REFERENCES organizations(id),
FOREIGN KEY (order_item_id) REFERENCES order_items(id),
FOREIGN KEY (product_version_id) REFERENCES product_versions(id),
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE SET NULL
);

View file

@ -0,0 +1,3 @@
-- Herkunft eines Produkts beim Anbieter (z. B. Programm-ID im Lizenzsystem), für die Übernahme aus Verbindungen
ALTER TABLE products ADD COLUMN external_ref VARCHAR(100) NULL AFTER connector_instance_id;
ALTER TABLE products ADD INDEX idx_products_ref (connector_instance_id, external_ref);

View file

@ -0,0 +1,4 @@
-- Preisbasis: Beträge sind je nach price_basis Netto- oder Bruttobeträge (Verbraucherpreise sind Bruttopreise und müssen exakt bleiben)
ALTER TABLE product_versions RENAME COLUMN setup_net_cents TO setup_cents;
ALTER TABLE product_versions RENAME COLUMN recurring_net_cents TO recurring_cents;
ALTER TABLE product_versions ADD COLUMN price_basis ENUM('net','gross') NOT NULL DEFAULT 'net' AFTER currency;

View file

@ -0,0 +1,22 @@
-- Backup-Einstellungen (verschlüsselt mit dem Master-Schlüssel) und externe Ziele
CREATE TABLE backup_settings (
`key` VARCHAR(50) PRIMARY KEY,
value_enc TEXT NOT NULL,
updated_by CHAR(36) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);
CREATE TABLE backup_targets (
id CHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
type ENUM('sftp','ftp','gdrive','local') NOT NULL,
path VARCHAR(500) NOT NULL DEFAULT '',
config_json JSON NOT NULL,
secrets_enc TEXT NULL,
host_key TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
last_test_at DATETIME(3) NULL,
last_test_ok TINYINT(1) NULL,
last_test_error VARCHAR(400) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);

View file

@ -0,0 +1,3 @@
-- Herkunft übernommener Kunden (z. B. "keyhelp:<Verbindung>:<Kunden-ID>"), verhindert doppelte Übernahme
ALTER TABLE organizations ADD COLUMN legacy_ref VARCHAR(150) NULL AFTER customer_type;
ALTER TABLE organizations ADD UNIQUE INDEX uq_org_legacy_ref (legacy_ref);

View file

@ -0,0 +1 @@
ALTER TABLE connector_instances ADD COLUMN last_error_at DATETIME(3) NULL AFTER last_error;

View file

@ -0,0 +1,19 @@
-- Domain-Preisliste: Einkaufspreise je Endung und Staffel, Verkaufspreis = Einkauf + Gewinnaufschlag
CREATE TABLE domain_tlds (
tld VARCHAR(63) PRIMARY KEY,
term_months INT NOT NULL DEFAULT 12,
cost1_cents INT NOT NULL, cost2_cents INT NOT NULL, cost3_cents INT NOT NULL, cost4_cents INT NOT NULL,
setup_cents INT NOT NULL DEFAULT 0,
margin_type ENUM('percent','fixed') NULL, -- NULL = globalen Aufschlag verwenden
margin_value INT NULL, -- percent: Basispunkte (2500 = 25 %), fixed: Cent
active TINYINT(1) NOT NULL DEFAULT 1,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE domain_settings (
id TINYINT PRIMARY KEY,
tier TINYINT NOT NULL DEFAULT 1, -- Preisstaffel des Einkaufs (1 = 0-49, 2 = 50-199, 3 = 200-999, 4 = 1000+)
margin_type ENUM('percent','fixed') NULL,
margin_value INT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO domain_settings (id, tier) VALUES (1, 1);

View file

@ -0,0 +1 @@
ALTER TABLE domain_settings ADD COLUMN rounding TINYINT(1) NOT NULL DEFAULT 1 AFTER margin_value;

View file

@ -0,0 +1,23 @@
-- Domain-Aufstellung: je Domain Einkauf (netto), Verkauf (netto/brutto) und Beschaffungsstatus (Bestellung beim Registrar KCS)
CREATE TABLE domain_records (
id CHAR(36) PRIMARY KEY,
domain VARCHAR(253) NOT NULL,
tld VARCHAR(63) NOT NULL,
org_id CHAR(36) NULL,
resource_id CHAR(36) NULL,
source ENUM('keyhelp','manual') NOT NULL DEFAULT 'manual',
term_months INT NULL,
cost_net_cents INT NULL, -- Einkauf netto (Preisstaffel zum Zeitpunkt der Erfassung)
setup_cost_cents INT NOT NULL DEFAULT 0,
sell_net_cents INT NULL, -- errechneter Verkaufspreis netto
tax_bp INT NOT NULL DEFAULT 1900,
sell_gross_cents INT NULL, -- errechneter Verkaufspreis brutto
procurement ENUM('open','ordered','external') NOT NULL DEFAULT 'open', -- open = bei KCS zu bestellen, external = liegt bei anderem Anbieter
ordered_at DATETIME(3) NULL,
ordered_ref VARCHAR(100) NULL,
note VARCHAR(300) NULL,
priced_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_domain (domain),
KEY idx_dr_org (org_id), KEY idx_dr_res (resource_id), KEY idx_dr_proc (procurement)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -0,0 +1,3 @@
-- Einkaufspreise laut Liste sind Bruttopreise (inkl. USt); Umstellbar auf netto. Aufstellung speichert beide Werte.
ALTER TABLE domain_settings ADD COLUMN cost_basis ENUM('gross','net') NOT NULL DEFAULT 'gross' AFTER rounding;
ALTER TABLE domain_records ADD COLUMN cost_gross_cents INT NULL AFTER cost_net_cents;