Plan: Kuendigung Teil A — die Frage und die Warnung
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

Bewusst nur die erste Haelfte des Entwurfs. Teil B (Export, Abbau, Archiv)
haengt an einem Speicherserver, den es noch nicht gibt: keine Adresse, keine
Zugangsdaten, kein Wissen darueber, welches Werkzeug auf den Proxmox-Hosts
wirklich steht. Dafuer jetzt Code zu schreiben hiesse, Pfade und Fehlerfaelle zu
erfinden und sie als Plan auszugeben — an diesem Vorhaben sind heute neun
Befunde aufgelaufen, und jeder stammte aus einem Plan, der an einer Stelle
selbstsicher war, an der niemand nachgesehen hatte.

Teil A traegt fuer sich den Punkt, der heute am meisten weh tut: ab dem
Laufzeitende zieht EndInstanceService die Adresse ein, und der Kunde kommt nicht
mehr an seine Nextcloud. Wer selbst herunterladen will, muss es vorher tun — und
das sagt ihm bisher niemand.

Drei Voraussetzungen fuer Teil B stehen am Ende des Plans. Die dritte ist die
wichtigste: dass eine Momentaufnahme einmal von Hand gefahren und das Ergebnis
aufgeschrieben wurde. Ob Proxmox eine laufende Maschine so einfriert, dass das
Dateisystem darin brauchbar ist, gehoert gemessen und nicht angenommen.
claude/nice-moser-521659
nexxo 2026-08-04 02:53:17 +02:00
parent cfbe339df0
commit 0ba3a285c2
1 changed files with 450 additions and 0 deletions

View File

@ -0,0 +1,450 @@
# Kündigung, Teil A: die Frage und die Warnung — Umsetzungsplan
> **Für agentische Arbeiter:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`. Schritte benutzen Checkbox-Syntax (`- [ ]`).
**Ziel:** Ein kündigender Kunde wird gefragt, ob er einen Datenexport möchte, und er wird gewarnt, bevor ihn seine eigene Cloud aussperrt.
**Architektur:** Zwei Zustandsfelder an der Instanz, eine Frage im Kündigungsdialog, eine Seite im Portal zum Ändern, und ein Zeitplan-Befehl, der sieben Tage vor Laufzeitende erinnert. Kein Gast wird angefasst, kein Speicher gebraucht.
**Entwurf:** `docs/superpowers/specs/2026-08-04-kuendigung-export-abbau-design.md`
---
## Warum dieser Plan bei A aufhört
Der Entwurf beschreibt den ganzen Weg: fragen, erinnern, exportieren, bestätigen, abbauen, archivieren. Gebaut wird hier nur die erste Hälfte.
**Der Grund ist nicht Bequemlichkeit, sondern eine Lehre von heute.** An diesem Vorhaben sind neun Befunde aufgelaufen, und jeder einzelne stammte aus einem Plan, der an einer Stelle selbstsicher war, an der niemand nachgesehen hatte. Teil B — der Export selbst und der Abbau — hängt an einem **Speicherserver, den es noch nicht gibt**: keine Adresse, keine Zugangsdaten, kein Wissen darüber, welches Werkzeug auf den Proxmox-Hosts wirklich vorhanden ist.
Dafür jetzt Code zu schreiben hiesse, Pfade, Befehle und Fehlerfälle zu erfinden und sie dann als Plan auszugeben. Teil B bekommt seinen eigenen Plan, wenn die Kiste steht und man sie befragen kann.
**Teil A ist trotzdem für sich nützlich, und zwar für den Punkt, der heute am meisten weh tut:** ab dem Laufzeitende kommt ein Kunde nicht mehr an seine Nextcloud, und niemand sagt es ihm vorher. Wer selbst herunterladen will, erfährt heute erst davon, wenn es zu spät ist. Das behebt dieser Teil allein.
---
## Global Constraints
- **Eine Kündigung endet nichts früher.** `EndInstanceService::hasEnded()` bleibt unangetastet. Wer am Zweiten kündigt, arbeitet bis Monatsende.
- **Kein Gast wird angefasst.** Dieser Teil schreibt Datenbankfelder und verschickt Mails. Nichts läuft über den Gastagenten, nichts über SSH.
- **Jede Mailart gehört in `MailCatalogue`** und in `MailLane` — sonst fällt sie stumm auf die gedrosselte Spur und ist in der Versandtakt-Übersicht unsichtbar. Das ist heute schon einmal aufgefallen.
- Kommentare erklären das WARUM, auf Deutsch, **mit Umlauten** — so schreibt dieser Bestand.
- Sprachdateien immer **beide** (`lang/de/`, `lang/en/`), mit denselben Schlüsseln.
- R19: jede Zeit, die ein Mensch liest, geht durch `->local()`. R23: Bestätigen im Modal, nie `wire:confirm`. R24: Modal nie höher als der Bildschirm.
- Tests: `docker compose exec -u 1000:1000 -T app php artisan test`. Niemals `php artisan` direkt auf dem Host.
---
## Dateiaufstellung
| Datei | Verantwortung |
|---|---|
| `database/migrations/…_der_kunde_sagt_ob_er_einen_export_will.php` | **neu** — die Felder |
| `app/Models/Instance.php` | **ändern** — Zustände, Casts |
| `app/Livewire/ConfirmCancelPackage.php` | **ändern** — die Frage |
| `resources/views/livewire/confirm-cancel-package.blade.php` | **ändern** — die Frage |
| `app/Livewire/Billing.php` (oder wo das Paket im Portal steht) | **ändern** — Antwort später ändern |
| `app/Mail/ServiceEndingSoonMail.php` | **neu** — die Erinnerung |
| `app/Console/Commands/RemindEndingServices.php` | **neu** — sieben Tage vorher |
| `routes/console.php` | **ändern** — täglich |
| `app/Services/Mail/MailCatalogue.php`, `MailLane.php`, `MailPreviews.php` | **ändern** — die neue Mailart |
| `lang/{de,en}/…` | **ändern** |
---
## Task 1: Die Instanz merkt sich, was der Kunde will
**Files:**
- Create: `database/migrations/2026_08_04_140000_der_kunde_sagt_ob_er_einen_export_will.php`
- Modify: `app/Models/Instance.php`
- Test: `tests/Feature/Cancellation/ExportWishTest.php`
**Interfaces:**
- Produces: `instances.export_wish` (`null` = nicht gefragt, `true`/`false` = beantwortet), `instances.export_reminded_at`
- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**
```php
<?php // tests/Feature/Cancellation/ExportWishTest.php
use App\Models\Instance;
it('weiss bei einer Instanz, die nie gekuendigt wurde, gar nichts', function () {
// Drei Zustaende, nicht zwei. `null` heisst "nicht gefragt" und ist etwas
// anderes als "nein" — eine Bestandsinstanz, die vor diesem Bau gekuendigt
// wurde, hat die Frage nie gesehen, und wir duerfen ihr keine Antwort
// unterstellen.
expect(Instance::factory()->create()->export_wish)->toBeNull();
});
it('haelt ein Ja und ein Nein auseinander', function () {
expect(Instance::factory()->create(['export_wish' => true])->export_wish)->toBeTrue()
->and(Instance::factory()->create(['export_wish' => false])->export_wish)->toBeFalse();
});
it('merkt sich, wann erinnert wurde, damit es nicht zweimal geschieht', function () {
$instance = Instance::factory()->create(['export_reminded_at' => now()]);
expect($instance->fresh()->export_reminded_at)->not->toBeNull();
});
```
- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**
Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Cancellation/ExportWishTest.php`
Erwartet: FEHLSCHLAG, Spalte unbekannt
- [ ] **Schritt 3: Die Wanderung schreiben**
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Was der Kunde beim Kuendigen zu seinen Daten gesagt hat.
*
* `export_wish` fuehrt DREI Zustaende, nicht zwei: `null` heisst "noch nicht
* gefragt". Das ist kein Feinschliff — jede Instanz, die vor diesem Bau
* gekuendigt wurde, hat die Frage nie gesehen, und ihr ein "nein" zu
* unterstellen hiesse, einer Handvoll Menschen still zu antworten, die nie
* gefragt wurden.
*
* `export_reminded_at` haelt fest, dass die Erinnerung sieben Tage vor Ende
* hinaus ist. Ohne diese Spalte schickte ein stuendlich laufender Zeitplan sie
* jede Stunde erneut — und eine Erinnerung, die im Sechserpack kommt, liest
* sich als Mahnung.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->boolean('export_wish')->nullable()->after('service_ends_at');
$table->timestamp('export_reminded_at')->nullable()->after('export_wish');
});
}
public function down(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->dropColumn(['export_wish', 'export_reminded_at']);
});
}
};
```
- [ ] **Schritt 4: Das Modell erweitern**
In `app/Models/Instance.php`: `export_wish` und `export_reminded_at` in `$fillable`, dazu die Casts `'export_wish' => 'boolean'` und `'export_reminded_at' => 'datetime'`.
**Achtung:** `boolean` als Cast macht aus `null` **nicht** `false`. Prüf das — die Unterscheidung ist der Kern dieser Aufgabe.
- [ ] **Schritt 5: Laufen lassen, grün**
Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Cancellation/ExportWishTest.php`
Erwartet: 3 grün
- [ ] **Schritt 6: Festschreiben**
```bash
git commit -F- -- database/migrations/2026_08_04_140000_der_kunde_sagt_ob_er_einen_export_will.php app/Models/Instance.php tests/Feature/Cancellation/ExportWishTest.php <<'MSG'
Die Instanz merkt sich, was der Kunde zu seinen Daten gesagt hat
Drei Zustaende, nicht zwei: `null` heisst "noch nicht gefragt". Jede Instanz,
die vor diesem Bau gekuendigt wurde, hat die Frage nie gesehen — ihr ein "nein"
zu unterstellen hiesse, still fuer Menschen zu antworten, die niemand gefragt
hat.
MSG
```
---
## Task 2: Die Frage im Kündigungsdialog
**Files:**
- Modify: `app/Livewire/ConfirmCancelPackage.php`
- Modify: `resources/views/livewire/confirm-cancel-package.blade.php`
- Modify: `lang/{de,en}/…` (die Sprachdatei, die der Dialog benutzt)
- Test: `tests/Feature/Cancellation/CancelAsksAboutExportTest.php`
**Interfaces:**
- Consumes: `instances.export_wish` aus Task 1
Der Dialog setzt heute `status`, `cancel_requested_at` und `service_ends_at` und teilt es Stripe mit. Diese Reihenfolge bleibt **unverändert** — sie ist eigens so gebaut, dass Stripe zuerst erfährt und alles andere aus dessen Antwort folgt.
- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**
```php
<?php // tests/Feature/Cancellation/CancelAsksAboutExportTest.php
use App\Livewire\ConfirmCancelPackage;
use App\Models\Instance;
use Livewire\Livewire;
it('schreibt die Antwort des Kunden an die Instanz', function () {
[$user, $instance] = kuendbareInstanz();
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', $instance->subdomain)
->set('exportWish', true)
->call('cancelPackage');
expect($instance->fresh()->export_wish)->toBeTrue();
});
it('nimmt auch ein Nein als Antwort — und nicht als fehlende Antwort', function () {
// Das ist die Zusicherung, um die es geht: wer bewusst nein sagt, soll
// nicht wie jemand behandelt werden, den niemand gefragt hat. Nur das
// erspart ihm die Erinnerungen und uns die Arbeit.
[$user, $instance] = kuendbareInstanz();
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', $instance->subdomain)
->set('exportWish', false)
->call('cancelPackage');
expect($instance->fresh()->export_wish)->toBeFalse()
->and($instance->fresh()->export_wish)->not->toBeNull();
});
it('kuendigt nicht ohne eine Antwort auf die Frage', function () {
// Eine Frage, die man ueberspringen kann, ist keine Frage — und die
// Kuendigung ist der einzige Moment, in dem der Kunde ohnehin ueber seine
// Daten nachdenkt.
[$user, $instance] = kuendbareInstanz();
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', $instance->subdomain)
->call('cancelPackage')
->assertHasErrors('exportWish');
expect($instance->fresh()->status)->toBe('active');
});
```
Den Helfer `kuendbareInstanz()` baust du nach dem Muster der bestehenden Kündigungs-Prüfungen im Repo — sieh nach, wie die vorhandenen Tests zu `ConfirmCancelPackage` ihre Ausgangslage herstellen, und nimm dieselbe.
- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**
- [ ] **Schritt 3: Die Frage einbauen**
`public ?bool $exportWish = null;` mit `#[Validate('required|boolean')]`, und im Dialog zwei Auswahlfelder — nicht eine Checkbox. **Eine Checkbox hat keinen dritten Zustand**, und „nicht angekreuzt" liesse sich nicht von „nein" unterscheiden; genau diese Unterscheidung trägt aber die ganze Aufgabe.
Der Text sagt, was die Antwort bedeutet: bei Ja bekommt der Kunde am Laufzeitende einen Link, bei Nein wird nichts vorbereitet. Und **beide** Wege sagen, bis wann er selbst herankommt.
- [ ] **Schritt 4: Laufen lassen, grün**
- [ ] **Schritt 5: Festschreiben**
```bash
git commit -F- -- app/Livewire/ConfirmCancelPackage.php resources/views/livewire/confirm-cancel-package.blade.php tests/Feature/Cancellation/CancelAsksAboutExportTest.php lang/de/… lang/en/… <<'MSG'
Beim Kuendigen wird gefragt, ob der Kunde seine Daten will
Der Angelpunkt des ganzen Vorhabens: wer keinen Export braucht, loest keine
Arbeit aus und wartet auf nichts. Zwei Auswahlfelder und keine Checkbox — eine
Checkbox kennt keinen dritten Zustand, und "nicht angekreuzt" waere von "nein"
nicht zu unterscheiden.
MSG
```
---
## Task 3: Die Erinnerung, bevor die Tür zufällt
**Files:**
- Create: `app/Mail/ServiceEndingSoonMail.php`
- Create: `resources/views/mail/service-ending-soon.blade.php`
- Create: `app/Console/Commands/RemindEndingServices.php`
- Modify: `routes/console.php`
- Modify: `app/Services/Mail/MailCatalogue.php`, `MailLane.php`, `MailPreviews.php`
- Create: `lang/{de,en}/service_ending.php`
- Test: `tests/Feature/Cancellation/RemindEndingServicesTest.php`
**Das ist der Teil, der für sich allein den heutigen Schaden behebt.**
- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben**
```php
<?php // tests/Feature/Cancellation/RemindEndingServicesTest.php
use App\Mail\ServiceEndingSoonMail;
use App\Models\Instance;
use Illuminate\Support\Facades\Mail;
it('erinnert sieben Tage vor dem Ende', function () {
Mail::fake();
$instance = Instance::factory()->create([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->addDays(6),
]);
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
Mail::assertQueued(ServiceEndingSoonMail::class);
expect($instance->fresh()->export_reminded_at)->not->toBeNull();
});
it('erinnert nicht zweimal', function () {
// Ein stuendlicher Zeitplan wuerde die Erinnerung sonst jede Stunde
// erneut schicken. Eine Erinnerung im Sechserpack liest sich als Mahnung.
Mail::fake();
Instance::factory()->create([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->addDays(6),
'export_reminded_at' => now()->subHour(),
]);
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
Mail::assertNothingQueued();
});
it('erinnert nicht, wenn das Ende noch weit weg ist', function () {
Mail::fake();
Instance::factory()->create([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->addDays(30),
]);
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
Mail::assertNothingQueued();
});
it('erinnert eine Instanz, die gar nicht gekuendigt wurde, nicht', function () {
Mail::fake();
Instance::factory()->create(['status' => 'active', 'service_ends_at' => now()->addDays(6)]);
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
Mail::assertNothingQueued();
});
it('erinnert auch dann, wenn das Ende schon in wenigen Stunden ist', function () {
// Der wichtigste Fall und der, den ein "genau sieben Tage"-Vergleich
// verfehlt: eine Instanz, die zwischen zwei Laeufen durchgerutscht ist,
// oder eine, die mit kurzer Frist gekuendigt wurde. Lieber spaet erinnern
// als gar nicht — ab dem Laufzeitende kommt der Kunde nicht mehr hinein.
Mail::fake();
Instance::factory()->create([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->addHours(3),
]);
$this->artisan('clupilot:remind-ending-services')->assertSuccessful();
Mail::assertQueued(ServiceEndingSoonMail::class);
});
it('traegt in der Mail das Datum und den Weg zum Selbst-Herunterladen', function () {
$instance = Instance::factory()->create([
'status' => 'cancellation_scheduled',
'service_ends_at' => now()->addDays(6),
'export_wish' => false,
]);
$text = (new ServiceEndingSoonMail($instance))->render();
// Kein roher Schluessel, und die zwei Dinge, um die es geht: ab wann ist
// zu, und wie komme ich vorher an meine Sachen.
expect($text)->not->toContain('service_ending.')
->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL'));
});
```
- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**
- [ ] **Schritt 3: Mail, Befehl und Zeitplan bauen**
Die Auswahl ist **nicht** „genau sieben Tage", sondern **„das Ende liegt in der Zukunft und höchstens sieben Tage entfernt, und es wurde noch nicht erinnert"**. Ein Gleichheitsvergleich auf einen Tag verfehlt jede Instanz, die zwischen zwei Läufen durchrutscht — und der Preis dafür ist, dass jemand ausgesperrt wird, ohne es gewusst zu haben.
Die Mailart gehört in `MailCatalogue` **und** in `MailLane`. Sieh dir an, wie `ServiceEndingSoonMail` dort einzuordnen ist: sie ist keine Werbung und kein Rundschreiben, sondern eine Frist, auf die jemand wartet.
Der Zeitplan läuft **täglich**, nicht stündlich: die Erinnerung hat einen Tag Spielraum, und eine Mail, die um 3 Uhr nachts kommt, liest niemand anders als eine um 9 Uhr.
- [ ] **Schritt 4: Laufen lassen, grün**
- [ ] **Schritt 5: Ganze Suite**
Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test`
- [ ] **Schritt 6: Festschreiben**
```bash
git commit -F- <<'MSG'
Eine Warnung, bevor die eigene Cloud den Kunden aussperrt
Ab dem Laufzeitende zieht EndInstanceService die Adresse ein — der Kunde kommt
ab dem Moment nicht mehr an seine Nextcloud. Wer selbst etwas herunterladen
will, muss es VORHER tun, und das wusste bisher niemand.
Die Auswahl ist bewusst "hoechstens sieben Tage" und nicht "genau sieben Tage":
ein Gleichheitsvergleich verfehlt jede Instanz, die zwischen zwei Laeufen
durchrutscht, und der Preis dafuer waere, dass jemand ausgesperrt wird, ohne es
gewusst zu haben.
MSG
```
---
## Task 4: Der Kunde kann es sich anders überlegen
**Files:**
- Modify: die Portalseite, auf der das Paket und die Kündigung stehen
- Test: `tests/Feature/Cancellation/ChangeExportWishTest.php`
Die Antwort lässt sich bis zum Laufzeitende ändern. Es ist dieselbe Frage, und wer es sich überlegt, soll nicht anrufen müssen.
- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben**
```php
it('laesst den Kunden seine Antwort bis zum Laufzeitende aendern', function () { … });
it('laesst sie nach dem Laufzeitende nicht mehr aendern', function () {
// Danach ist die Instanz `ended`, die Adresse eingezogen, und ob ein
// Export vorbereitet wurde, ist entschieden. Ein Knopf, der dort noch
// etwas verspricht, waere die naechste Attrappe.
});
```
- [ ] **Schritt 25:** wie oben — fehlschlagen sehen, bauen, grün sehen, festschreiben.
---
## Selbstdurchsicht
**Abdeckung von Teil A des Entwurfs**
| Anforderung | Aufgabe |
|---|---|
| Beim Kündigen wird gefragt | 2 |
| Drei Zustände, `null` ≠ „nein" | 1 |
| Antwort später änderbar | 4 |
| Erinnerung vor dem Laufzeitende | 3 |
| Kein Gast angefasst, kein Speicher gebraucht | alle |
**Was Teil A ausdrücklich NICHT tut**
- Kein Export wird erzeugt. Wer „ja" sagt, hat das gesagt — mehr passiert in Teil A nicht.
- Keine Maschine wird abgebaut, kein Archiv angelegt.
- **Der Satz im Kündigungsdialog, der einen fertigen Export verspricht, wird nicht wahr.** Er wird auch nicht geändert: die Frage danebenzustellen ist ehrlich, solange Teil B in Arbeit ist. Wird Teil B nicht gebaut, **muss** der Satz weg — das gehört in den Merge Request.
**Namensgleichheit über die Aufgaben**
`instances.export_wish` (`?bool`) und `instances.export_reminded_at` (`?Carbon`) aus Aufgabe 1 → benutzt in 2, 3, 4.
`ServiceEndingSoonMail(Instance $instance)` aus Aufgabe 3 → in sich geschlossen.
---
## Was Teil B braucht, bevor er geplant werden kann
Damit der nächste Plan nicht wieder rät:
1. **Der Speicherserver steht** — Adresse, Zugangsweg, wie viel Platz.
2. **Es ist bekannt, welches Werkzeug auf den Proxmox-Hosts wirklich vorhanden ist** (`qemu-nbd`, `libguestfs`), und ob es die Host-Übernahme mitbringen soll.
3. **Eine Momentaufnahme ist einmal von Hand gefahren worden** — auf einer echten Instanz, mit dem Ergebnis aufgeschrieben. Ob Proxmox eine laufende Maschine so einfriert, dass das Dateisystem darin brauchbar ist, gehört gemessen und nicht angenommen.
Punkt 3 ist der, der einen Plan sonst zum zweiten Mal an derselben Stelle scheitern liesse.