Fix-Welle: die Kuendigung sagt jedem nur das, was fuer ihn stimmt
Vier Befunde aus dem Gesamt-Review, und alle vier hatten dieselbe Wurzel:
`export_wish` fuehrt drei Zustaende, und jede Stelle, die den Kunden
ansprach, kannte nur zwei.
K1 — Der Streifen im Dashboard trug einen Schalter, und `(bool) null` ist
`false`. Wer vor dieser Ausrollung gekuendigt hat, las unter dem Streifen
„Kein Export gewuenscht" — als waere das seine eigene Antwort. Jetzt stehen
dort dieselben zwei Auswahlfelder wie im Kuendigungsdialog: gleiche Frage,
gleiche Form, und ein unbeantworteter Zustand markiert schlicht keines von
beiden. Der Satz daneben fragt dann, statt zu behaupten, und sagt, was
passiert, wenn die Frage offen bleibt.
K2 — Die Vertragsseite versprach jedem den Export, auch dem, den der Dialog
eine Sekunde vorher mit einem bewussten „Nein" genau dorthin umgeleitet
hatte. Drei Fassungen statt einer, an `export_wish` gebunden. Die Ja-Fassung
verspricht dabei nicht mehr den Export selbst, sondern dass der Wunsch
vermerkt ist und sich jemand meldet — den Export gibt es nicht, und ein
gebundenes, aber weiterhin unhaltbares Versprechen haette den Fehler nur
verschoben.
Dazu der Zustand danach: eine `ended`-Instanz holt Dashboard::render() nicht
mehr, und der Kunde fiel in denselben Zweig wie jemand, der noch nie etwas
bestellt hat — „Ihre Cloud wird eingerichtet." samt „Paket buchen", am Tag,
an dem ihm die Adresse eingezogen wurde. Der Fall hat jetzt seinen eigenen
Kasten, mit dem Datum, an dem das Paket endete.
W1 — Die Erinnerungsmail behauptete im Praesens, wir bereiteten bereits einen
Export vor. Der Satz sagt jetzt, was stimmt. Und die Antwort hatte in der
ganzen Konsole keinen einzigen Leser: ein „Ja" landete in einer Spalte, die
niemand je zu Gesicht bekam. Ueber der Instanzliste steht deshalb ein
Abschnitt „Datenexport bestellt" — wer, und bis wann. Nicht als Plakette in
der Zeile, weil die Liste geblaettert ist und ein alter Eintrag auf Seite acht
saesse; nicht auf der Uebersicht, weil ein Hinweis, den nichts je wieder
abraeumen kann, Moebel waere.
W3 — Die einzige Pruefung zur Anzeige der Antwort konnte nicht fehlschlagen:
`x-ui.switch` rendert beide Woerter und ueberlaesst dem CSS die Auswahl, also
war `assertSee('Kein Export gewuenscht')` bei true, bei false UND bei null
gruen. Nachgewiesen mit einer Wegwerf-Pruefung gegen den alten Streifen:
dreimal derselbe Satz, dreimal gruen. Jetzt drei Pruefungen, je eine pro
Zustand, am `checked`-Attribut der Auswahlfelder.
Zu jedem der vier Punkte wurde der Fix kurz zurueckgedreht und die neue
Zusicherung rot gesehen; die Ergebnisse stehen im Bericht.
Ganze Suite: 2819 gruen, 2 rot — beide fremd. ReadinessPageTest scheitert an
`server.private_key` aus der parallel laufenden Terminal-Arbeit im selben
Baum; HostStepTest ist auf main vorbestehend rot (install-agent.sh traegt
CONTRACT=3, update.sh HOST_STEP_NEEDS=2, beide unveraendert).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
parent
76cf3d0555
commit
c9fef59983
|
|
@ -72,6 +72,39 @@ class Instances extends Component
|
|||
|
||||
return view('livewire.admin.instances', [
|
||||
'instances' => $instances,
|
||||
// Die bestellten Datenexporte, ueber der Liste.
|
||||
//
|
||||
// `export_wish` stand bis hierher in einer Spalte, die kein Mensch
|
||||
// zu Gesicht bekam: ein „Ja" aus dem Kuendigungsdialog hatte in
|
||||
// der ganzen Konsole keinen einzigen Leser, und selbst ein Ausweg
|
||||
// von Hand haette eine Datenbankabfrage gebraucht. Eine Frage
|
||||
// ohne Empfaenger ist keine Frage.
|
||||
//
|
||||
// Bewusst ein eigener Abschnitt und keine Plakette in der Zeile:
|
||||
// die Tabelle darunter ist nach id absteigend seitenweise
|
||||
// geblaettert, ein vor Monaten bestellter Export saesse also auf
|
||||
// Seite acht. Und bewusst hier statt auf der Uebersicht — die
|
||||
// Liste ist die Seite, auf die ein Betreiber nach einer
|
||||
// Kuendigung ohnehin schaut, und ein Hinweis auf der Startseite,
|
||||
// den nichts je wieder abraeumen kann, waere Moebel.
|
||||
//
|
||||
// Nach Laufzeitende sortiert, nicht nach id: der naechste Termin
|
||||
// steht oben. `ended` bleibt drin — dann ist der Export faellig,
|
||||
// nicht erledigt.
|
||||
'exports' => Instance::query()
|
||||
->with('customer')
|
||||
->where('export_wish', true)
|
||||
->whereIn('status', ['cancellation_scheduled', 'ended'])
|
||||
->orderBy('service_ends_at')
|
||||
->get()
|
||||
->map(fn (Instance $i) => [
|
||||
'customer' => $i->customer?->name ?? '—',
|
||||
'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain,
|
||||
// R19: die Wanduhr des Betreibers, nicht die Speicherzone.
|
||||
'ends_at' => $i->service_ends_at?->local()->isoFormat('LL') ?? '—',
|
||||
'over' => $i->service_ends_at !== null && $i->service_ends_at->isPast(),
|
||||
])
|
||||
->all(),
|
||||
// Whether to draw the action column at all. An operator who may not
|
||||
// restart anything should not be reading a column of buttons that
|
||||
// answer them 403.
|
||||
|
|
|
|||
|
|
@ -82,10 +82,44 @@ class Dashboard extends Component
|
|||
// Die Frist, die den Kunden betrifft — wie lange SEIN Zugang noch
|
||||
// steht, nicht wann irgendetwas geloescht wird. Siehe ending().
|
||||
'ending' => $this->ending($instance),
|
||||
// Wann die Laufzeit endete — nur, wenn gar keine Instanz mehr in
|
||||
// Betrieb ist. Siehe endedAt().
|
||||
'endedAt' => $this->endedAt($customer, $instance),
|
||||
'asOf' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wann die Laufzeit dieses Kunden endete — oder null.
|
||||
*
|
||||
* Die Abfrage oben holt nur, was in Betrieb ist; eine `ended`-Instanz
|
||||
* faellt heraus, und der Kunde landete dadurch in demselben Zweig wie
|
||||
* jemand, der noch nie etwas bestellt hat. Er las „Ihre Cloud wird
|
||||
* eingerichtet." und bekam einen Knopf „Paket buchen" — am Tag, an dem
|
||||
* ihm EndInstanceService die Adresse eingezogen hat.
|
||||
*
|
||||
* Nur gefragt, wenn nichts mehr laeuft: wer neben der abgelaufenen
|
||||
* Instanz schon wieder eine aktive hat, ist kein beendeter Kunde, und
|
||||
* seine Seite soll von der alten nichts erzaehlen.
|
||||
*
|
||||
* `service_ends_at` ist dabei nie null — `ended` wird ausschliesslich
|
||||
* ueber EndInstanceService::hasEnded() erreicht, und die verlangt das
|
||||
* Datum. Trotzdem hier verlangt, statt darauf zu vertrauen: ein Satz mit
|
||||
* „endete am —" waere schlimmer als der Zweig darunter.
|
||||
*/
|
||||
private function endedAt(?Customer $customer, ?Instance $instance): ?Carbon
|
||||
{
|
||||
if ($customer === null || $instance !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $customer->instances()
|
||||
->where('status', 'ended')
|
||||
->whereNotNull('service_ends_at')
|
||||
->latest('id')
|
||||
->first()?->service_ends_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aendert die Antwort zum Export, solange sie noch etwas bedeutet: bis
|
||||
* zum Laufzeitende. Wie acknowledgeCredentials() loest diese Methode
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ use Illuminate\Queue\SerializesModels;
|
|||
* Geht an BEIDE Antworten auf die Exportfrage aus ConfirmCancelPackage — wer
|
||||
* beim Kündigen „nein" gesagt hat, braucht die Warnung am dringendsten, weil
|
||||
* für ihn sonst gar nichts vorbereitet wird. Nur wer „ja" gesagt hat, bekommt
|
||||
* zusätzlich den Hinweis, dass ein Export vorbereitet wird (App\Console\
|
||||
* zusätzlich den Hinweis, dass sein Wunsch vermerkt ist und sich jemand meldet
|
||||
* — nicht mehr, denn der Export selbst ist nicht gebaut (App\Console\
|
||||
* Commands\RemindEndingServices entscheidet über das Ob, nicht diese Klasse).
|
||||
*/
|
||||
class ServiceEndingSoonMail extends Mailable implements ShouldQueue
|
||||
|
|
|
|||
|
|
@ -153,6 +153,18 @@ return [
|
|||
'pending_change' => 'Wechsel auf :plan am :date',
|
||||
'by_plan' => 'Nach Paket',
|
||||
'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.',
|
||||
|
||||
// Der einzige Ort, an dem die Antwort aus dem Kündigungsdialog
|
||||
// (instances.export_wish) einen Leser hat — siehe
|
||||
// App\Livewire\Admin\Instances::render(). Der Export selbst ist nicht
|
||||
// gebaut; der Abschnitt sagt genau das, statt eine Warteschlange
|
||||
// vorzutäuschen, die es nicht gibt.
|
||||
'export_wish' => [
|
||||
'title' => 'Datenexport bestellt',
|
||||
'sub' => 'Beim Kündigen ausdrücklich gewünscht. Der Export läuft noch von Hand — hier steht, für wen und bis wann.',
|
||||
'col_ends' => 'Laufzeitende',
|
||||
'over' => 'fällig',
|
||||
],
|
||||
'instances_label' => 'Instanzen',
|
||||
'hosts_sub' => 'Proxmox-Hosts und Kapazität.',
|
||||
'storage_used' => 'Speicher belegt',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ return [
|
|||
'sheet' => 'Betriebsblatt · Stand :when',
|
||||
'title_running' => 'Ihre Cloud läuft.',
|
||||
'title_pending' => 'Ihre Cloud wird eingerichtet.',
|
||||
'title_ended' => 'Ihre Cloud ist beendet.',
|
||||
|
||||
'instance_status' => [
|
||||
'active' => 'Alle Dienste aktiv',
|
||||
|
|
@ -50,6 +51,22 @@ return [
|
|||
'export_hint' => 'Sie können sich das bis zum Laufzeitende jederzeit anders überlegen.',
|
||||
'export_yes' => 'Export gewünscht',
|
||||
'export_no' => 'Kein Export gewünscht',
|
||||
// Der dritte Zustand: `null` heißt, dass diese Frage nie gestellt
|
||||
// wurde (gekündigt vor dieser Ausrollung). Der Streifen fragt dann,
|
||||
// statt eine Antwort zu behaupten, die niemand gegeben hat — und sagt
|
||||
// dazu, was passiert, wenn die Frage offen bleibt.
|
||||
'export_open' => 'Ob Sie einen Datenexport möchten, haben Sie uns noch nicht gesagt. Ohne Antwort bereiten wir keinen vor.',
|
||||
],
|
||||
|
||||
// Die Laufzeit ist vorbei, die Adresse eingezogen (App\Actions\
|
||||
// EndInstanceService). Bis dahin fiel dieser Kunde in denselben Zweig wie
|
||||
// jemand, der noch nie etwas bestellt hat — und las „Ihre Cloud wird
|
||||
// eingerichtet." samt „Paket buchen". Das ist der eine Satz, der dort am
|
||||
// wenigsten stehen darf.
|
||||
'ended' => [
|
||||
'label' => 'Ihre Laufzeit ist beendet',
|
||||
'body' => 'Ihr Paket endete am :date. Seitdem ist der Zugang zu Ihrer Nextcloud geschlossen.',
|
||||
'order_again' => 'Neues Paket buchen',
|
||||
],
|
||||
|
||||
'no_instance_label' => 'Noch keine Instanz',
|
||||
|
|
|
|||
|
|
@ -11,5 +11,11 @@ return [
|
|||
'intro' => 'Ihr Vertrag endet am :date. Ab diesem Zeitpunkt ziehen wir die Adresse Ihrer Cloud ein — Sie kommen dann nicht mehr an Ihre eigenen Dateien.',
|
||||
'how_to_download' => 'Melden Sie sich bis dahin selbst in Ihrer Cloud an und laden Sie herunter, was Sie behalten möchten.',
|
||||
'action' => 'Zur eigenen Cloud',
|
||||
'export_prepared' => 'Da Sie beim Kündigen einen Export gewünscht haben, bereiten wir zusätzlich einen für Sie vor — unabhängig davon lohnt es sich, vorher selbst zu sichern.',
|
||||
// Sagt nur, was wirklich stimmt: der Wunsch steht vermerkt, und es meldet
|
||||
// sich jemand. Der Satz behauptete vorher im Präsens, wir bereiteten
|
||||
// bereits einen Export vor — den gibt es nicht, er hängt an einem
|
||||
// Speicherserver, den es noch nicht gibt. Ein laufender Vorgang, den
|
||||
// niemand ausführt, ist genau die Sorte Zusage, die dieser Kunde erst am
|
||||
// letzten Tag als leer erkennt.
|
||||
'export_prepared' => 'Ihren Wunsch nach einem Datenexport haben wir vermerkt — wir melden uns dazu bei Ihnen. Laden Sie sich unabhängig davon bitte selbst herunter, was Sie behalten möchten.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -58,7 +58,16 @@ return [
|
|||
'no_package' => 'Kein aktives Paket.',
|
||||
'cancel_cta' => 'Paket kündigen',
|
||||
'cancel_scheduled_title' => 'Kündigung vorgemerkt',
|
||||
'cancel_scheduled_body' => 'Ihr Paket endet am :date. Danach erhalten Sie Ihren Datenexport.',
|
||||
// Drei Fassungen für die drei Zustände von instances.export_wish. Ein
|
||||
// einziger Satz stand hier vorher und versprach JEDEM den Export — auch
|
||||
// dem, der im Kündigungsdialog gerade bewusst „Nein" gewählt hatte und
|
||||
// unmittelbar danach auf genau diese Seite umgeleitet wird
|
||||
// (ConfirmCancelPackage::cancelPackage()).
|
||||
'cancel_scheduled_body_yes' => 'Ihr Paket endet am :date. Ihr Wunsch nach einem Datenexport ist vermerkt — wir melden uns dazu bei Ihnen.',
|
||||
'cancel_scheduled_body_no' => 'Ihr Paket endet am :date. Einen Datenexport möchten Sie nicht — laden Sie sich bis dahin selbst herunter, was Sie behalten wollen.',
|
||||
// `null` heißt: nie gefragt (gekündigt vor dieser Ausrollung). Der Satz
|
||||
// fragt dann, statt zu versprechen.
|
||||
'cancel_scheduled_body_open' => 'Ihr Paket endet am :date. Möchten Sie zum Laufzeitende einen Datenexport? Sagen Sie es uns bis dahin — ohne Antwort bereiten wir keinen vor.',
|
||||
|
||||
'cancel_title' => 'Paket kündigen?',
|
||||
'cancel_body' => 'Ihr Paket wird zum Ende der Abrechnungsperiode gekündigt.',
|
||||
|
|
|
|||
|
|
@ -153,6 +153,18 @@ return [
|
|||
'pending_change' => 'Moves to :plan on :date',
|
||||
'by_plan' => 'By plan',
|
||||
'instances_sub' => 'All provisioned cloud instances.',
|
||||
|
||||
// The only place where the answer from the cancellation dialogue
|
||||
// (instances.export_wish) has a reader at all — see
|
||||
// App\Livewire\Admin\Instances::render(). The export itself is not built;
|
||||
// the section says exactly that rather than implying a queue that does
|
||||
// not exist.
|
||||
'export_wish' => [
|
||||
'title' => 'Data export ordered',
|
||||
'sub' => 'Explicitly asked for when cancelling. The export is still done by hand — this says for whom, and by when.',
|
||||
'col_ends' => 'Term ends',
|
||||
'over' => 'due',
|
||||
],
|
||||
'instances_label' => 'instances',
|
||||
'hosts_sub' => 'Proxmox hosts and capacity.',
|
||||
'storage_used' => 'Storage used',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ return [
|
|||
'sheet' => 'Operating sheet · as of :when',
|
||||
'title_running' => 'Your cloud is running.',
|
||||
'title_pending' => 'Your cloud is being set up.',
|
||||
'title_ended' => 'Your cloud has ended.',
|
||||
|
||||
'instance_status' => [
|
||||
'active' => 'All services running',
|
||||
|
|
@ -50,6 +51,20 @@ return [
|
|||
'export_hint' => 'You can change your mind about this at any time until the end of the term.',
|
||||
'export_yes' => 'Export wanted',
|
||||
'export_no' => 'No export wanted',
|
||||
// The third state: `null` means the question was never put (cancelled
|
||||
// before this rollout). The card then ASKS instead of claiming an
|
||||
// answer nobody gave, and says what happens if it stays open.
|
||||
'export_open' => 'You have not told us yet whether you want a data export. Without an answer we prepare none.',
|
||||
],
|
||||
|
||||
// The term is over and the address has been withdrawn (App\Actions\
|
||||
// EndInstanceService). Until now such a customer fell into the same branch
|
||||
// as somebody who never ordered anything — and read "Your cloud is being
|
||||
// set up." with a "Book a package" button under it.
|
||||
'ended' => [
|
||||
'label' => 'Your term has ended',
|
||||
'body' => 'Your package ended on :date. Your Nextcloud has been closed off since then.',
|
||||
'order_again' => 'Book a new package',
|
||||
],
|
||||
|
||||
'no_instance_label' => 'No instance yet',
|
||||
|
|
|
|||
|
|
@ -8,5 +8,9 @@ return [
|
|||
'intro' => 'Your contract ends on :date. From that point we withdraw the address of your cloud — you will no longer be able to reach your own files.',
|
||||
'how_to_download' => 'Sign in to your cloud yourself before then and download whatever you want to keep.',
|
||||
'action' => 'Go to my cloud',
|
||||
'export_prepared' => 'Since you asked for an export when you cancelled, we are additionally preparing one for you — either way, it is worth saving your own copy first.',
|
||||
// Says only what is true: the request is on record and somebody will get
|
||||
// in touch. This sentence used to claim, in the present tense, that we
|
||||
// were already preparing an export — there is none, it hangs on a storage
|
||||
// server that does not exist yet.
|
||||
'export_prepared' => 'We have noted your request for a data export — we will come back to you about it. Either way, please download whatever you want to keep yourself.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -58,7 +58,16 @@ return [
|
|||
'no_package' => 'No active package.',
|
||||
'cancel_cta' => 'Cancel package',
|
||||
'cancel_scheduled_title' => 'Cancellation scheduled',
|
||||
'cancel_scheduled_body' => 'Your package ends on :date. You will then receive your data export.',
|
||||
// Three wordings for the three states of instances.export_wish. A single
|
||||
// sentence used to stand here and promised the export to EVERYBODY —
|
||||
// including the customer who had just deliberately chosen "no" in the
|
||||
// cancellation dialogue and is redirected straight to this page
|
||||
// (ConfirmCancelPackage::cancelPackage()).
|
||||
'cancel_scheduled_body_yes' => 'Your package ends on :date. Your request for a data export is noted — we will come back to you about it.',
|
||||
'cancel_scheduled_body_no' => 'Your package ends on :date. You do not want a data export — download whatever you want to keep before then.',
|
||||
// `null` means never asked (cancelled before this rollout). The sentence
|
||||
// then asks instead of promising.
|
||||
'cancel_scheduled_body_open' => 'Your package ends on :date. Would you like a data export at the end of the term? Tell us before then — without an answer we prepare none.',
|
||||
|
||||
'cancel_title' => 'Cancel package?',
|
||||
'cancel_body' => 'Your package will be cancelled at the end of the billing period.',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,48 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ __('admin.instances_sub') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Bestellte Datenexporte. Nur vorhanden, wenn wirklich einer bestellt
|
||||
wurde — ein dauerhaft leerer Kasten wird nach der zweiten Woche nicht
|
||||
mehr gelesen. Siehe Instances::render(): das ist der einzige Ort in
|
||||
der Konsole, an dem die Antwort aus dem Kuendigungsdialog ueberhaupt
|
||||
sichtbar wird. --}}
|
||||
@if ($exports !== [])
|
||||
<div class="overflow-hidden rounded-lg border border-warning-border bg-surface shadow-xs animate-rise [animation-delay:40ms]">
|
||||
<div class="border-b border-warning-border bg-warning-bg px-4 py-3">
|
||||
<h2 class="flex items-center gap-2 text-sm font-semibold text-warning">
|
||||
<x-ui.icon name="download" class="size-4" />{{ __('admin.export_wish.title') }}
|
||||
<span class="font-mono text-xs font-normal">{{ count($exports) }}</span>
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-warning">{{ __('admin.export_wish.sub') }}</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.col.address') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('admin.export_wish.col_ends') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($exports as $e)
|
||||
<tr class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3 text-body">{{ $e['customer'] }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $e['address'] }}</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="text-body">{{ $e['ends_at'] }}</span>
|
||||
@if ($e['over'])
|
||||
<span class="ml-1.5 font-semibold text-warning">{{ __('admin.export_wish.over') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
{{ __('dashboard.sheet', ['when' => $asOf->local()->locale($locale)->isoFormat('LL, LT')]) }}
|
||||
</p>
|
||||
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
|
||||
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
|
||||
{{ $instance !== null
|
||||
? __('dashboard.title_running')
|
||||
: ($endedAt !== null ? __('dashboard.title_ended') : __('dashboard.title_pending')) }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
|
@ -126,22 +128,62 @@
|
|||
{{-- Aufgabe 4: dieselbe Frage wie beim Kuendigen, hier mit
|
||||
der Moeglichkeit, sie sich anders zu ueberlegen — bis
|
||||
zum Laufzeitende (setExportWish() prueft das selbst
|
||||
nach). --}}
|
||||
nach).
|
||||
|
||||
Bewusst DIESELBEN zwei Auswahlfelder wie im
|
||||
Kuendigungsdialog (livewire/confirm-cancel-package),
|
||||
nicht der Schalter, der hier stand. `export_wish`
|
||||
fuehrt drei Zustaende, ein Schalter kennt zwei — und
|
||||
`(bool) null` ist `false`, also stand unter dem
|
||||
Streifen „Kein Export gewuenscht" als Antwort eines
|
||||
Menschen, den niemand gefragt hatte. Ein
|
||||
unbeantworteter Zustand zeigt hier schlicht keine
|
||||
Auswahl an und fragt darueber im Klartext nach. --}}
|
||||
<div class="mt-4 max-w-sm rounded-lg border border-warning-border bg-surface px-3 py-2.5">
|
||||
<x-ui.switch name="export_wish"
|
||||
wire:click="setExportWish({{ $instance->export_wish ? 'false' : 'true' }})"
|
||||
:checked="(bool) $instance->export_wish"
|
||||
:label="__('dashboard.ending.export_label')"
|
||||
:hint="__('dashboard.ending.export_hint')"
|
||||
:on="__('dashboard.ending.export_yes')"
|
||||
:off="__('dashboard.ending.export_no')" />
|
||||
<p class="text-sm font-medium text-ink">{{ __('dashboard.ending.export_label') }}</p>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-muted">
|
||||
{{ $instance->export_wish === null
|
||||
? __('dashboard.ending.export_open')
|
||||
: __('dashboard.ending.export_hint') }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
|
||||
<input type="radio" name="export_wish" value="1" id="export-wish-yes" @checked($instance->export_wish === true)
|
||||
wire:click="setExportWish(true)" class="size-4 shrink-0 border-line text-ink" />
|
||||
{{ __('dashboard.ending.export_yes') }}
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
|
||||
<input type="radio" name="export_wish" value="0" id="export-wish-no" @checked($instance->export_wish === false)
|
||||
wire:click="setExportWish(false)" class="size-4 shrink-0 border-line text-ink" />
|
||||
{{ __('dashboard.ending.export_no') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($instance === null)
|
||||
@if ($instance === null && $endedAt !== null)
|
||||
{{-- Die abgelaufene Laufzeit — der dritte Fall, den es hier bisher
|
||||
nicht gab. Ein Kunde, dessen Instanz auf `ended` steht, holt
|
||||
Dashboard::render() nicht mehr; er fiel damit in den Zweig
|
||||
darunter und las „Ihre Cloud wird eingerichtet." samt „Paket
|
||||
buchen". Fuer jemanden, dem eben die Adresse eingezogen wurde
|
||||
(EndInstanceService), ist das die falscheste Zeile der Seite.
|
||||
Kein Kunde ohne Paket, sondern einer, dessen Paket vorbei ist —
|
||||
mit dem Datum, an dem es endete. --}}
|
||||
<section class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
|
||||
<h2 class="text-lg font-semibold text-ink">{{ __('dashboard.ended.label') }}</h2>
|
||||
<p class="mt-2 max-w-prose text-md text-muted">
|
||||
{{ __('dashboard.ended.body', ['date' => $endedAt->local()->locale($locale)->isoFormat('LL')]) }}
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap gap-2.5">
|
||||
<x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
|
||||
<x-ui.button variant="secondary" :href="route('order')" wire:navigate>{{ __('dashboard.ended.order_again') }}</x-ui.button>
|
||||
</div>
|
||||
</section>
|
||||
@elseif ($instance === null)
|
||||
{{-- Said plainly rather than papered over with an empty record: a page
|
||||
of dashes reads as broken, and "we are still setting it up" and
|
||||
"you have not ordered yet" are different things. --}}
|
||||
|
|
|
|||
|
|
@ -373,7 +373,18 @@
|
|||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="min-w-0 text-sm text-body">
|
||||
@if ($cancellationScheduled)
|
||||
{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}
|
||||
{{-- An die Antwort des Kunden gebunden, statt jedem
|
||||
denselben Export zu versprechen. Der Kunde, der im
|
||||
Dialog gerade „Nein" gewaehlt hat, wird direkt
|
||||
hierher umgeleitet — und las als Erstes, dass er
|
||||
ihn doch bekommt. Drei Zustaende, drei Saetze; bei
|
||||
`null` (nie gefragt) fragt der Satz, statt zu
|
||||
versprechen. --}}
|
||||
{{ __(match ($instance?->export_wish) {
|
||||
true => 'settings.cancel_scheduled_body_yes',
|
||||
false => 'settings.cancel_scheduled_body_no',
|
||||
default => 'settings.cancel_scheduled_body_open',
|
||||
}, ['date' => $instance?->service_ends_at?->local()->isoFormat('LL')]) }}
|
||||
@elseif ($hasActivePackage)
|
||||
{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@
|
|||
|
||||
{{-- Nur, wer beim Kündigen "ja" gesagt hat. Ein "nein" bedeutet ausdrücklich
|
||||
"bereitet nichts vor" (ConfirmCancelPackage) — ihm hier trotzdem einen
|
||||
Export anzukündigen wäre ein Versprechen, das niemand eingelöst hätte. --}}
|
||||
Export anzukündigen wäre ein Versprechen, das niemand eingelöst hätte.
|
||||
Und `null` heißt "nie gefragt": auch dafür ist der Satz falsch, deshalb
|
||||
der strikte Vergleich auf true. --}}
|
||||
@if ($exportWish === true)
|
||||
<tr><td style="padding:20px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
|
|
|
|||
|
|
@ -87,13 +87,48 @@ it('zeigt den Streifen nicht, solange nicht gekuendigt wurde', function () {
|
|||
|
||||
it('zeigt ihn auch nach dem Laufzeitende nicht mehr', function () {
|
||||
// Danach ist der Zugang zu, und ein Streifen, der eine Restzeit nennt,
|
||||
// wäre schlicht falsch. Was dann gilt, sagt die Seite an anderer Stelle.
|
||||
// wäre schlicht falsch. Was dann gilt, sagt der Kasten aus der Prüfung
|
||||
// darunter — bis zur Fix-Welle behauptete dieser Kommentar das, und die
|
||||
// Seite sagte das Gegenteil.
|
||||
[$user] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
|
||||
|
||||
Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertDontSee(__('dashboard.ending.title'));
|
||||
});
|
||||
|
||||
it('sagt nach dem Laufzeitende, dass die Laufzeit beendet ist', function () {
|
||||
// K2, zweite Hälfte: Dashboard::render() holt eine `ended`-Instanz nicht
|
||||
// mehr, und der Kunde fiel damit in denselben Zweig wie jemand, der noch
|
||||
// nie etwas bestellt hat — „Ihre Cloud wird eingerichtet." samt „Paket
|
||||
// buchen", am Tag, an dem ihm EndInstanceService die Adresse eingezogen
|
||||
// hat. Das ist kein Kunde ohne Paket, sondern einer, dessen Paket vorbei
|
||||
// ist, und die Seite muss das sagen, samt dem Datum.
|
||||
[$user, $instance] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
|
||||
|
||||
Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertSee(__('dashboard.ended.label'))
|
||||
->assertSee($instance->service_ends_at->local()->isoFormat('LL'))
|
||||
->assertDontSee(__('dashboard.title_pending'))
|
||||
->assertDontSee(__('dashboard.no_instance_label'));
|
||||
});
|
||||
|
||||
it('zeigt einem Kunden ohne jede Instanz weiterhin den leeren Zustand', function () {
|
||||
// Die Gegenprobe zur Prüfung darüber: der beendete Fall darf den ehrlichen
|
||||
// Leerzustand nicht verschlucken. Wer nie etwas bestellt hat, bekommt
|
||||
// weiterhin „Noch keine Instanz" — und keine Rede von einer Laufzeit, die
|
||||
// es nie gab.
|
||||
$user = User::factory()->create(['email' => 'ohne@example.test', 'email_verified_at' => now()]);
|
||||
Customer::factory()->create([
|
||||
'email' => 'ohne@example.test',
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertSee(__('dashboard.no_instance_label'))
|
||||
->assertDontSee(__('dashboard.ended.label'));
|
||||
});
|
||||
|
||||
it('nennt keine Restzeit, die schon abgelaufen ist', function () {
|
||||
// Der Fall zwischen den Stühlen: das Laufzeitende ist vorbei, aber der
|
||||
// stündliche Lauf hat die Instanz noch nicht auf `ended` gestellt. Eine
|
||||
|
|
@ -130,12 +165,58 @@ it('zaehlt am letzten Tag in Stunden, nicht mehr in Tagen', function () {
|
|||
->assertSee(trans_choice('dashboard.ending.remaining_hours', 5));
|
||||
});
|
||||
|
||||
it('traegt die Antwort zum Export und laesst sie aendern', function () {
|
||||
/*
|
||||
* Die drei Zustände am Streifen, je einer pro Prüfung.
|
||||
*
|
||||
* Vorher stand hier EINE Prüfung, und sie konnte nicht fehlschlagen:
|
||||
* `x-ui.switch` rendert beide Wörter und überlässt dem CSS, welches sichtbar
|
||||
* ist — `assertSee('Kein Export gewünscht')` war deshalb bei `true`, bei
|
||||
* `false` UND bei `null` grün. Genau deshalb kam K1 durch: `(bool) null` ist
|
||||
* `false`, der Schalter behauptete einem nie gefragten Menschen seine eigene
|
||||
* Antwort, und nichts hielt die drei Zustände auseinander.
|
||||
*
|
||||
* Die Auswahlfelder tragen ihren Zustand dagegen im Markup (`checked`), also
|
||||
* prüft das hier wirklich das, was der Kunde sieht.
|
||||
*/
|
||||
it('zeigt am Streifen ein Ja als Ja', function () {
|
||||
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
|
||||
$instance->update(['export_wish' => true]);
|
||||
|
||||
Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertSeeHtml('id="export-wish-yes" checked')
|
||||
->assertDontSeeHtml('id="export-wish-no" checked');
|
||||
});
|
||||
|
||||
it('zeigt am Streifen ein Nein als Nein', function () {
|
||||
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
|
||||
$instance->update(['export_wish' => false]);
|
||||
|
||||
Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertSeeHtml('id="export-wish-no" checked')
|
||||
->assertDontSeeHtml('id="export-wish-yes" checked');
|
||||
});
|
||||
|
||||
it('behauptet keine Antwort, wenn niemand gefragt wurde', function () {
|
||||
// Der Schaden, gegen den der Kopfkommentar der Wanderung geschrieben
|
||||
// wurde: eine vor dieser Ausrollung gekündigte Instanz trägt `null`, und
|
||||
// der Streifen sagte darunter „Kein Export gewünscht" — eine Aussage über
|
||||
// einen Wunsch, den dieser Mensch nie geäußert hat, dargestellt als seine
|
||||
// eigene Antwort. Keine Auswahl ist markiert, und der Satz daneben fragt.
|
||||
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
|
||||
|
||||
expect($instance->export_wish)->toBeNull();
|
||||
|
||||
Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertDontSeeHtml('id="export-wish-yes" checked')
|
||||
->assertDontSeeHtml('id="export-wish-no" checked')
|
||||
->assertSee(__('dashboard.ending.export_open'));
|
||||
});
|
||||
|
||||
it('laesst die Antwort am Streifen aendern', function () {
|
||||
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
|
||||
$instance->update(['export_wish' => false]);
|
||||
|
||||
Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertSee(__('dashboard.ending.export_no'))
|
||||
->call('setExportWish', true);
|
||||
|
||||
expect($instance->fresh()->export_wish)->toBeTrue();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
<?php // tests/Feature/Cancellation/ExportAnswerHasReadersTest.php
|
||||
|
||||
use App\Livewire\Admin\Instances as AdminInstances;
|
||||
use App\Livewire\Settings;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Wer die Antwort auf die Exportfrage überhaupt zu sehen bekommt.
|
||||
*
|
||||
* Zwei Empfänger, die es vorher nicht gab: der Kunde auf seiner Vertragsseite
|
||||
* — dort stand ein einziger Satz, der JEDEM den Export versprach, auch dem,
|
||||
* der eine Sekunde vorher „Nein" gewählt hatte — und der Betreiber in der
|
||||
* Konsole, wo `export_wish` gar nicht vorkam. Ein Ja landete in einer Spalte
|
||||
* ohne Leser; die Frage im Kündigungsdialog war ein Formular ohne Empfänger.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein Kunde, dessen Kündigung vorgemerkt ist — der Zustand, in dem
|
||||
* ConfirmCancelPackage::cancelPackage() ihn auf die Vertragsseite umleitet.
|
||||
* Eigene Fixture statt der aus DashboardCountdownTest, damit ein gezielter
|
||||
* Lauf über nur diese Datei sie noch findet (siehe tests/Pest.php).
|
||||
*
|
||||
* @return array{0: User, 1: Instance}
|
||||
*/
|
||||
function kundeMitVorgemerkterKuendigung(?bool $exportWunsch): array
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'vertrag@example.test', 'email_verified_at' => now()]);
|
||||
|
||||
$customer = Customer::factory()->create([
|
||||
'email' => 'vertrag@example.test',
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']);
|
||||
|
||||
$instance = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'order_id' => $order->id,
|
||||
'plan' => 'team',
|
||||
'status' => 'cancellation_scheduled',
|
||||
'subdomain' => 'vertrag',
|
||||
'cancel_requested_at' => now()->subDay(),
|
||||
'service_ends_at' => now()->addDays(20),
|
||||
'export_wish' => $exportWunsch,
|
||||
]);
|
||||
|
||||
return [$user, $instance->refresh()];
|
||||
}
|
||||
|
||||
it('verspricht keinen Export, wenn der Kunde gerade Nein gesagt hat', function () {
|
||||
// K2: zwei richtige Bauteile, ein Bildschirm dazwischen, der beiden
|
||||
// widersprach. Der Dialog schreibt korrekt `false` und leitet auf genau
|
||||
// diese Seite um — und das Erste, was der Kunde dort las, war, dass er
|
||||
// den Export bekommt.
|
||||
[$user, $instance] = kundeMitVorgemerkterKuendigung(false);
|
||||
|
||||
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
|
||||
->assertSee(__('settings.cancel_scheduled_body_no', [
|
||||
'date' => $instance->service_ends_at->local()->isoFormat('LL'),
|
||||
]))
|
||||
->assertDontSee(__('settings.cancel_scheduled_body_yes', [
|
||||
'date' => $instance->service_ends_at->local()->isoFormat('LL'),
|
||||
]));
|
||||
});
|
||||
|
||||
it('nennt den Export nur dem, der ihn wollte', function () {
|
||||
[$user, $instance] = kundeMitVorgemerkterKuendigung(true);
|
||||
|
||||
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
|
||||
->assertSee(__('settings.cancel_scheduled_body_yes', [
|
||||
'date' => $instance->service_ends_at->local()->isoFormat('LL'),
|
||||
]));
|
||||
});
|
||||
|
||||
it('fragt auf der Vertragsseite nach, wenn niemand gefragt wurde', function () {
|
||||
// `null` heißt: vor dieser Ausrollung gekündigt. Der Satz fragt dann,
|
||||
// statt zu versprechen — und sagt, was passiert, wenn die Frage offen
|
||||
// bleibt.
|
||||
[$user, $instance] = kundeMitVorgemerkterKuendigung(null);
|
||||
|
||||
Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract'])
|
||||
->assertSee(__('settings.cancel_scheduled_body_open', [
|
||||
'date' => $instance->service_ends_at->local()->isoFormat('LL'),
|
||||
]))
|
||||
->assertDontSee(__('settings.cancel_scheduled_body_yes', [
|
||||
'date' => $instance->service_ends_at->local()->isoFormat('LL'),
|
||||
]));
|
||||
});
|
||||
|
||||
it('zeigt dem Betreiber, wer einen Export bestellt hat und bis wann', function () {
|
||||
// W1: ohne diesen Abschnitt hatte ein „Ja" in der ganzen Konsole keinen
|
||||
// einzigen Leser — ein grep über app/ und die Konsolenansichten fand
|
||||
// ausserhalb dieses Vorhabens keinen Treffer. Beides muss dastehen: wer,
|
||||
// und wann dessen Laufzeit endet.
|
||||
$customer = Customer::factory()->create(['name' => 'Exportkundin GmbH']);
|
||||
$instance = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'cancellation_scheduled',
|
||||
'subdomain' => 'exportkundin',
|
||||
'service_ends_at' => now()->addDays(9),
|
||||
'export_wish' => true,
|
||||
]);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertSee(__('admin.export_wish.title'))
|
||||
->assertSee('Exportkundin GmbH')
|
||||
->assertSee($instance->service_ends_at->local()->isoFormat('LL'));
|
||||
});
|
||||
|
||||
it('fuehrt in der Konsole niemanden auf, der keinen Export wollte', function () {
|
||||
// Ein Nein und ein unbeantwortetes Null sind beide keine Bestellung. Der
|
||||
// Abschnitt erscheint gar nicht erst — ein dauerhaft leerer Kasten wird
|
||||
// nach der zweiten Woche nicht mehr gelesen. Geprüft an der Überschrift
|
||||
// und nicht am Kundennamen: der steht ohnehin in der Instanzliste
|
||||
// darunter, und eine Zusicherung darauf wäre aus dem falschen Grund rot.
|
||||
$customer = Customer::factory()->create(['name' => 'Neinkundin GmbH']);
|
||||
Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'status' => 'cancellation_scheduled',
|
||||
'subdomain' => 'neinkundin',
|
||||
'service_ends_at' => now()->addDays(9),
|
||||
'export_wish' => false,
|
||||
]);
|
||||
Instance::factory()->create([
|
||||
'status' => 'cancellation_scheduled',
|
||||
'subdomain' => 'niegefragt',
|
||||
'service_ends_at' => now()->addDays(9),
|
||||
]);
|
||||
|
||||
Livewire::actingAs(admin(), 'operator')->test(AdminInstances::class)
|
||||
->assertDontSee(__('admin.export_wish.title'));
|
||||
});
|
||||
|
|
@ -120,3 +120,20 @@ it('trägt in der Mail das Datum und den Weg zum Selbst-Herunterladen', function
|
|||
->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL'))
|
||||
->and($text)->toContain('https://'.$instance->address(ProvisioningSettings::dnsZone()));
|
||||
});
|
||||
|
||||
it('behauptet keinen Vorgang, den niemand ausfuehrt', function () {
|
||||
// W1: der Satz stand im Präsens und sagte, wir bereiteten bereits einen
|
||||
// Export vor. Es gibt keinen — er hängt an einem Speicherserver, den es
|
||||
// noch nicht gibt. Was stimmt, ist: der Wunsch ist vermerkt, und es
|
||||
// meldet sich jemand.
|
||||
$instance = Instance::factory()->create([
|
||||
'status' => 'cancellation_scheduled',
|
||||
'service_ends_at' => now()->addDays(6),
|
||||
'export_wish' => true,
|
||||
]);
|
||||
|
||||
$text = (new ServiceEndingSoonMail($instance))->render();
|
||||
|
||||
expect($text)->toContain('vermerkt')
|
||||
->and($text)->not->toContain('bereiten wir zusätzlich einen für Sie vor');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue