Keep a register of what was sent, and answer the customer from here

Three complaints, one shape: the console knew things and could not act on
them, so the work happened somewhere else and left no trace.

The header said "4 Hinweis(e)" and led nowhere. Whoever read it had to
know that the list was a card further down the same page, and then go
looking for the host or the failed run by hand. The count is a link now,
every notice carries the page that shows the thing it is about, and the
plural is a word rather than a bracket.

Nothing recorded what this installation had sent. "Hat der Kunde die
Zugangsdaten je bekommen, und wann?" was answered on the mail server — a
different machine, a different program, and nothing an operator can put
in front of somebody who says nothing ever arrived. Every mail now writes
a row: when, to whom, which mailable, and the customer it belongs to.
Written from MessageSent, so a row means the transport accepted it; that
is the strongest thing an application can honestly claim, and a column
called "delivered" would be pretending otherwise. Verification and reset
mails are left out on purpose — they go to unconfirmed addresses and
anybody who can type into a form can send them, so recording them would
hand the register to whoever wants to fill it.

And the customer list was the end of the road. There is a page behind it
now: what they bought, what they asked in their own words, what we sent
them, and a box to write the next message. It goes out from the support
mailbox and is recorded on the way — with its body, because an operator
typed it and "what exactly did I write to them in March" is a question
the mail server's log cannot answer either. Answering a request from here
closes it, which is the reason to answer from here at all: answering from
a mail client leaves it open for ever, because nothing tells it otherwise.

Found on the way, by a test that sent a real mail rather than asserting it
was queued: resources/views/mail/reset-password.blade.php was missing its
closing </x-mail.layout>. The component's contents were never terminated,
the layout's own @if ran off the end of the file, and the mail raised a
Blade syntax error instead of rendering. Password reset has therefore
never delivered a link since it was built. Closed, plus a test that
renders it and one that counts opening against closing tags across every
mail view.

Reading INCOMING mail in the console is not in this: it needs the mailbox
polled over IMAP, which is a credential and a dependency. Requests filed
through the portal appear here in full; a customer who writes by mail
instead still has to be read in the mail client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-29 21:02:36 +02:00
parent 559a466630
commit 0e25fe88d4
28 changed files with 1124 additions and 20 deletions

View File

@ -1 +1 @@
1.3.32 1.3.33

View File

@ -0,0 +1,89 @@
<?php
namespace App\Listeners;
use App\Models\Customer;
use App\Models\SentMail;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Write every outgoing mail into the register.
*
* On MessageSent rather than MessageSending: a row here means the transport
* accepted the message. Recording the intention instead would fill the register
* with mails that never left, which is worse than no register an operator
* would show a customer a line proving something was sent that was not.
*
* One listener for every mailable in the application, rather than a call in
* each of them. A register that has to be remembered at each call site is a
* register with holes in it, and the holes are exactly the mails nobody thought
* about.
*/
class RecordSentMail
{
/**
* Mails that are not worth a row.
*
* The verification and reset mails go to an address that has not been
* confirmed to belong to anybody yet, and both are sent in bulk by anyone
* who can type an address into a form. Recording them would let a stranger
* write lines into the operator's register at will.
*/
private const IGNORED = [
\App\Mail\VerifyEmailMail::class,
\App\Mail\ResetPasswordMail::class,
];
public function handle(MessageSent $event): void
{
try {
$this->record($event);
} catch (Throwable $e) {
// A register that cannot be written must never be the reason a mail
// fails. The send already happened by the time this runs, and
// throwing here would turn a delivered message into a failed job
// that gets delivered a second time on retry.
Log::error('Could not record a sent mail', ['exception' => $e]);
}
}
private function record(MessageSent $event): void
{
$mailable = $event->data['__laravel_mailable'] ?? null;
if ($mailable !== null && in_array($mailable, self::IGNORED, true)) {
return;
}
$message = $event->message;
$to = collect($message->getTo())->map(fn ($address) => $address->getAddress())->all();
if ($to === []) {
return;
}
// One row per recipient. A mail to three addresses is three deliveries,
// and an operator asking "what did this customer get" must not have to
// read someone else's row to find out.
foreach ($to as $address) {
SentMail::create([
'customer_id' => $this->customerFor($address)?->id,
'to' => $address,
'subject' => (string) $message->getSubject(),
'mailable' => $mailable,
// Set by the console when an operator writes one themselves;
// see Admin\CustomerMessage, which fills the body in afterwards.
'from_operator' => false,
'sent_at' => now(),
]);
}
}
/** Whose mail this was, where the address belongs to somebody we know. */
private function customerFor(string $address): ?Customer
{
return Customer::query()->where('email', $address)->first();
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Livewire\Admin;
use App\Mail\OperatorMessageMail;
use App\Models\Customer;
use App\Models\SentMail;
use App\Models\SupportRequest;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* One customer, everything about them, and a box to write to them.
*
* The console had a customer LIST and nothing behind it: an operator answering
* a question had the name in one window, the mail client in another, and no
* record anywhere afterwards that an answer had been given at all. So the
* conversation happened outside the system that knows who the customer is.
*
* This page is the other half. What they bought, what they asked, what we sent
* them and a field to write the next message, which goes out as mail from the
* support mailbox and is written into the register on its way.
*
* Reading their INCOMING mail is not here yet. That needs the mailbox itself
* polled over IMAP, which is a mail-server credential and a dependency; until
* then the requests a customer files through the portal appear here in full,
* and a mail they write instead still has to be read in the mail client.
*/
#[Layout('layouts.admin')]
class CustomerDetail extends Component
{
public Customer $customer;
/** What the operator is writing. Empty is the normal state of this page. */
#[Validate('required|string|max:200')]
public string $subject = '';
#[Validate('required|string|min:3|max:5000')]
public string $body = '';
/**
* The request being answered, if this message is an answer to one.
*
* Answering marks it answered which is the whole reason to answer from
* here rather than from a mail client, where the request stays open for
* ever because nothing told it otherwise.
*/
public ?string $answering = null;
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$this->customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
}
/**
* Start an answer to one request: subject prefilled, the request remembered.
*/
public function answer(string $uuid): void
{
$this->authorize('customers.manage');
$request = $this->customer->supportRequests()->where('uuid', $uuid)->first();
if ($request === null) {
return;
}
$this->answering = $request->uuid;
$this->subject = __('customer_message.re', ['subject' => $request->subject]);
$this->body = '';
}
public function cancelAnswer(): void
{
$this->answering = null;
$this->subject = '';
$this->body = '';
}
/**
* Send it, record it, and close the request it answers.
*
* The register row is written here rather than left to the MessageSent
* listener, because this is the one mail whose BODY is worth keeping: an
* operator typed it, and "what exactly did I write to them in March" is a
* question the mail server's log cannot answer either.
*/
public function send(): void
{
$this->authorize('customers.manage');
$data = $this->validate();
Mail::to($this->customer->email)->send(
new OperatorMessageMail($this->customer, $data['subject'], $data['body'])
);
// The listener has already written a row for the delivery — it fires on
// every mail this application sends. This adds what only the console
// knows: that a person wrote it, and what they wrote.
SentMail::query()
->where('customer_id', $this->customer->id)
->where('to', $this->customer->email)
->latest('id')
->limit(1)
->update(['from_operator' => true, 'body' => $data['body']]);
if ($this->answering !== null) {
$this->customer->supportRequests()
->where('uuid', $this->answering)
->update(['status' => 'answered', 'answered_at' => now()]);
}
$this->cancelAnswer();
$this->dispatch('notify', message: __('customer_message.sent'));
}
public function render()
{
$this->authorize('customers.manage');
return view('livewire.admin.customer-detail', [
'requests' => $this->customer->supportRequests()
->with('instance')
->latest('id')
->get(),
// The whole conversation as it left here, newest first. Capped:
// this is a page, not an export, and a customer of three years has
// a few hundred rows nobody scrolls.
'mails' => SentMail::query()
->where('customer_id', $this->customer->id)
->latest('sent_at')
->limit(50)
->get(),
'instance' => $this->customer->instances()->latest('id')->first(),
'subscription' => $this->customer->subscriptions()->latest('id')->first(),
]);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Admin;
use App\Models\SentMail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Everything this installation has sent, newest first.
*
* "Hat der Kunde die Zugangsdaten je bekommen, und wann?" used to be answered
* on the mail server a different machine, a different program, and nothing an
* operator can put in front of somebody who says nothing ever arrived.
*
* A row means the transport accepted the message. What the receiving server did
* with it afterwards is not ours to know, and a column claiming otherwise would
* be worse than none.
*/
#[Layout('layouts.admin')]
class MailLog extends Component
{
use WithPagination;
#[Url]
public string $search = '';
public function mount(): void
{
$this->authorize('customers.manage');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function render()
{
$this->authorize('customers.manage');
$mails = SentMail::query()
->with('customer')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('to', 'like', $term)->orWhere('subject', 'like', $term));
})
->latest('sent_at')
->latest('id')
->paginate(30);
return view('livewire.admin.mail-log', ['mails' => $mails]);
}
}

View File

@ -297,11 +297,23 @@ class Overview extends Component
$failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count(); $failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count();
if ($failedRuns > 0) { if ($failedRuns > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns])]; $notices[] = [
'level' => 'warning',
'text' => __('admin.notice.failed_runs', ['n' => $failedRuns]),
// Every notice carries the page that shows the thing it is
// about. A warning an operator cannot act on from where they
// read it is a warning they go looking for by hand, and the
// count in the header was not even a link to the list.
'route' => 'admin.provisioning',
];
} }
foreach (Host::query()->where('status', 'error')->pluck('name') as $name) { foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])]; $notices[] = [
'level' => 'warning',
'text' => __('admin.notice.host_error', ['host' => $name]),
'route' => 'admin.hosts',
];
} }
$silent = Host::query() $silent = Host::query()
@ -311,9 +323,13 @@ class Overview extends Component
->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES))) ->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES)))
->pluck('name'); ->pluck('name');
foreach ($silent as $name) { foreach ($silent as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_silent', [ $notices[] = [
'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES, 'level' => 'warning',
])]; 'text' => __('admin.notice.host_silent', [
'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES,
]),
'route' => 'admin.hosts',
];
} }
// Distinct instances, not targets: one instance can be watched by // Distinct instances, not targets: one instance can be watched by
@ -326,7 +342,11 @@ class Overview extends Component
->distinct() ->distinct()
->count('instance_id'); ->count('instance_id');
if ($down > 0) { if ($down > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])]; $notices[] = [
'level' => 'warning',
'text' => __('admin.notice.monitoring_down', ['n' => $down]),
'route' => 'admin.instances',
];
} }
// Capacity, before an order finds out. // Capacity, before an order finds out.
@ -340,7 +360,7 @@ class Overview extends Component
// the decision; "buy a server big enough for the package I am selling" // the decision; "buy a server big enough for the package I am selling"
// is. // is.
foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) { foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.no_capacity', [ $notices[] = ['level' => 'warning', 'route' => 'admin.capacity', 'text' => __('admin.notice.no_capacity', [
'plan' => $plan['name'], 'plan' => $plan['name'],
'needs' => $plan['needs'], 'needs' => $plan['needs'],
'largest' => $plan['largest'], 'largest' => $plan['largest'],

View File

@ -0,0 +1,51 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Models\Customer;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* A message an operator wrote, in the console, to one customer.
*
* Everything else this application sends is a mail the SYSTEM decided to send:
* an invoice, a maintenance window, credentials. This is the one a person
* writes the answer to "können Sie unsere Daten übernehmen?" and it exists
* so that answering does not mean opening a mail client, finding the address,
* and leaving no trace in the console that it ever happened.
*
* Sent from the support mailbox, because that is where the customer's reply
* should land.
*/
class OperatorMessageMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
public function __construct(
public Customer $customer,
public string $subjectLine,
public string $bodyText,
) {
$this->mailer('cp_'.MailPurpose::SUPPORT);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SUPPORT, $this->subjectLine);
}
public function content(): Content
{
return new Content(view: 'mail.operator-message', with: [
'name' => $this->customer->name,
'bodyText' => $this->bodyText,
]);
}
}

View File

@ -118,6 +118,22 @@ class Customer extends Model
return $this->hasMany(Instance::class); return $this->hasMany(Instance::class);
} }
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
public function supportRequests(): HasMany
{
return $this->hasMany(SupportRequest::class);
}
/** Everything this installation has sent to them, newest first at the call site. */
public function sentMails(): HasMany
{
return $this->hasMany(SentMail::class);
}
/** /**
* True if this address already belongs to a customer identity R21's * True if this address already belongs to a customer identity R21's
* other direction, checked before an `operators` row is created or * other direction, checked before an `operators` row is created or

56
app/Models/SentMail.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One mail this installation sent.
*
* The register exists so that "did they ever get it, and when?" has an answer
* inside the console. Before this, the only record was whatever `sent_at`
* column the feature in question happened to keep one per feature, only for
* the features that thought of it and everything else lived in the mail
* server's log on another machine.
*
* Written from the MessageSent event, so a row here means the transport
* accepted the message. That is the strongest claim an application can honestly
* make; what the receiving server did with it afterwards is not ours to know,
* and a column called `delivered` would be pretending otherwise.
*/
class SentMail extends Model
{
use HasFactory;
use HasUuid;
protected $fillable = [
'customer_id', 'to', 'subject', 'mailable', 'from_operator', 'body', 'sent_at',
];
protected function casts(): array
{
return [
'from_operator' => 'boolean',
'sent_at' => 'datetime',
];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
/**
* The mailable's short name, for a column that has to fit.
*
* "OrderConfirmationMail", not "App\Mail\OrderConfirmationMail" the
* namespace is the same on every row and tells the reader nothing.
*/
public function kind(): string
{
return $this->mailable === null ? '—' : class_basename($this->mailable);
}
}

View File

@ -6,6 +6,7 @@ use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\EnsureCustomerActive; use App\Http\Middleware\EnsureCustomerActive;
use App\Http\Middleware\RestrictAdminHost; use App\Http\Middleware\RestrictAdminHost;
use App\Http\Middleware\RestrictConsoleNetwork; use App\Http\Middleware\RestrictConsoleNetwork;
use App\Listeners\RecordSentMail;
use App\Listeners\RecordSignInDevice; use App\Listeners\RecordSignInDevice;
use App\Mail\MaintenanceCancelledMail; use App\Mail\MaintenanceCancelledMail;
use App\Mail\Transport\MailboxTransport; use App\Mail\Transport\MailboxTransport;
@ -165,6 +166,12 @@ class AppServiceProvider extends ServiceProvider
return null; return null;
}); });
// The register of what this installation has sent, and to whom. One
// listener rather than a call in each mailable: a register that has to
// be remembered at every call site is a register with holes in it, and
// the holes are the mails nobody thought about.
Event::listen(MessageSent::class, RecordSentMail::class);
// Stamp a maintenance-notification ledger row as delivered only once the // Stamp a maintenance-notification ledger row as delivered only once the
// mail is actually sent (the X-CP-Notification header carries the id). // mail is actually sent (the X-CP-Notification header carries the id).
// Until then sent_at stays null → the row is a retryable marker. If an // Until then sent_at stays null → the row is a retryable marker. If an

View File

