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
parent
559a466630
commit
0e25fe88d4
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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,',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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,',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue