diff --git a/VERSION b/VERSION index 1cb6193..b2be32b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.37 +1.3.38 diff --git a/database/migrations/2026_07_31_120000_seed_the_answers_that_are_always_needed.php b/database/migrations/2026_07_31_120000_seed_the_answers_that_are_always_needed.php new file mode 100644 index 0000000..3e9a1a9 --- /dev/null +++ b/database/migrations/2026_07_31_120000_seed_the_answers_that_are_always_needed.php @@ -0,0 +1,324 @@ + + */ + 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(); + } + } +}; diff --git a/tests/Feature/Admin/MailTemplateTest.php b/tests/Feature/Admin/MailTemplateTest.php index 02d547b..6180a43 100644 --- a/tests/Feature/Admin/MailTemplateTest.php +++ b/tests/Feature/Admin/MailTemplateTest.php @@ -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); +});