Ship the thirteen answers this business actually sends
tests / pest (push) Failing after 8m30s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details

An empty template list is a feature nobody starts using: writing the first
one costs the same as writing the mail, so the operator writes the mail.
These are the questions that come up in a managed-Nextcloud business —
enquiry received, migration question, migration quote, cloud being
prepared, credentials asked for again, storage filling up, package change,
incident update, incident resolved, maintenance announced, payment
reminder, cancellation confirmed, request closed.

Every figure and every date is a gap, never a number. A seeded "innerhalb
von 24 Stunden" is a promise put in the operator's mouth that nobody
agreed to, and a seeded price is wrong for every customer but one. Nothing
here says a migration is possible before somebody has looked at the source
system either — that was the whole point of the wording on the website.
There is a test that fails if a shipped template ever grows a duration or
an amount.

German only, deliberately: these are texts written TO customers, not
interface labels, and the audience is this company's customers. An
operator serving another market writes their own; the mechanism and the
page are already translated.

firstOrCreate on the name, so re-running changes nothing and an operator
who has edited or retired one keeps their version — a migration must never
overwrite text somebody worked on. The rollback deletes only rows still
identical to what was seeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus v1.3.38
nexxo 2026-07-29 22:44:31 +02:00
parent 623acb3702
commit 7fd45b01fb
3 changed files with 401 additions and 6 deletions

View File

@ -1 +1 @@
1.3.37
1.3.38

View File

@ -0,0 +1,324 @@
<?php
use App\Models\MailTemplate;
use Illuminate\Database\Migrations\Migration;
/**
* The dozen answers this business actually sends, ready to edit.
*
* An empty template list is a feature nobody starts using: writing the first one
* costs the same as writing the mail, so the operator writes the mail. These are
* the questions that come up in a managed-Nextcloud business, in the register
* the rest of the product uses Sie, plain, no superlatives.
*
* ## What is deliberately left blank
*
* Every figure and every date is a gap (), never a number. A migration that
* wrote "wir melden uns innerhalb von 24 Stunden" would put a promise in the
* operator's mouth that nobody agreed to, and one that quoted a price would
* quote it wrong for every customer but one.
*
* The same goes for what this platform cannot promise: nothing here says a
* migration is possible before somebody has looked at the source system, and
* nothing states a delivery time for a machine that has to be bought first
* both are the operator's own decisions, made per case.
*
* ## German only
*
* These are texts written TO customers, not interface labels: the audience is
* this company's customers, who are German-speaking. An operator serving another
* market writes their own; the mechanism is the same and the page is translated.
*
* ## Idempotent by name
*
* firstOrCreate, so re-running this on a database that already has them changes
* nothing and an operator who has since edited or retired one keeps their
* version. A migration must never overwrite text somebody worked on.
*/
return new class extends Migration
{
/**
* name, subject, body in the order an operator meets them.
*
* @return array<int, array{0: string, 1: string, 2: string}>
*/
private function templates(): array
{
return [
[
'Anfrage bestätigen',
'Ihre Anfrage ist angekommen',
<<<'TXT'
Guten Tag {{contact}},
vielen Dank für Ihre Nachricht sie ist bei uns angekommen und wir sehen uns das an.
Sobald ich etwas Belastbares sagen kann, melde ich mich mit einer konkreten Antwort. Falls Sie in der Zwischenzeit noch etwas ergänzen möchten: einfach auf diese E-Mail antworten.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Datenübernahme — Rückfrage',
'Ihre Datenübernahme: zwei Fragen vorab',
<<<'TXT'
Guten Tag {{contact}},
zu Ihrer Frage nach der Übernahme Ihrer Daten: ob und wie das geht, hängt davon ab, woher sie kommen. Damit ich Ihnen eine ehrliche Antwort geben kann, brauche ich zwei Angaben:
1. Welches System setzen Sie heute ein (Name und, wenn bekannt, Version)?
2. Wie viele Daten sind es etwa, und wie viele Benutzerkonten?
Sobald ich das weiß, sehe ich mir die Ausgangslage an und sage Ihnen, ob eine Übernahme möglich ist und was sie kostet bevor irgendetwas anfällt.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Datenübernahme — Angebot',
'Ihre Datenübernahme: mein Vorschlag',
<<<'TXT'
Guten Tag {{contact}},
ich habe mir Ihre Ausgangslage angesehen. Die Übernahme ist möglich, und zwar so:
Aufwand nach meiner Einschätzung: Stunden zu pro Stunde, zzgl. USt. Die Rechnung kommt nach der Arbeit und über die tatsächlich angefallenen Stunden.
Was in dieser Zeit passiert und was Sie in dieser Zeit nicht tun sollten, stimmen wir vorher ab. Ihre bestehende Installation bleibt unberührt, bis Sie bestätigen, dass alles vollständig ist.
Wenn das für Sie passt, sagen Sie mir kurz Bescheid, dann vereinbaren wir einen Termin.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Cloud wird vorbereitet',
'Ihre Cloud wird eingerichtet',
<<<'TXT'
Guten Tag {{contact}},
Ihre Bestellung von {{plan}} ist bei uns eingegangen und bezahlt vielen Dank.
Wir richten dafür gerade eine Maschine ein. Sobald sie bereitsteht, entsteht Ihre Cloud automatisch und Sie erhalten Ihre Zugangsdaten per E-Mail. Sie müssen dafür nichts tun.
Falls Sie vorher noch etwas wissen möchten, antworten Sie einfach auf diese E-Mail.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Zugangsdaten nachgefragt',
'Ihre Zugangsdaten zu {{instance}}',
<<<'TXT'
Guten Tag {{contact}},
Ihre Cloud erreichen Sie unter {{instance}}.
Die Zugangsdaten haben wir bei der Einrichtung an {{email}} geschickt. Falls die E-Mail nicht angekommen ist, sehen Sie bitte zuerst im Spam-Ordner nach und wenn sie dort auch nicht liegt, sagen Sie mir Bescheid: dann setze ich das Kennwort neu und zeige es Ihnen einmalig an.
Aus Sicherheitsgründen können wir bestehende Kennwörter nicht auslesen, auch wir nicht.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Speicher wird knapp',
'Ihr Speicher in {{plan}} füllt sich',
<<<'TXT'
Guten Tag {{contact}},
ein Hinweis, bevor es unangenehm wird: der Speicher in Ihrem Paket {{plan}} ist zu % belegt.
Sie haben zwei Möglichkeiten:
Speicherpaket dazubuchen, sofort wirksam, monatlich kündbar.
In das nächstgrößere Paket wechseln; das rechnet sich ab etwa GB.
Ihre Daten bleiben in beiden Fällen, wo sie sind es gibt keinen Umzug und keine Unterbrechung. Sagen Sie mir kurz, was Ihnen lieber ist, dann richte ich es ein.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Paketwechsel bestätigen',
'Ihr Wechsel auf …',
<<<'TXT'
Guten Tag {{contact}},
Ihr Wechsel ist eingerichtet: Sie sind ab sofort auf , bisher {{plan}}.
Was sich ändert:
Der Differenzbetrag wird anteilig verrechnet, Sie finden ihn auf der nächsten Rechnung. Ihre Daten, Benutzer und Freigaben bleiben unverändert.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Störung — Zwischenstand',
'Störung an Ihrer Cloud: Zwischenstand',
<<<'TXT'
Guten Tag {{contact}},
zu der Störung an {{instance}}: Sie hatten recht, dass etwas nicht in Ordnung war, und ich sage Ihnen offen, wo wir stehen.
Was passiert ist:
Was das für Ihre Daten bedeutet:
Was wir bereits getan haben:
Wie es weitergeht:
Ich melde mich wieder, sobald sich etwas ändert nicht erst, wenn alles fertig ist.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Störung — behoben',
'Störung behoben',
<<<'TXT'
Guten Tag {{contact}},
die Störung an {{instance}} ist behoben. Ihre Cloud ist wieder normal erreichbar.
Ursache:
Betroffener Zeitraum:
Was wir geändert haben, damit es nicht wieder vorkommt:
Ihre Daten waren zu keinem Zeitpunkt falls Sie eine Bestätigung für Ihre eigene Dokumentation brauchen, schicke ich Ihnen das Protokoll gerne zu.
Entschuldigen Sie die Unterbrechung.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Wartung angekündigt',
'Geplante Wartung an Ihrer Cloud',
<<<'TXT'
Guten Tag {{contact}},
wir spielen an Ihrer Cloud {{instance}} Aktualisierungen ein.
Zeitraum:
Was Sie merken: die Cloud ist in diesem Fenster kurz nicht erreichbar. Laufende Synchronisierungen setzen danach von selbst fort.
Was Sie tun müssen: nichts.
Wenn der Zeitraum für Sie ungünstig liegt, sagen Sie mir Bescheid wir verschieben ihn.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Zahlungserinnerung',
'Offene Rechnung zu Ihrem Paket {{plan}}',
<<<'TXT'
Guten Tag {{contact}},
zu Ihrer Rechnung über {{amount}}: bei uns ist noch keine Zahlung eingegangen. Vermutlich ist sie einfach untergegangen das passiert.
Falls Sie die Rechnung nicht mehr finden, schicke ich sie gerne erneut. Und wenn es einen anderen Grund gibt, sagen Sie mir Bescheid: an einer Lösung ist mir mehr gelegen als an einer Mahnung.
Ihre Cloud läuft unverändert weiter.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Kündigung bestätigen',
'Ihre Kündigung: bestätigt',
<<<'TXT'
Guten Tag {{contact}},
Ihre Kündigung von {{plan}} ist eingetragen. Zum endet die Laufzeit; bis dahin läuft alles unverändert weiter.
Damit Sie Ihre Daten mitnehmen können: bis zum Ende der Laufzeit können Sie alles über Ihre Cloud herunterladen. Wenn Sie einen vollständigen Export in einem Stück brauchen, sagen Sie mir Bescheid das richte ich ein.
Danach werden Ihre Daten nach gelöscht, samt Sicherungen. Das ist endgültig; bitte prüfen Sie vorher, dass Sie vollständig sind.
Danke für die Zeit mit uns.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
[
'Anfrage abschließen',
'Ihre Anfrage: erledigt',
<<<'TXT'
Guten Tag {{contact}},
das ist erledigt:
Falls doch noch etwas offen ist, antworten Sie einfach auf diese E-Mail dann sehe ich es mir noch einmal an.
Mit freundlichen Grüßen
{{operator}}
{{company}}
TXT,
],
];
}
public function up(): void
{
$sort = 10;
foreach ($this->templates() as [$name, $subject, $body]) {
// firstOrCreate on the name: re-running changes nothing, and an
// operator who has edited or retired one keeps their version. A
// migration must never overwrite text somebody worked on.
MailTemplate::query()->firstOrCreate(
['name' => $name],
[
'subject' => $subject,
// Heredoc keeps the indentation of the file; the mail must
// not arrive indented by twenty spaces.
'body' => $body,
'sort' => $sort,
'active' => true,
],
);
$sort += 10;
}
}
public function down(): void
{
// Only the ones nobody has touched. An operator who edited a template
// has made it theirs, and a rollback that deletes their work would be
// the worst kind of surprise.
foreach ($this->templates() as [$name, $subject, $body]) {
MailTemplate::query()
->where('name', $name)
->where('subject', $subject)
->where('body', $body)
->delete();
}
}
};

View File

@ -124,16 +124,20 @@ it('names the address of the cloud, where there is one', function () {
// ---- Managing them ----
it('creates a template and offers it when writing', function () {
// Counted against what is already there: the installation ships a set, so an
// absolute count would only be testing the size of that set.
$before = MailTemplate::query()->count();
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailTemplates::class)
->set('name', 'Datenübernahme')
->set('name', 'Eigene Antwort')
->set('subject', 'Ihre Anfrage')
->set('body', 'Guten Tag {{contact}},')
->call('create')
->assertHasNoErrors();
expect(MailTemplate::query()->count())->toBe(1)
->and(MailTemplate::query()->usable()->count())->toBe(1);
expect(MailTemplate::query()->count())->toBe($before + 1)
->and(MailTemplate::query()->usable()->pluck('name'))->toContain('Eigene Antwort');
});
it('retires a template instead of deleting it', function () {
@ -146,8 +150,9 @@ it('retires a template instead of deleting it', function () {
->call('toggleActive', $template->uuid);
expect($template->fresh()->active)->toBeFalse()
->and(MailTemplate::query()->count())->toBe(1)
->and(MailTemplate::query()->usable()->count())->toBe(0);
// The row is still there — retired, not deleted.
->and(MailTemplate::query()->where('name', 'Alt')->exists())->toBeTrue()
->and(MailTemplate::query()->usable()->pluck('name'))->not->toContain('Alt');
});
it('edits a template in a modal, never in the row', function () {
@ -231,3 +236,69 @@ it('sends what the operator saw, not what the template said', function () {
expect(SentMail::query()->where('customer_id', $customer->id)->latest('id')->first()->body)
->toBe('Aus der Vorlage. Und mein eigener Satz.');
});
// ---- The set that ships with the installation ----
it('ships the answers this business actually needs', function () {
// An empty list is a feature nobody starts using: writing the first template
// costs the same as writing the mail, so the operator writes the mail.
$names = MailTemplate::query()->usable()->pluck('name');
expect($names->count())->toBeGreaterThanOrEqual(10)
->and($names)->toContain('Datenübernahme — Rückfrage')
->and($names)->toContain('Störung — behoben')
->and($names)->toContain('Kündigung bestätigen');
});
it('promises nothing in a shipped template that nobody agreed to', function () {
// Every figure and every date is a gap, never a number: a seeded "innerhalb
// von 24 Stunden" is a promise put in the operator's mouth, and a seeded
// price is wrong for every customer but one.
foreach (MailTemplate::query()->get() as $template) {
expect($template->body)
->not->toMatch('/\b\d+\s*(Stunden|Werktagen|Tagen)\b/')
->not->toMatch('/\b\d+[,.]\d{2}\s*€/');
}
});
it('renders every shipped template without leaving a placeholder behind', function () {
// A token that the renderer does not know stays visible on purpose — so a
// shipped template must not contain one, or every operator meets a typo we
// wrote for them.
$customer = Customer::factory()->create(['name' => 'Beispiel GmbH', 'contact_name' => 'Bea Berger']);
Subscription::factory()->plan('start')->create(['customer_id' => $customer->id, 'status' => 'active']);
Instance::factory()->create(['customer_id' => $customer->id, 'subdomain' => 'beispiel', 'status' => 'active']);
$renderer = app(MailTemplateRenderer::class);
foreach (MailTemplate::query()->get() as $template) {
$filled = $renderer->render($template, $customer, operator('Owner'));
expect($filled['subject'])->not->toContain('{{', $template->name)
->and($filled['body'])->not->toContain('{{', $template->name);
}
});
it('stores a shipped body without the file own indentation in it', function () {
// Heredoc keeps the indentation of the source file, and a mail that arrives
// indented by twenty spaces looks like a mistake — because it is one.
foreach (MailTemplate::query()->get() as $template) {
expect($template->body)->not->toStartWith(' ')
->and($template->body)->not->toContain("\n ");
}
});
it('leaves an edited template alone when the seed runs again', function () {
// firstOrCreate on the name. A migration that overwrites text somebody
// worked on is the worst kind of surprise.
$template = MailTemplate::query()->where('name', 'Anfrage bestätigen')->firstOrFail();
$template->update(['body' => 'Meine eigene Fassung.']);
MailTemplate::query()->firstOrCreate(
['name' => 'Anfrage bestätigen'],
['subject' => 'Anders', 'body' => 'Anders', 'sort' => 0, 'active' => true],
);
expect($template->fresh()->body)->toBe('Meine eigene Fassung.')
->and(MailTemplate::query()->where('name', 'Anfrage bestätigen')->count())->toBe(1);
});