CluPilotCloud/app/Support/OpenWork.php

186 lines
14 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 ist aus dem Verkauf genommen',
'state' => self::PLANNED,
'where' => 'App\\Services\\Billing\\AddonCatalogue::EXTRA_BACKUPS_ON_SALE',
'missing' => 'Das Modul kostete 6 € im Monat und versprach „eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert" — es gab dafür aber keinen Provisionierungsschritt, der ein zweites Sicherungsziel anlegt, nur die nächtliche vzdump-Sicherung auf die lokale Ablage des Hosts (App\\Provisioning\\Steps\\Customer\\RegisterBackup). Preistafel und Abrechnungsseite bieten das Modul jetzt nicht mehr an — der Code-Schalter ist die Konstante `EXTRA_BACKUPS_ON_SALE`, eigenständig von `OFFICE_ON_SALE`, weil dieses Modul an einem anderen Server hängt (dem künftigen Speicherserver, nicht dem Dokumentenserver) und darum zu einem anderen Zeitpunkt zurückkommt. Auf der Entwicklungsmaschine enthielt `subscription_addons` zum Zeitpunkt dieser Änderung keine einzige Zeile mit `extra_backups` — ob im Live-Betrieb schon gebucht wurde, ist von hier aus nicht sichtbar; sollte doch, entscheidet der Betreiber, was mit diesen Verträgen geschieht, diese Sperre betrifft nur NEUE Buchungen.',
'risk' => 'Kein akuter Schaden mehr — weder die Preistafel noch die Abrechnungsseite versprechen etwas, das nicht eingelöst wird. Das zweite Sicherungsziel selbst ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.',
],
[
'title' => 'Office (ONLYOFFICE über einen gemeinsamen Dokumentenserver) ist aus dem Verkauf genommen',
'state' => self::PLANNED,
'where' => 'App\\Services\\Billing\\AddonCatalogue::OFFICE_ON_SALE, database/migrations/2026_08_05_120000_office_verlaesst_team_und_business.php',
'missing' => 'Die Preistafel versprach „Office im Browser" an drei Stellen — der Aufzählung „Was enthalten ist", dem Paketmerkmal `office` in Team und Business, und dem Zusatzmodul `collabora_pro` für 22,80 € im Monat —, obwohl weder die goldene Vorlage (deploy/bootstrap/assets/docker-compose.yml enthält nur Nextcloud und die Datenbank) noch ein Bereitstellungsschritt je ein Office installiert haben. Alle drei Stellen sind jetzt stillgelegt, aber nicht gelöscht: der Code-Schalter ist die Konstante `OFFICE_ON_SALE` (dokumentiert an ihrer eigenen Stelle mit den zwei Handgriffen, die ein Wiederanschalten braucht — die Konstante auf true, und in der Konsole eine neue Planversion für Team und Business veröffentlichen, die `office` wieder führt). Wer die beiden Pakete vor dieser Umschaltung gekauft hat, behält `office` in seinem eingefrorenen Vertrag unverändert — dieser Punkt betrifft ausschließlich, was NEU beworben wird. `collabora_pro` hat bislang niemand gebucht (subscription_addons enthielt zum Zeitpunkt dieser Änderung keine einzige Zeile dazu), es gibt also keinen laufenden Vertrag, den eine spätere Entscheidung träfe. Am Tag nach der ersten Schliessung fiel auf, dass sie nur die Preistafel betraf: ein eingeloggter Team- oder Business-Kunde hätte `collabora_pro` weiterhin über die Abrechnungsseite buchen können, weil AddonCatalogue den Preis unverändert kannte und Billing::purchase() nie danach gefragt hat. Die Konstante wanderte deshalb vom LandingController in AddonCatalogue, die einzige Stelle, die beide Seiten lesen — Preistafel UND Kauf-Knopf weisen `collabora_pro` jetzt aus demselben Schalter ab.',
'risk' => 'Kein akuter Schaden mehr — weder die Preistafel noch die Abrechnungsseite versprechen etwas, das nicht eingelöst wird. Der eigentliche Dokumentenserver ist an keiner Stelle im Repository angefangen; das bleibt die Arbeit, die vor dem Wiederanschalten noch aussteht.',
],
[
'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' => '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.',
],
[
'title' => 'Die Zahlungssperre fällt über eine Maschine, die schon steht',
'state' => self::PARTIAL,
'where' => 'App\\Actions\\SuspendInstance',
'missing' => 'Sie ruft `shutdownVm()` unbedingt, ohne vorher zu fragen, ob der Gast überhaupt läuft. `ShutDownVirtualMachine` macht an derselben Stelle genau das Gegenteil und steigt bei einer stehenden Maschine sauber aus. Gefunden, als eine geschärfte Proxmox-Attrappe im Abbau-Vorhaben aufdeckte, dass neun Mahnwesen-Prüfungen nur deshalb grün waren, weil die alte Attrappe die Bitte gegen eine stehende Maschine klaglos annahm.',
'risk' => 'Ein Kunde, dessen Maschine aus einem anderen Grund steht, bekommt seine Sperre nie — der Lauf wirft, jede Nacht neu, und die Sperr-Nachricht geht nie hinaus. AdvanceDunning fängt je Fall ab, also stirbt still genau dieser eine.',
],
[
'title' => 'Ein abgebrochener vzdump läuft weiter und füllt die Ablage',
'state' => self::PARTIAL,
'where' => 'App\\Actions\\ArchiveAndTearDown → awaitTask()',
'missing' => 'Reisst die Sicherung beim Abbau die Zeitgrenze, wirft awaitTask() — bricht den Auftrag auf dem Knoten aber nicht ab (kein DELETE /nodes/{node}/tasks/{upid}). Der Dump schreibt sein Vollarchiv fertig, und die nächste Nacht fängt von vorn an.',
'risk' => 'Auf derselben Ablage liegen die nächtlichen Sicherungen ALLER anderen Kunden dieses Hosts. Eine einzige grosse gekuendigte Instanz kann sie nach ein paar Nächten volllaufen lassen, und dann scheitern die Sicherungen zahlender Kunden.',
],
[
'title' => 'nc_synced_at vermerkt die Antwort des Gastes, nicht den Zustand der Nextcloud',
'state' => self::PARTIAL,
'where' => 'App\\Provisioning\\Jobs\\SyncSeatToNextcloud, App\\Livewire\\Users → queueSync()',
'missing' => 'Geht die Antwort nach einem geglückten `user:add` verloren (Zeitüberlauf, Verbindungsabbruch), existiert das Konto und der Zeitstempel bleibt leer. queueSync() weigert sich danach dauerhaft, ein `disable` zu schicken. Der richtige Weg wäre, den Gast per `user:info` zu fragen, statt der eigenen Buchführung zu glauben.',
'risk' => 'Ein Inhaber entzieht einen Zugang, die Zeile sagt "entzogen", und das Nextcloud-Konto bleibt offen. Niemand sieht es.',
],
[
'title' => 'Sitzaufträge teilen sich den einen Arbeiter mit der bezahlten Bereitstellung',
'state' => self::PLANNED,
'where' => 'App\\Provisioning\\Jobs\\SyncSeatToNextcloud, docker/provisioning-worker.sh',
'missing' => 'Beide laufen auf der `provisioning`-Warteschlange, die von genau einem seriellen Arbeiter bedient wird. Ein haengender Gast kann ihn bis zu 35 Minuten belegen. Eine eigene Schlange (`seats`) mit einem zweiten queue:work im selben Container löste es — der Tunnel hängt am Container, nicht am Schlangennamen.',
'risk' => 'Zehn Einladungen je Kunde und Stunde sind erlaubt. Bei zwanzig Kunden können zweihundert Aufträge vor einer frisch bezahlten Bestellung in derselben Reihe stehen.',
],
[
'title' => 'Der Aufräumlauf für abgelaufene Archive fehlt',
'state' => self::PLANNED,
'where' => 'App\\Livewire\\Admin\\Instances → ARCHIVE_MONTHS',
'missing' => 'Die zugesagte Aufbewahrung beträgt zwölf Monate. Der Abschnitt in der Konsole blendet nach Ablauf aus, was noch dasteht — gelöscht wird nichts. Es gibt keinen Lauf, der die Frist durchsetzt.',
'risk' => 'Das Archiv bleibt auf der Ablage liegen, und archive_volid — die einzige Angabe, mit der man es wiederfindet — ist ab dann nirgends mehr sichtbar. Der Speicher wächst still weiter.',
],
[
'title' => 'Ein nie eingeladener Sitz steht auf "eingeladen"',
'state' => self::PARTIAL,
'where' => 'App\\Livewire\\Users → addSeat()',
'missing' => 'Anlegen schreibt `status = invited`, obwohl niemand eingeladen wurde. In der Anzeige überdeckt nc_state das; in der Datenbank steht eine Unwahrheit. Ein eigener Status `added` braucht die Wanderung UND die Anpassung von fünf Lesestellen: Users::usedSeats(), Dashboard::seats(), Dashboard::seatBreakdown(), DowngradeCheck und EditSeat::addressEditable.',
'risk' => 'Heute tragend, nicht kosmetisch: genau weil `invited` dasteht, stimmen Dashboard-Zähler, Downgrade-Sperre und Benutzerseite überein. Wer den Status ändert, ohne alle fünf Stellen mitzunehmen, lässt die Zahlen auseinanderlaufen.',
],
];
}
/**
* 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;
}
}