Answer the inbox from the panel, with templates
A reply starts from a template filled in the language the person wrote in, is sent by the application when it has a mailer that delivers or handed to the replier's mail program otherwise, and is recorded on the submission. The same is available through the API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
|
||||
use App\Filament\Resources\Applications\ApplicationResource;
|
||||
use App\Filament\Resources\Applications\Tables\ApplicationsTable;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationReply;
|
||||
use App\Models\ReplyTemplate;
|
||||
use App\Support\Inbox\Replier;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ViewApplication extends ViewRecord
|
||||
{
|
||||
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
|
||||
return [
|
||||
// Answering is the point of the record, so the reply is the first
|
||||
// button rather than something to copy out of the page.
|
||||
// Answering is the point of the record, so the reply is the first
|
||||
// button: a template to start from, edited here, and sent — by
|
||||
// this application when it can send mail, or handed to the
|
||||
// person's own mail program with the text already written.
|
||||
Action::make('reply')
|
||||
->label(__('panel.applications.actions.reply'))
|
||||
->icon('heroicon-o-envelope')
|
||||
->color('primary')
|
||||
->authorize('update')
|
||||
->modalHeading(fn (Application $record) => __('panel.replies.heading', ['name' => $record->name]))
|
||||
->modalDescription(fn () => Replier::canSendMail() ? __('panel.replies.sends') : __('panel.replies.hands_over'))
|
||||
->modalSubmitActionLabel(fn () => Replier::canSendMail() ? __('panel.replies.send') : __('panel.replies.open_mail'))
|
||||
->schema([
|
||||
Select::make('template_id')
|
||||
->label(__('panel.replies.fields.template'))
|
||||
->options(fn (Application $record) => ReplyTemplate::query()->for($record->kind)->pluck('name', 'id')->all())
|
||||
->placeholder(__('panel.replies.no_template'))
|
||||
->live()
|
||||
->afterStateUpdated(function (?string $state, callable $set, Application $record): void {
|
||||
$template = $state ? ReplyTemplate::find($state) : null;
|
||||
|
||||
if ($template !== null) {
|
||||
$text = $template->renderFor($record, Auth::user());
|
||||
$set('subject', $text['subject']);
|
||||
$set('body', $text['body']);
|
||||
}
|
||||
}),
|
||||
|
||||
TextInput::make('subject')
|
||||
->label(__('panel.replies.fields.subject'))
|
||||
->default(fn (Application $record) => __('panel.applications.reply_subject', ['subject' => $record->subject()], $record->locale))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Textarea::make('body')
|
||||
->label(__('panel.replies.fields.body'))
|
||||
->helperText(fn (Application $record) => __('panel.replies.in_language', [
|
||||
'language' => config("locales.supported.{$record->locale}.native", $record->locale),
|
||||
]))
|
||||
->required()
|
||||
->rows(12),
|
||||
])
|
||||
->action(function (Application $record, array $data, $livewire): void {
|
||||
$reply = Replier::reply($record, $data['subject'], $data['body'], Auth::user());
|
||||
|
||||
if ($reply->channel === ApplicationReply::MAILTO) {
|
||||
$livewire->js('window.location.href = '.json_encode(Replier::mailto($record, $data['subject'], $data['body'])));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title($reply->channel === ApplicationReply::MAIL ? __('panel.replies.sent') : __('panel.replies.recorded'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
// The way it always worked: an empty message in your own mail
|
||||
// program, for an answer that fits no template.
|
||||
Action::make('reply_elsewhere')
|
||||
->label(__('panel.replies.elsewhere'))
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
|
||||
.rawurlencode(__('panel.applications.reply_subject', [
|
||||
'subject' => $record->subject(),
|
||||
|
||||
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\Applications\Schemas;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationReply;
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
@@ -69,6 +71,36 @@ class ApplicationInfolist
|
||||
->visible(fn (Application $record) => filled($record->links)),
|
||||
]),
|
||||
|
||||
// What was already said, so the next person to open this does not
|
||||
// say it again.
|
||||
Section::make(__('panel.replies.history'))
|
||||
->visible(fn (Application $record) => $record->replies()->exists())
|
||||
->schema([
|
||||
RepeatableEntry::make('replies')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('subject')
|
||||
->hiddenLabel()
|
||||
->weight('bold'),
|
||||
|
||||
TextEntry::make('created_at')
|
||||
->hiddenLabel()
|
||||
->state(fn (ApplicationReply $record) => __('panel.replies.by', [
|
||||
'name' => $record->user_name ?? '—',
|
||||
'when' => $record->created_at?->isoFormat('LLL'),
|
||||
]).' · '.__('panel.replies.channels.'.$record->channel))
|
||||
->color('gray'),
|
||||
|
||||
TextEntry::make('body')
|
||||
->hiddenLabel()
|
||||
->prose()
|
||||
->formatStateUsing(fn (string $state) => nl2br(e($state)))
|
||||
->html()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
|
||||
Section::make(__('panel.applications.sections.internal'))
|
||||
->description(__('panel.applications.sections.internal_hint'))
|
||||
->columns(4)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ReplyTemplates\Pages;
|
||||
|
||||
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateReplyTemplate extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ReplyTemplateResource::class;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ReplyTemplates\Pages;
|
||||
|
||||
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditReplyTemplate extends EditRecord
|
||||
{
|
||||
protected static string $resource = ReplyTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ReplyTemplates\Pages;
|
||||
|
||||
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListReplyTemplates extends ListRecords
|
||||
{
|
||||
protected static string $resource = ReplyTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
131
app/Filament/Resources/ReplyTemplates/ReplyTemplateResource.php
Normal file
131
app/Filament/Resources/ReplyTemplates/ReplyTemplateResource.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ReplyTemplates;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use App\Filament\Resources\ReplyTemplates\Pages\CreateReplyTemplate;
|
||||
use App\Filament\Resources\ReplyTemplates\Pages\EditReplyTemplate;
|
||||
use App\Filament\Resources\ReplyTemplates\Pages\ListReplyTemplates;
|
||||
use App\Filament\Support\TranslatableTabs;
|
||||
use App\Models\ReplyTemplate;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* The answers the inbox gives often enough to write once.
|
||||
*/
|
||||
class ReplyTemplateResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ReplyTemplate::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleBottomCenterText;
|
||||
|
||||
protected static ?int $navigationSort = 5;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('panel.replies.templates.label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('panel.replies.templates.plural');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return __('panel.groups.collective');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Section::make()
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('panel.replies.templates.fields.name'))
|
||||
->helperText(__('panel.replies.templates.fields.name_hint'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Select::make('kind')
|
||||
->label(__('panel.replies.templates.fields.kind'))
|
||||
->options(ApplicationKind::options())
|
||||
->placeholder(__('panel.replies.templates.any_kind')),
|
||||
|
||||
TextInput::make('position')
|
||||
->label(__('panel.replies.templates.fields.position'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
]),
|
||||
|
||||
Section::make()
|
||||
->description(__('panel.replies.templates.placeholders', ['list' => implode(', ', ReplyTemplate::PLACEHOLDERS)]))
|
||||
->schema([
|
||||
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
|
||||
TextInput::make("subject.{$locale}")
|
||||
->label(__('panel.replies.fields.subject'))
|
||||
->required($isDefault)
|
||||
->maxLength(255),
|
||||
|
||||
Textarea::make("body.{$locale}")
|
||||
->label(__('panel.replies.fields.body'))
|
||||
->required($isDefault)
|
||||
->rows(10),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('position')
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('panel.replies.templates.fields.name'))
|
||||
->searchable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('kind')
|
||||
->label(__('panel.replies.templates.fields.kind'))
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state) => $state ? ApplicationKind::from($state)->getLabel() : __('panel.replies.templates.any_kind'))
|
||||
->placeholder(__('panel.replies.templates.any_kind')),
|
||||
|
||||
TextColumn::make('subject')
|
||||
->label(__('panel.replies.fields.subject'))
|
||||
->limit(60),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListReplyTemplates::route('/'),
|
||||
'create' => CreateReplyTemplate::route('/create'),
|
||||
'edit' => EditReplyTemplate::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
98
app/Http/Controllers/Api/V1/ApplicationReplyController.php
Normal file
98
app/Http/Controllers/Api/V1/ApplicationReplyController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationReply;
|
||||
use App\Models\ReplyTemplate;
|
||||
use App\Support\Inbox\Replier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Answering the inbox through the API: what was already said, the templates
|
||||
* to start from, and sending an answer — the panel's "Reply".
|
||||
*/
|
||||
class ApplicationReplyController extends Controller
|
||||
{
|
||||
public function index(Application $application): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $application);
|
||||
|
||||
return response()->json([
|
||||
'data' => $application->replies()->get()->map(fn (ApplicationReply $reply) => $this->present($reply))->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `subject` and `body`, or a `template_id` to fill them from — in the
|
||||
* language the person wrote in. Sent when this installation can send
|
||||
* mail; otherwise recorded, with a `mailto` link to send it by hand.
|
||||
*/
|
||||
public function store(Request $request, Application $application): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$data = $request->validate([
|
||||
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:reply_templates,id'],
|
||||
'subject' => ['required_without:template_id', 'nullable', 'string', 'max:255'],
|
||||
'body' => ['required_without:template_id', 'nullable', 'string', 'max:20000'],
|
||||
]);
|
||||
|
||||
$text = ['subject' => $data['subject'] ?? null, 'body' => $data['body'] ?? null];
|
||||
|
||||
if (filled($data['template_id'] ?? null)) {
|
||||
$rendered = ReplyTemplate::query()->findOrFail($data['template_id'])->renderFor($application, $request->user());
|
||||
$text = ['subject' => $text['subject'] ?: $rendered['subject'], 'body' => $text['body'] ?: $rendered['body']];
|
||||
}
|
||||
|
||||
if (blank($text['subject']) || blank($text['body'])) {
|
||||
throw ValidationException::withMessages(['template_id' => [__('panel.replies.empty_template')]]);
|
||||
}
|
||||
|
||||
$reply = Replier::reply($application, $text['subject'], $text['body'], $request->user());
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
...$this->present($reply),
|
||||
'mailto' => $reply->channel === ApplicationReply::MAILTO
|
||||
? Replier::mailto($application, $text['subject'], $text['body'])
|
||||
: null,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function templates(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', ReplyTemplate::class);
|
||||
|
||||
return response()->json([
|
||||
'data' => ReplyTemplate::query()->orderBy('position')->orderBy('name')->get()->map(fn (ReplyTemplate $template) => [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'kind' => $template->kind,
|
||||
'subject' => (object) $template->getTranslations('subject'),
|
||||
'body' => (object) $template->getTranslations('body'),
|
||||
])->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function present(ApplicationReply $reply): array
|
||||
{
|
||||
return [
|
||||
'id' => $reply->id,
|
||||
'subject' => $reply->subject,
|
||||
'body' => $reply->body,
|
||||
'channel' => $reply->channel,
|
||||
'author' => ['id' => $reply->user_id, 'name' => $reply->user_name],
|
||||
'created_at' => $reply->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Mail/ApplicationReplyMail.php
Normal file
39
app/Mail/ApplicationReplyMail.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ApplicationReply;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* The answer, as plain text — what a person would have typed, not a
|
||||
* newsletter.
|
||||
*/
|
||||
class ApplicationReplyMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public ApplicationReply $reply) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$replyTo = config('mail.reply_to.address');
|
||||
|
||||
return new Envelope(
|
||||
subject: $this->reply->subject,
|
||||
replyTo: $replyTo ? [new Address((string) $replyTo, (string) config('mail.reply_to.name'))] : [],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(text: 'mail.application-reply');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
@@ -59,6 +60,14 @@ class Application extends Model
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
/**
|
||||
* @return HasMany<ApplicationReply, $this>
|
||||
*/
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApplicationReply::class)->latest('id');
|
||||
}
|
||||
|
||||
public function handler(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'handled_by');
|
||||
|
||||
31
app/Models/ApplicationReply.php
Normal file
31
app/Models/ApplicationReply.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* An answer given to a submission, kept so the next person to open it knows
|
||||
* what was already said.
|
||||
*/
|
||||
#[Fillable(['user_id', 'user_name', 'subject', 'body', 'channel'])]
|
||||
class ApplicationReply extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
public const MAIL = 'mail';
|
||||
|
||||
public const MAILTO = 'mailto';
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Application, $this>
|
||||
*/
|
||||
public function application(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Application::class);
|
||||
}
|
||||
}
|
||||
64
app/Models/ReplyTemplate.php
Normal file
64
app/Models/ReplyTemplate.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* An answer the inbox gives often enough to write once: "thanks, we'll be in
|
||||
* touch", "this call is filled", "here is how we work".
|
||||
*
|
||||
* Written in every language, because the answer goes out in the one the
|
||||
* person wrote in. `{name}`, `{subject}` and `{sender}` are filled in.
|
||||
*/
|
||||
#[Fillable(['name', 'kind', 'subject', 'body', 'position'])]
|
||||
class ReplyTemplate extends Model
|
||||
{
|
||||
use HasFactory, HasTranslations;
|
||||
|
||||
public const PLACEHOLDERS = ['{name}', '{subject}', '{sender}'];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $translatable = ['subject', 'body'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['position' => 'integer'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeFor(Builder $query, ApplicationKind $kind): Builder
|
||||
{
|
||||
return $query
|
||||
->where(fn (Builder $q) => $q->whereNull('kind')->orWhere('kind', $kind->value))
|
||||
->orderBy('position')
|
||||
->orderBy('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject and body for one submission, in its language.
|
||||
*
|
||||
* @return array{subject: string, body: string}
|
||||
*/
|
||||
public function renderFor(Application $application, ?User $sender = null): array
|
||||
{
|
||||
$values = [$application->name, $application->subject(), $sender?->name ?? (string) config('app.name')];
|
||||
|
||||
return [
|
||||
'subject' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('subject', $application->locale)),
|
||||
'body' => str_replace(self::PLACEHOLDERS, $values, (string) $this->getTranslation('body', $application->locale)),
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Policies/ReplyTemplatePolicy.php
Normal file
44
app/Policies/ReplyTemplatePolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Templates belong to the inbox: whoever answers it may write them.
|
||||
*/
|
||||
class ReplyTemplatePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('applications.view_any');
|
||||
}
|
||||
|
||||
public function view(User $user, Model $model): bool
|
||||
{
|
||||
return $user->can('applications.view_any');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->can('applications.update');
|
||||
}
|
||||
|
||||
public function update(User $user, Model $model): bool
|
||||
{
|
||||
return $user->can('applications.update');
|
||||
}
|
||||
|
||||
public function delete(User $user, Model $model): bool
|
||||
{
|
||||
return $user->can('applications.update');
|
||||
}
|
||||
|
||||
public function deleteAny(User $user): bool
|
||||
{
|
||||
return $user->can('applications.update');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Models\MissingUrl;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Redirect;
|
||||
use App\Models\ReplyTemplate;
|
||||
use App\Models\Repository;
|
||||
use App\Models\Service;
|
||||
use App\Models\Talk;
|
||||
@@ -31,6 +32,7 @@ use App\Policies\MissingUrlPolicy;
|
||||
use App\Policies\PagePolicy;
|
||||
use App\Policies\ProjectPolicy;
|
||||
use App\Policies\RedirectPolicy;
|
||||
use App\Policies\ReplyTemplatePolicy;
|
||||
use App\Policies\RepositoryPolicy;
|
||||
use App\Policies\RolePolicy;
|
||||
use App\Policies\ServicePolicy;
|
||||
@@ -66,6 +68,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
Gate::policy(User::class, UserPolicy::class);
|
||||
Gate::policy(Role::class, RolePolicy::class);
|
||||
Gate::policy(Webhook::class, WebhookPolicy::class);
|
||||
Gate::policy(ReplyTemplate::class, ReplyTemplatePolicy::class);
|
||||
|
||||
// The one place a permission is granted — spatie's own gate check is
|
||||
// switched off (config/permission.php) so nothing answers before this.
|
||||
|
||||
61
app/Support/Inbox/Replier.php
Normal file
61
app/Support/Inbox/Replier.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Inbox;
|
||||
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Mail\ApplicationReplyMail;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationReply;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Answering a submission: sending it when this application can send mail,
|
||||
* handing it to the person's own mail program when it cannot, and keeping a
|
||||
* record either way.
|
||||
*
|
||||
* "Can send" is a mailer that actually delivers. `log` and `array` accept a
|
||||
* message and drop it, so with either of them a reply "sent from here" would
|
||||
* be one nobody received.
|
||||
*/
|
||||
final class Replier
|
||||
{
|
||||
public static function canSendMail(): bool
|
||||
{
|
||||
return ! in_array(config('mail.default'), ['log', 'array', null], true);
|
||||
}
|
||||
|
||||
public static function reply(Application $application, string $subject, string $body, User $by): ApplicationReply
|
||||
{
|
||||
$send = self::canSendMail();
|
||||
|
||||
$reply = $application->replies()->create([
|
||||
'user_id' => $by->id,
|
||||
'user_name' => $by->name,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'channel' => $send ? ApplicationReply::MAIL : ApplicationReply::MAILTO,
|
||||
]);
|
||||
|
||||
if ($send) {
|
||||
Mail::to($application->email, $application->name)->send(new ApplicationReplyMail($reply));
|
||||
}
|
||||
|
||||
// Answered is at least contacted; a decision already taken stays.
|
||||
if ($application->status === ApplicationStatus::New) {
|
||||
$application->markAs(ApplicationStatus::Contacted, $by);
|
||||
}
|
||||
|
||||
return $reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same answer, for the person's own mail program to send.
|
||||
*/
|
||||
public static function mailto(Application $application, string $subject, string $body): string
|
||||
{
|
||||
return 'mailto:'.$application->email.'?subject='.rawurlencode($subject).'&body='.rawurlencode($body);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user