172 lines
9.5 KiB
PHP
172 lines
9.5 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
/**
|
|
* Was angefangen und nicht fertig ist.
|
|
*
|
|
* Eine Liste im Code und nicht in der Datenbank: sie beschreibt den Zustand
|
|
* DIESER Fassung. Ein Punkt verschwindet, wenn die Arbeit im selben Commit
|
|
* fertig wird, der ihn streicht — und damit kann die Liste nicht behaupten,
|
|
* etwas sei offen, das es längst nicht mehr ist. Eine Tabelle könnte das sehr
|
|
* wohl, und niemand pflegt sie.
|
|
*
|
|
* Jeder Eintrag nennt drei Dinge, weil ohne sie niemand damit arbeiten kann:
|
|
* wo es steht, was fehlt, und was passiert, wenn es so bleibt. Der letzte Punkt
|
|
* ist der wichtigste — er entscheidet die Reihenfolge, nicht das Gefühl.
|
|
*/
|
|
final class OpenWork
|
|
{
|
|
/** Kunden zahlen dafür, es passiert aber nichts. */
|
|
public const SOLD = 'sold';
|
|
|
|
/** Gebaut, aber nie unter echten Bedingungen bewiesen. */
|
|
public const UNPROVEN = 'unproven';
|
|
|
|
/** Halb gebaut: ein Teil trägt, ein anderer fehlt. */
|
|
public const PARTIAL = 'partial';
|
|
|
|
/** Beschlossen, noch nicht angefangen. */
|
|
public const PLANNED = 'planned';
|
|
|
|
/**
|
|
* @return list<array{title: string, state: string, where: string, missing: string, risk: string}>
|
|
*/
|
|
public static function all(): array
|
|
{
|
|
return [
|
|
[
|
|
'title' => 'Zweiter Sicherungsort wird verkauft, aber nicht geliefert',
|
|
'state' => self::SOLD,
|
|
'where' => 'config/provisioning.php → addons.extra_backups',
|
|
'missing' => 'Das Modul kostet 6 € im Monat und verspricht „eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert". Im ganzen Repo kommt der Schlüssel nur im LandingController und in der Konfiguration vor — es gibt keinen Provisionierungsschritt, der ein zweites Sicherungsziel anlegt.',
|
|
'risk' => 'Wer es bucht, zahlt monatlich für eine Leistung, die nicht erbracht wird.',
|
|
],
|
|
[
|
|
'title' => 'Office mit vollem Funktionsumfang wird verkauft, aber nicht geliefert',
|
|
'state' => self::SOLD,
|
|
'where' => 'config/provisioning.php → addons.collabora_pro',
|
|
'missing' => 'Kostet 22,80 € im Monat und verspricht mehr gleichzeitige Bearbeiter und erweiterte Office-Funktionen. Es gibt keine Collabora-Konfiguration, die vom gebuchten Modul abhängt.',
|
|
'risk' => 'Wie oben: bezahlt und nicht erbracht. Die Paketsperre für Start ist inzwischen gesetzt, das Modul selbst tut weiterhin nichts.',
|
|
],
|
|
[
|
|
'title' => 'Ein Plan-Wechsel wird nirgends angewendet',
|
|
'state' => self::PARTIAL,
|
|
'where' => 'App\\Services\\Billing\\PlanChange',
|
|
'missing' => 'PlanChange rechnet eine Vorschau. Auf- und Abstufungen erzeugen eine Bestellung, die niemand verbraucht — settleCustomDomain() hat bis heute keinen Aufrufer. Wer den Wechsel baut, muss zusätzlich entscheiden, ob ein gebuchtes Modul auf den neuen Vertrag mitgeht.',
|
|
'risk' => 'Ein Kunde bestellt einen Wechsel, sieht eine Bestätigung, und sein Vertrag bleibt, wie er war.',
|
|
],
|
|
[
|
|
'title' => 'Eine entzogene Domain bleibt auf der Maschine stehen',
|
|
'state' => self::PARTIAL,
|
|
'where' => 'App\\Services\\Billing\\CustomDomainAccess',
|
|
'missing' => 'Das Deaktivieren setzt den Zustand in der Datenbank, startet aber keine Provisionierung. Traefik und Nextclouds trusted_domains nennen die Adresse weiter, bis ConfigureDnsAndTls und ConfigureNextcloud erneut laufen.',
|
|
'risk' => 'Eine Adresse, die der Kunde nicht mehr bezahlt, antwortet weiter.',
|
|
],
|
|
[
|
|
'title' => 'Die Zertifikats-Spec beschreibt einen Weg, den es nicht gibt',
|
|
'state' => self::PLANNED,
|
|
'where' => 'docs/superpowers/specs/2026-08-01-host-konsole-zertifikat-design.md',
|
|
'missing' => 'Die Spec ist auf eine eigene Zone und Proxmox\' Alias-Modus gebaut. Hetzner unterstützt keine Subzonen (Artikel NE-7597D), damit entfallen beide ersatzlos. Weg B steht fest: CluPilot stellt zentral aus, DNS-01 über den vorhandenen Token, Zertifikat per SSH auf den Host, Erneuerung als geplanter Lauf mit Restlaufzeit auf der Bereitschaftsseite. Die Spec ist noch nicht umgeschrieben.',
|
|
'risk' => 'Wer sie heute aufschlägt und loslegt, baut den toten Weg.',
|
|
],
|
|
[
|
|
'title' => 'Der Rettungssystem-Weg liegt still',
|
|
'state' => self::PARTIAL,
|
|
'where' => 'deploy/bootstrap/clupilot-bootstrap.sh, App\\Livewire\\Admin\\ReissueTakeover',
|
|
'missing' => 'Zwei Wege, einen Host aufzunehmen, existieren nebeneinander: die Provisionierungs-Pipeline auf einem vorinstallierten Debian (das ist der gefahrene) und das Bootstrap-Skript, das im Rettungssystem Proxmox von der ISO auf ZFS legt. Der zweite ist vollständig gebaut und wird nicht benutzt.',
|
|
'risk' => 'Kein akuter Schaden — aber es ist ein zweiter, ungetesteter Pfad, den beim nächsten Umbau jemand für den aktuellen hält.',
|
|
],
|
|
[
|
|
'title' => 'Hostnamen-Abnahme auf der echten Installation steht aus',
|
|
'state' => self::UNPROVEN,
|
|
'where' => 'database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php',
|
|
'missing' => 'Die Migration ist gegen echtes MariaDB in allen Fehlerfällen geprüft — aber auf einer Wegwerf-Datenbank. Die Abnahme aus dem Entwurf (pve-fns-1 wird zu fsn-01, der fsn-Zähler springt auf 2) wurde auf dem Live-Server nie nachgesehen.',
|
|
'risk' => 'Hosts ohne DNS-Namen behalten ihren getippten Namen. Für die zeigt die Detailseite eine Adresse an, die nie jemand veröffentlicht hat.',
|
|
],
|
|
[
|
|
'title' => 'Ein Zähler kann durch Löschen eines Rechenzentrums zurückfallen',
|
|
'state' => self::PARTIAL,
|
|
'where' => 'App\\Livewire\\Admin\\ConfirmDeleteDatacenter',
|
|
'missing' => 'Der Hostnamen-Zähler liegt auf der Rechenzentrums-Zeile. Ein leeres Rechenzentrum lässt sich löschen; wer denselben Code neu anlegt, beginnt wieder bei 01. Bewusst so entschieden — was nichts mehr hält, soll entfernbar bleiben.',
|
|
'risk' => 'Enger Weg, aber er weicht die Zusage „eine Nummer wird nie wiederverwendet" auf.',
|
|
],
|
|
[
|
|
'title' => 'Support-Mail geht nicht raus',
|
|
'state' => self::PARTIAL,
|
|
'where' => 'Konsole → System → E-Mail, Postfach „support"',
|
|
'missing' => 'Das Postfach support@ hat kein eigenes SMTP-Konto und meldet sich deshalb über das no-reply-Konto an. Der Server weist den Absender ab (553). Betroffen ist alles mit Support-Zweck — auch die Antwort an einen Kunden aus der Konsole.',
|
|
'risk' => 'Website-Anfragen landen im Posteingang der Konsole und sind nicht verloren, aber niemand wird benachrichtigt. Kundenantworten aus der Konsole kommen gar nicht an.',
|
|
],
|
|
[
|
|
'title' => 'Erklärungs-Modal je Zusatzmodul',
|
|
'state' => self::PLANNED,
|
|
'where' => 'resources/views/livewire/billing.blade.php, landing.blade.php',
|
|
'missing' => 'Zu jedem Modul soll ein Modal erklären, warum es das Modul gibt und worauf es aufbaut, damit „mal dabei, mal dazubuchbar" keine Frage mehr aufwirft. Sinnvoll erst, wenn die zwei nicht gelieferten Module etwas tun.',
|
|
'risk' => 'Ohne die Erklärung bleibt die Preistafel an der Stelle missverständlich, an der sie am meisten kostet.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Die Zustände nach Dringlichkeit, und das ist keine Geschmacksfrage.
|
|
*
|
|
* Sortiert wird danach, was ein offener Punkt HEUTE anrichtet: verkauft und
|
|
* nicht geliefert kostet in diesem Moment Geld und Vertrauen. Halb gebaut
|
|
* kann jederzeit jemandem vor die Füße fallen. Ungeprüft ist ein Risiko,
|
|
* das noch niemand gesehen hat. Beschlossen tut gar nichts, bis jemand
|
|
* anfängt.
|
|
*
|
|
* Ohne diese Reihenfolge stand die Liste in der Reihenfolge, in der sie
|
|
* geschrieben wurde — also zufällig.
|
|
*/
|
|
public const ORDER = [self::SOLD, self::PARTIAL, self::UNPROVEN, self::PLANNED];
|
|
|
|
/**
|
|
* Nach Zustand gruppiert, dringendste Gruppe zuerst. Leere Gruppen fallen
|
|
* weg — eine Überschrift ohne Inhalt liest sich wie ein Fehler.
|
|
*
|
|
* @return array<string, list<array<string, string>>>
|
|
*/
|
|
public static function grouped(): array
|
|
{
|
|
$groups = [];
|
|
|
|
foreach (self::ORDER as $state) {
|
|
$items = array_values(array_filter(self::all(), fn (array $i) => $i['state'] === $state));
|
|
|
|
if ($items !== []) {
|
|
$groups[$state] = $items;
|
|
}
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
/** Der dringendste Zustand, der gerade vorkommt — oder null, wenn nichts offen ist. */
|
|
public static function worst(): ?string
|
|
{
|
|
foreach (self::ORDER as $state) {
|
|
foreach (self::all() as $item) {
|
|
if ($item['state'] === $state) {
|
|
return $state;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @return array<string, int> Zustand => Anzahl */
|
|
public static function counts(): array
|
|
{
|
|
$counts = [];
|
|
|
|
foreach (self::all() as $item) {
|
|
$counts[$item['state']] = ($counts[$item['state']] ?? 0) + 1;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
}
|