@ -60,6 +60,10 @@ final class Navigation
// site-visibility switch invites somebody to change one in passing. // site-visibility switch invites somebody to change one in passing.
['admin.finance', 'receipt', 'finance', 'site.manage'], ['admin.finance', 'receipt', 'finance', 'site.manage'],
['admin.invoices', 'file-text', 'invoices', 'site.manage'], ['admin.invoices', 'file-text', 'invoices', 'site.manage'],
// What this installation has sent, and to whom. Under Betrieb
// rather than System: it is read while answering a customer,
// not while configuring the machine.
['admin.mail-log', 'send', 'mail_log', 'customers.manage'],
]], ]],
['label' => __('admin.nav_group.system'), 'items' => [ ['label' => __('admin.nav_group.system'), 'items' => [
['admin.mail', 'mail', 'mail', 'mail.manage'], ['admin.mail', 'mail', 'mail', 'mail.manage'],

View File

@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A register of what this installation has sent, and to whom.
*
* Until now the only record of a mail was that some model somewhere had a
* `sent_at` on it, one per feature and only for the features that thought to
* keep one. So "did the customer ever get their credentials?" was answered by
* looking in the mail server's log a different machine, a different program,
* and nothing an operator can put in front of a customer who says they never
* received anything.
*
* Deliberately NOT the message body in full detail: this is a delivery
* register, not a copy of the mailbox. Subject, recipient, when, and which
* mailable enough to answer "what went out and when", not enough to turn a
* database backup into a leak of everything anybody was ever told.
*
* The exception is `body`, which is filled only where the operator wrote the
* message themselves in the console: they typed it, they should be able to read
* back what they sent.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('sent_mails', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
// Nullable, because not every mail has a customer behind it: a
// password reset can be triggered for an address that never bought
// anything, and an operator invitation is not a customer at all.
$table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
$table->string('to');
$table->string('subject');
// The mailable class, so a filter can say "every invoice mail"
// without matching on a subject line that changes with the wording.
$table->string('mailable')->nullable();
// Written by a person in the console rather than by the system.
$table->boolean('from_operator')->default(false);
$table->text('body')->nullable();
// Recorded on MessageSent — i.e. the transport accepted it. That is
// the strongest thing an application can honestly claim; what the
// receiving server did with it afterwards is not ours to know.
$table->timestamp('sent_at');
$table->timestamps();
$table->index(['customer_id', 'sent_at']);
$table->index('sent_at');
});
}
public function down(): void
{
Schema::dropIfExists('sent_mails');
}
};

View File

@ -11,6 +11,7 @@ return [
], ],
'nav' => [ 'nav' => [
'mail_log' => 'Postausgang',
'overview' => 'Übersicht', 'overview' => 'Übersicht',
'customers' => 'Kunden', 'customers' => 'Kunden',
'instances' => 'Instanzen', 'instances' => 'Instanzen',
@ -36,7 +37,7 @@ return [
'overview_title' => 'Fleet-Übersicht', 'overview_title' => 'Fleet-Übersicht',
'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.', 'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.',
'systems_ok' => 'Nichts gemeldet', 'systems_ok' => 'Nichts gemeldet',
'systems_notices' => ':n Hinweis(e)', 'systems_notices' => '{1} :n Hinweis|[2,*] :n Hinweise',
'kpi' => [ 'kpi' => [
'customers' => 'Kunden', 'customers' => 'Kunden',

View File

@ -0,0 +1,21 @@
<?php
// Ein Kunde, alles über ihn, und ein Feld, um ihm zu schreiben.
return [
'back' => 'Alle Kunden',
'contract' => 'Vertrag',
'plan' => 'Paket',
'instance' => 'Instanz',
'since' => 'Kunde seit',
'vat_id' => 'UID',
'write_invoice' => 'Rechnung schreiben',
'requests' => 'Anfragen aus dem Kundenbereich',
'no_requests' => 'Bisher keine Anfrage.',
'answer' => 'Antworten',
'mails' => 'Gesendete E-Mails',
'no_mails' => 'An diesen Kunden ging bisher keine E-Mail.',
'all_mails' => 'Alle E-Mails',
'by_hand' => 'Von Hand',
];

View File

@ -0,0 +1,20 @@
<?php
// Die eine E-Mail, die ein Mensch schreibt — die Antwort auf „können Sie unsere
// Daten übernehmen?". Alles andere verschickt das System von selbst.
return [
'title' => 'Nachricht schreiben',
'subject' => 'Betreff',
'body' => 'Nachricht',
'body_hint' => 'Was Sie dem Kunden antworten möchten.',
'send' => 'Senden',
'note' => 'Geht an :email und wird im Postausgang festgehalten.',
'sent' => 'Nachricht gesendet und protokolliert.',
'answering' => 'Antwort auf eine Anfrage',
'cancel_answer' => 'Bezug aufheben',
're' => 'Re: :subject',
'mail_heading' => 'Nachricht von CluPilot',
'mail_greeting' => 'Guten Tag :name,',
];

16
lang/de/mail_log.php Normal file
View File

@ -0,0 +1,16 @@
<?php
// Das Versandregister. Eine Zeile heißt: der Mailserver hat die Nachricht
// angenommen — mehr kann eine Anwendung ehrlich nicht behaupten.
return [
'title' => 'Postausgang',
'subtitle' => 'Jede E-Mail, die diese Installation verschickt hat — wann, an wen, mit welchem Betreff. Eine Zeile bedeutet: der Mailserver hat sie angenommen.',
'search' => 'Suchen',
'search_hint' => 'Adresse oder Betreff',
'empty' => 'Es wurde noch nichts verschickt.',
'col_when' => 'Wann',
'col_to' => 'An',
'col_subject' => 'Betreff',
'col_kind' => 'Art',
'by_hand' => 'Von Hand',
];

View File

@ -11,6 +11,7 @@ return [
], ],
'nav' => [ 'nav' => [
'mail_log' => 'Mail sent',
'overview' => 'Overview', 'overview' => 'Overview',
'customers' => 'Customers', 'customers' => 'Customers',
'instances' => 'Instances', 'instances' => 'Instances',
@ -36,7 +37,7 @@ return [
'overview_title' => 'Fleet overview', 'overview_title' => 'Fleet overview',
'overview_sub' => 'The whole platform at a glance.', 'overview_sub' => 'The whole platform at a glance.',
'systems_ok' => 'Nothing reported', 'systems_ok' => 'Nothing reported',
'systems_notices' => ':n notice(s)', 'systems_notices' => '{1} :n notice|[2,*] :n notices',
'kpi' => [ 'kpi' => [
'customers' => 'Customers', 'customers' => 'Customers',

View File

@ -0,0 +1,21 @@
<?php
// One customer, everything about them, and a box to write to them.
return [
'back' => 'All customers',
'contract' => 'Contract',
'plan' => 'Package',
'instance' => 'Instance',
'since' => 'Customer since',
'vat_id' => 'VAT ID',
'write_invoice' => 'Write an invoice',
'requests' => 'Requests from the portal',
'no_requests' => 'No request so far.',
'answer' => 'Answer',
'mails' => 'Mail sent',
'no_mails' => 'Nothing has been sent to this customer yet.',
'all_mails' => 'All mail',
'by_hand' => 'By hand',
];

View File

@ -0,0 +1,20 @@
<?php
// The one mail a person writes — the answer to "can you migrate our data?".
// Everything else this application sends, it sends by itself.
return [
'title' => 'Write a message',
'subject' => 'Subject',
'body' => 'Message',
'body_hint' => 'What you would like to tell the customer.',
'send' => 'Send',
'note' => 'Goes to :email and is recorded in the mail register.',
'sent' => 'Message sent and recorded.',
'answering' => 'Answering a request',
'cancel_answer' => 'Detach',
're' => 'Re: :subject',
'mail_heading' => 'A message from CluPilot',
'mail_greeting' => 'Hello :name,',
];

16
lang/en/mail_log.php Normal file
View File

@ -0,0 +1,16 @@
<?php
// The delivery register. A row means the mail server accepted the message —
// which is the strongest thing an application can honestly claim.
return [
'title' => 'Mail sent',
'subtitle' => 'Every mail this installation has sent — when, to whom, with what subject. A row means the mail server accepted it.',
'search' => 'Search',
'search_hint' => 'Address or subject',
'empty' => 'Nothing has been sent yet.',
'col_when' => 'When',
'col_to' => 'To',
'col_subject' => 'Subject',
'col_kind' => 'Kind',
'by_hand' => 'By hand',
];

View File

@ -0,0 +1,146 @@
<div class="mx-auto max-w-[1120px] space-y-5">
<div class="flex flex-wrap items-start gap-4 animate-rise">
<div class="min-w-0">
<a href="{{ route('admin.customers') }}" wire:navigate class="text-xs font-semibold text-muted hover:text-ink">
{{ __('customer_detail.back') }}
</a>
<h1 class="mt-1 text-2xl font-bold tracking-tight text-ink">{{ $customer->name }}</h1>
<p class="mt-1 font-mono text-sm text-muted">{{ $customer->email }}</p>
</div>
<x-ui.badge :status="$customer->status === 'active' ? 'active' : 'warning'" class="mt-6">
{{ __('customers.status.'.$customer->status) }}
</x-ui.badge>
</div>
<div class="grid gap-5 lg:grid-cols-[1fr_340px] lg:items-start">
<div class="space-y-5">
{{-- ── Write to them ────────────────────────────────────────────
The point of the page. An operator answering a question used to
have the name in one window and a mail client in another, and
afterwards nothing in the console said an answer had ever been
given. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('customer_message.title') }}</h2>
@if ($answering)
<x-ui.badge status="provisioning">{{ __('customer_message.answering') }}</x-ui.badge>
<button type="button" wire:click="cancelAnswer" class="text-xs font-semibold text-muted hover:text-ink">
{{ __('customer_message.cancel_answer') }}
</button>
@endif
</div>
<form wire:submit="send" class="space-y-4">
<x-ui.input name="subject" wire:model="subject" :label="__('customer_message.subject')" />
<div>
<label for="body" class="block text-sm font-medium text-body">{{ __('customer_message.body') }}</label>
<textarea id="body" rows="7" wire:model="body"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint"
placeholder="{{ __('customer_message.body_hint') }}"></textarea>
@error('body')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="flex flex-wrap items-center gap-3">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="send">
<x-ui.icon name="send" class="size-4" />{{ __('customer_message.send') }}
</x-ui.button>
<p class="text-xs text-muted">{{ __('customer_message.note', ['email' => $customer->email]) }}</p>
</div>
</form>
</div>
{{-- ── What they asked ──────────────────────────────────────── --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('customer_detail.requests') }}</h2>
@if ($requests->isEmpty())
<p class="mt-3 text-sm text-muted">{{ __('customer_detail.no_requests') }}</p>
@else
<ul class="mt-3 divide-y divide-line">
@foreach ($requests as $request)
<li class="py-4">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="font-medium text-ink">{{ $request->subject }}</span>
<x-ui.badge :status="$request->isOpen() ? 'warning' : 'active'">
{{ __('support.status.'.$request->status) }}
</x-ui.badge>
<span class="ml-auto text-xs text-muted">{{ $request->created_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}</span>
</div>
{{-- The customer's own words, whole. Reading the
question was the other reason to leave the
console. --}}
<p class="mt-2 whitespace-pre-line text-sm leading-relaxed text-body">{{ $request->body }}</p>
@if ($request->isOpen())
<x-ui.button wire:click="answer('{{ $request->uuid }}')" variant="secondary" size="sm" class="mt-3">
{{ __('customer_detail.answer') }}
</x-ui.button>
@endif
</li>
@endforeach
</ul>
@endif
</div>
{{-- ── What we sent ─────────────────────────────────────────────
The delivery register, filtered to this customer: when, which
mail, and where an operator wrote it what it said. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('customer_detail.mails') }}</h2>
<a href="{{ route('admin.mail-log') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">
{{ __('customer_detail.all_mails') }}
</a>
</div>
@if ($mails->isEmpty())
<p class="mt-3 text-sm text-muted">{{ __('customer_detail.no_mails') }}</p>
@else
<ul class="mt-3 divide-y divide-line">
@foreach ($mails as $mail)
<li class="py-3">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span class="font-medium text-ink">{{ $mail->subject }}</span>
@if ($mail->from_operator)
<x-ui.badge status="provisioning">{{ __('customer_detail.by_hand') }}</x-ui.badge>
@endif
<span class="ml-auto font-mono text-xs text-muted">
{{ $mail->sent_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}
</span>
</div>
@if ($mail->body)
<p class="mt-1.5 whitespace-pre-line text-xs leading-relaxed text-muted">{{ $mail->body }}</p>
@else
<p class="mt-1 font-mono text-xs text-faint">{{ $mail->kind() }}</p>
@endif
</li>
@endforeach
</ul>
@endif
</div>
</div>
{{-- ── Who they are ─────────────────────────────────────────────── --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('customer_detail.contract') }}</h2>
<dl class="space-y-2.5 text-sm">
@foreach ([
__('customer_detail.plan') => $subscription ? __('billing.plan.'.$subscription->plan) : '—',
__('customer_detail.instance') => $instance?->subdomain ?? '—',
__('customer_detail.since') => $customer->created_at->local()->isoFormat('D. MMM YYYY'),
__('customer_detail.vat_id') => $customer->vat_id ?: '—',
] as $term => $value)
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ $term }}</dt>
<dd class="text-right font-medium text-ink">{{ $value }}</dd>
</div>
@endforeach
</dl>
{{-- Prefilled with this customer, so writing an invoice for the work
just agreed is one click from the conversation about it. --}}
<x-ui.button :href="route('admin.invoices.new', ['customer' => $customer->uuid])" variant="secondary" class="w-full" wire:navigate>
<x-ui.icon name="receipt" class="size-4" />{{ __('customer_detail.write_invoice') }}
</x-ui.button>
</div>
</div>
</div>

View File

@ -21,7 +21,12 @@
@forelse ($rows as $r) @forelse ($rows as $r)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover"> <tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3"> <td class="px-4 py-3">
<p class="font-medium text-ink">{{ $r['name'] }}</p> {{-- The row leads somewhere now. The list
was the end of the road: an operator
answering a question had the name here
and the mail client in another window. --}}
<a href="{{ route('admin.customer', $r['uuid']) }}" wire:navigate
class="font-medium text-ink hover:underline">{{ $r['name'] }}</a>
<p class="font-mono text-xs text-muted">{{ $r['instance'] }}.clupilot.com</p> <p class="font-mono text-xs text-muted">{{ $r['instance'] }}.clupilot.com</p>
</td> </td>
<td class="px-4 py-3 text-body"> <td class="px-4 py-3 text-body">

View File

@ -0,0 +1,58 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('mail_log.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('mail_log.subtitle') }}</p>
</div>
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="border-b border-line p-4">
<div class="max-w-sm">
<x-ui.input name="search" wire:model.live.debounce.300ms="search"
:label="__('mail_log.search')" :placeholder="__('mail_log.search_hint')" />
</div>
</div>
@if ($mails->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('mail_log.empty') }}</p>
@else
<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">{{ __('mail_log.col_when') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('mail_log.col_to') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('mail_log.col_subject') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('mail_log.col_kind') }}</th>
</tr>
</thead>
<tbody>
@foreach ($mails as $mail)
<tr class="border-b border-line last:border-0">
{{-- R19: stored in UTC, read on the wall clock. --}}
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs text-muted">
{{ $mail->sent_at->local()->isoFormat('D. MMM YYYY, HH:mm:ss') }}
</td>
<td class="px-4 py-3">
@if ($mail->customer)
<a href="{{ route('admin.customer', $mail->customer->uuid) }}" wire:navigate
class="font-medium text-ink hover:underline">{{ $mail->customer->name }}</a>
<p class="font-mono text-xs text-muted">{{ $mail->to }}</p>
@else
<span class="font-mono text-xs text-muted">{{ $mail->to }}</span>
@endif
</td>
<td class="px-4 py-3 text-body">
{{ $mail->subject }}
@if ($mail->from_operator)
<x-ui.badge status="provisioning" class="ml-2">{{ __('mail_log.by_hand') }}</x-ui.badge>
@endif
</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $mail->kind() }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="border-t border-line p-4">{{ $mails->links() }}</div>
@endif
</div>
</div>

View File

@ -8,9 +8,13 @@
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('admin.systems_ok') }} <span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('admin.systems_ok') }}
</span> </span>
@else @else
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-warning-bg px-3.5 py-1.5 text-xs font-semibold text-warning"> {{-- A link, not a label. It said "4 Hinweis(e)" and led nowhere:
<x-ui.icon name="alert-triangle" class="size-3.5" />{{ __('admin.systems_notices', ['n' => $noticeCount]) }} whoever read it had to know that the list was a card further
</span> down the same page, and the plural was a bracket rather than a
word. --}}
<a href="#notices" class="ml-auto inline-flex items-center gap-2 rounded-pill bg-warning-bg px-3.5 py-1.5 text-xs font-semibold text-warning transition-colors hover:bg-warning-bg/70">
<x-ui.icon name="alert-triangle" class="size-3.5" />{{ trans_choice('admin.systems_notices', $noticeCount, ['n' => $noticeCount]) }}
</a>
@endif @endif
<p class="w-full text-sm text-muted">{{ __('admin.overview_sub') }}</p> <p class="w-full text-sm text-muted">{{ __('admin.overview_sub') }}</p>
</div> </div>
@ -118,14 +122,27 @@
</ul> </ul>
</div> </div>
{{-- Notices --}} {{-- Notices. Each one links to the page that shows the thing it is
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> about a warning somebody has to go and find by hand is a warning
that waits until the next time they happen to look. --}}
<div id="notices" class="scroll-mt-6 rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2>
<ul class="mt-2 space-y-2"> <ul class="mt-2 divide-y divide-line">
@forelse ($notices as $a) @forelse ($notices as $a)
<li class="flex items-start gap-2.5 text-sm"> <li>
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" /> @if ($a['route'] ?? null)
<span class="text-body">{{ $a['text'] }}</span> <a href="{{ route($a['route']) }}" wire:navigate
class="group flex items-start gap-2.5 py-2.5 text-sm transition-colors hover:text-ink">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />
<span class="text-body group-hover:text-ink">{{ $a['text'] }}</span>
<x-ui.icon name="chevron-down" class="mt-0.5 size-4 shrink-0 -rotate-90 text-faint group-hover:text-muted" />
</a>
@else
<div class="flex items-start gap-2.5 py-2.5 text-sm">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />
<span class="text-body">{{ $a['text'] }}</span>
</div>
@endif
</li> </li>
@empty @empty
<li class="py-1 text-sm text-muted">{{ __('admin.no_notices') }}</li> <li class="py-1 text-sm text-muted">{{ __('admin.no_notices') }}</li>

View File

@ -0,0 +1,15 @@
<x-mail.layout
:heading="__('customer_message.mail_heading')"
:preheader="\Illuminate\Support\Str::limit($bodyText, 90)"
:greeting="__('customer_message.mail_greeting', ['name' => $name])"
>
<tr><td style="padding:0 40px 32px 40px;">
{{-- The operator's own words, as they typed them. nl2br over an escaped
string, never {!! !!}: this text comes out of a form, and a message
somebody pastes in must not be able to bring markup into a mail we
sign our name to. --}}
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{!! nl2br(e($bodyText)) !!}</p>
</td></tr>
</x-mail.layout>

View File

@ -37,3 +37,10 @@
<tr><td style="padding:24px 40px 0 40px;"> <tr><td style="padding:24px 40px 0 40px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('reset_password.not_you') }}</p> <p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('reset_password.not_you') }}</p>
</td></tr> </td></tr>
{{-- The closing tag. Without it the component's contents were never
terminated: the layout's own @if ran off the end of the file and the mail
raised a Blade syntax error instead of rendering so the reset link never
reached anybody who asked for one. Every other mail view in this directory
closes; this was the only one that did not. --}}
</x-mail.layout>

View File

@ -41,6 +41,10 @@ Route::get('/capacity', Admin\Capacity::class)->name('capacity');
Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/vpn', Admin\Vpn::class)->name('vpn');
Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/revenue', Admin\Revenue::class)->name('revenue');
Route::get('/finance', Admin\Finance::class)->name('finance'); Route::get('/finance', Admin\Finance::class)->name('finance');
// One customer, everything about them, and a box to write to them.
Route::get('/customers/{uuid}', Admin\CustomerDetail::class)->name('customer');
// The register of what left the building — see Admin\MailLog.
Route::get('/mail-log', Admin\MailLog::class)->name('mail-log');
Route::get('/invoices', Admin\Invoices::class)->name('invoices'); Route::get('/invoices', Admin\Invoices::class)->name('invoices');
// Writing one by hand, for work that came off no price list. Same series, same // Writing one by hand, for work that came off no price list. Same series, same
// numbering — see Admin\NewInvoice. // numbering — see Admin\NewInvoice.

View File

@ -0,0 +1,205 @@
<?php
use App\Livewire\Admin\CustomerDetail;
use App\Livewire\Admin\MailLog;
use App\Livewire\Admin\Overview;
use App\Mail\OperatorMessageMail;
use App\Models\Customer;
use App\Models\SentMail;
use App\Models\SupportRequest;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
/**
* What left the building, and who wrote it.
*
* "Hat der Kunde die Zugangsdaten je bekommen, und wann?" was answered on the
* mail server until now a different machine, a different program, and nothing
* an operator can put in front of somebody who says nothing arrived.
*/
it('writes a row for every mail the application sends', function () {
$customer = Customer::factory()->create();
Mail::to($customer->email)->send(new OperatorMessageMail($customer, 'Betreff', 'Text'));
$row = SentMail::query()->latest('id')->first();
expect($row)->not->toBeNull()
->and($row->to)->toBe($customer->email)
->and($row->subject)->toBe('Betreff')
->and($row->mailable)->toBe(OperatorMessageMail::class)
// Attached to the customer, so the question "what did THIS customer
// get" does not mean reading the whole register.
->and($row->customer_id)->toBe($customer->id);
});
it('records the send, not the intention', function () {
// On MessageSent, not MessageSending: a row here has to mean the transport
// accepted it. Recording intentions would fill the register with mails that
// never left — worse than no register, because an operator would show a
// customer a line proving something was sent that was not.
expect(config('mail.default'))->not->toBeNull();
$before = SentMail::query()->count();
Mail::fake();
$customer = Customer::factory()->create();
Mail::to($customer->email)->send(new OperatorMessageMail($customer, 'Betreff', 'Text'));
// Mail::fake() never hands the message to a transport, so nothing was sent
// and nothing is claimed.
expect(SentMail::query()->count())->toBe($before);
});
it('keeps a mail to an address nobody owns, without inventing a customer for it', function () {
Mail::to('fremder@example.test')->send(
new OperatorMessageMail(Customer::factory()->create(), 'Betreff', 'Text')
);
$row = SentMail::query()->where('to', 'fremder@example.test')->first();
expect($row)->not->toBeNull()
->and($row->customer_id)->toBeNull();
});
it('does not let a stranger write rows by asking for a password reset', function () {
// The verification and reset mails go to addresses nobody has confirmed,
// and anyone who can type into a form can send them. Recording them would
// hand the operator's register to whoever wants to fill it.
$user = App\Models\User::factory()->create();
Mail::to($user->email)->send(new App\Mail\ResetPasswordMail($user, 'https://example.test/reset', 60));
expect(SentMail::query()->where('to', $user->email)->exists())->toBeFalse();
});
it('shows the register, newest first, with the customer it belongs to', function () {
$customer = Customer::factory()->create(['name' => 'Kanzlei Berger']);
SentMail::create([
'customer_id' => $customer->id,
'to' => $customer->email,
'subject' => 'Ihre Zugangsdaten',
'mailable' => App\Mail\OperatorMessageMail::class,
'sent_at' => now()->subHour(),
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(MailLog::class)
->assertOk()
->assertSee('Ihre Zugangsdaten')
->assertSee('Kanzlei Berger');
});
// ---- Writing to a customer from the console ----
it('sends the operator message and records what was written', function () {
// The body is kept only for a mail a person typed: "what exactly did I
// write to them in March" is a question the mail server's log cannot
// answer either.
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->set('subject', 'Zur Datenübernahme')
->set('body', 'Wir haben uns Ihr System angesehen und können übernehmen.')
->call('send');
$row = SentMail::query()->where('customer_id', $customer->id)->latest('id')->first();
expect($row)->not->toBeNull()
->and($row->subject)->toBe('Zur Datenübernahme')
->and($row->from_operator)->toBeTrue()
->and($row->body)->toBe('Wir haben uns Ihr System angesehen und können übernehmen.');
});
it('closes the request it answers, which is the reason to answer from here', function () {
// Answering from a mail client leaves the request open for ever, because
// nothing ever told it otherwise.
$customer = Customer::factory()->create();
$request = SupportRequest::create([
'customer_id' => $customer->id,
'subject' => 'Können Sie unsere Daten übernehmen?',
'category' => 'other',
'body' => 'Wir haben ownCloud 10 im Haus.',
'status' => 'open',
'reported_by' => $customer->email,
]);
$page = Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('answer', $request->uuid);
expect($page->get('subject'))->toContain('Können Sie unsere Daten übernehmen?');
$page->set('body', 'Ja, das geht — Angebot folgt.')->call('send');
expect($request->fresh()->status)->toBe('answered')
->and($request->fresh()->answered_at)->not->toBeNull();
});
it('shows the customer their own words, so nobody opens the mail client to read the question', function () {
$customer = Customer::factory()->create();
SupportRequest::create([
'customer_id' => $customer->id,
'subject' => 'Frage zur Migration',
'category' => 'other',
'body' => 'Wir haben 42 GB in ownCloud und wollen wechseln.',
'status' => 'open',
'reported_by' => $customer->email,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->assertSee('Wir haben 42 GB in ownCloud und wollen wechseln.');
});
it('refuses an empty message rather than sending one', function () {
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Owner'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->call('send')
->assertHasErrors(['subject', 'body']);
expect(SentMail::query()->where('customer_id', $customer->id)->exists())->toBeFalse();
});
it('keeps both pages to operators who may see customers', function () {
// Support KEEPS both, deliberately: answering customers is the job, and a
// support operator who cannot read what was already sent them is answering
// blind. Read-only is the role that may look at the console and change
// nothing — writing a mail is a change, and so is reading the register of
// what was written.
$customer = Customer::factory()->create();
expect(operator('Support')->can('customers.manage'))->toBeTrue();
Livewire::actingAs(operator('Read-only'), 'operator')
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
->assertForbidden();
Livewire::actingAs(operator('Read-only'), 'operator')
->test(MailLog::class)
->assertForbidden();
});
// ---- The notices somebody can finally act on ----
it('gives every notice the page that shows the thing it is about', function () {
// The count in the header said "4 Hinweis(e)" and led nowhere: whoever read
// it had to know the list was a card further down the same page, and then
// go looking for the host or the run by hand.
App\Models\Host::factory()->create(['status' => 'error', 'name' => 'pve-broken']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(Overview::class)
->assertViewHas('notices', fn (array $notices) => collect($notices)
->every(fn (array $n) => array_key_exists('route', $n) && $n['route'] !== null))
->assertSee(route('admin.hosts'), false);
});
it('counts notices in words, not in brackets', function () {
expect(trans_choice('admin.systems_notices', 1, ['n' => 1]))->toBe('1 Hinweis')
->and(trans_choice('admin.systems_notices', 4, ['n' => 4]))->toBe('4 Hinweise');
});

View File

@ -129,3 +129,29 @@ it('insists on a password long enough to be worth resetting to', function () {
->call('save') ->call('save')
->assertHasErrors('password'); ->assertHasErrors('password');
}); });
it('renders the reset mail, which it could not do at all', function () {
// The view was missing its closing </x-mail.layout>. The component's
// contents were never terminated, the layout's own @if ran off the end of
// the file, and the mail raised a Blade syntax error instead of rendering —
// so the link never reached anybody who asked for one. Every test until now
// asserted that the mail was QUEUED, which it faithfully was.
$user = App\Models\User::factory()->create(['name' => 'Bea Berger']);
$html = (new App\Mail\ResetPasswordMail($user, 'https://example.test/reset/abc', 60))
->render();
expect($html)->toContain('https://example.test/reset/abc')
->toContain(__('reset_password.action'));
});
it('closes every mail layout it opens', function () {
// One unterminated component is a mail that cannot be sent, and the failure
// only shows at render time — long after the test that queued it went green.
foreach (glob(resource_path('views/mail/*.blade.php')) as $file) {
$body = file_get_contents($file);
expect(substr_count($body, '<x-mail.layout'))
->toBe(substr_count($body, '</x-mail.layout>'), basename($file));
}
});