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:
14
README.md
14
README.md
@@ -1165,6 +1165,20 @@ abre en cuanto se crea. Antes la copia salía sin slug —un registro sin
|
||||
dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una
|
||||
página publicada daba otra «publicada» sin URL.
|
||||
|
||||
**Responder al buzón** — en la ficha de cada solicitud, «Responder» abre un
|
||||
mensaje con el asunto ya puesto y, si se quiere, una **plantilla** (Colectivo →
|
||||
Plantillas de respuesta) rellenada en el idioma en que escribió esa persona:
|
||||
`{name}`, `{subject}` y `{sender}` se sustituyen. Se edita y se envía. Si la
|
||||
instalación tiene un mailer que entrega de verdad (no `log` ni `array`), sale
|
||||
desde la aplicación; si no, se abre el programa de correo de quien responde con
|
||||
el texto ya escrito. En los dos casos **queda registrada** en la ficha —quién,
|
||||
cuándo, qué— para que la siguiente persona que la abra sepa qué se dijo, y una
|
||||
solicitud nueva pasa a «contactada» (una ya aceptada o rechazada no cambia). El
|
||||
botón de siempre, un correo en blanco, sigue al lado. Por API:
|
||||
`GET /api/v1/applications/{id}/replies`, `POST /api/v1/applications/{id}/reply`
|
||||
(con `template_id`, o `subject` y `body`) y `GET /api/v1/reply-templates`.
|
||||
`MAIL_REPLY_TO_ADDRESS` decide a dónde vuelven las contestaciones.
|
||||
|
||||
**Calendario editorial** (Contenido → Calendario editorial) — un mes de un
|
||||
vistazo: lo que se publicó o se publicará (programado), lo que sale de la web,
|
||||
y las convocatorias que abren y cierran, cada cosa enlazada a su formulario. Las
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -115,4 +115,11 @@ return [
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
// Where answers to the inbox's replies go back to. Unset, they go to the
|
||||
// "from" address.
|
||||
'reply_to' => [
|
||||
'address' => env('MAIL_REPLY_TO_ADDRESS'),
|
||||
'name' => env('MAIL_REPLY_TO_NAME', env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel'))),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
28
database/factories/ReplyTemplateFactory.php
Normal file
28
database/factories/ReplyTemplateFactory.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\ReplyTemplate;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ReplyTemplate>
|
||||
*/
|
||||
class ReplyTemplateFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->words(3, true),
|
||||
'kind' => null,
|
||||
'subject' => ['es' => 'Gracias, {name}', 'en' => 'Thank you, {name}', 'ca' => 'Gràcies, {name}'],
|
||||
'body' => [
|
||||
'es' => "Hola {name}:\n\nGracias por escribirnos sobre «{subject}».\n\n{sender}",
|
||||
'en' => "Hi {name},\n\nThank you for writing to us about \"{subject}\".\n\n{sender}",
|
||||
'ca' => "Hola {name}:\n\nGràcies per escriure'ns sobre «{subject}».\n\n{sender}",
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Answering the inbox from the panel: templates for the answers that are
|
||||
* nearly always the same, and a record of each answer sent.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('reply_templates', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
|
||||
// `contribute` or `request`, or null for either.
|
||||
$table->string('kind', 20)->nullable();
|
||||
|
||||
$table->jsonb('subject');
|
||||
$table->jsonb('body');
|
||||
$table->unsignedInteger('position')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('application_replies', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('application_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('user_name')->nullable();
|
||||
$table->string('subject');
|
||||
$table->text('body');
|
||||
|
||||
// `mail` when this application sent it, `mailto` when it was
|
||||
// handed to the person's own mail program to send.
|
||||
$table->string('channel', 10);
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('application_replies');
|
||||
Schema::dropIfExists('reply_templates');
|
||||
}
|
||||
};
|
||||
@@ -130,6 +130,7 @@ x-app-env: &app-env
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-admin@example.org}
|
||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Pireon Admin}
|
||||
MAIL_REPLY_TO_ADDRESS: ${MAIL_REPLY_TO_ADDRESS:-}
|
||||
|
||||
services:
|
||||
landing-app:
|
||||
|
||||
@@ -144,6 +144,43 @@ return [
|
||||
'select_all' => 'Selecciona-ho tot',
|
||||
],
|
||||
|
||||
'replies' => [
|
||||
'empty_template' => 'La plantilla no té text en l\'idioma d\'aquesta sol·licitud.',
|
||||
'heading' => 'Respondre a :name',
|
||||
'sends' => 'S\'envia des de l\'aplicació, en l\'idioma en què va escriure, i queda registrada aquí.',
|
||||
'hands_over' => 'Aquesta instal·lació no envia correu: s\'obrirà el teu programa de correu amb el text ja escrit, i la resposta queda registrada aquí.',
|
||||
'send' => 'Enviar',
|
||||
'open_mail' => 'Obrir al meu correu',
|
||||
'sent' => 'Resposta enviada',
|
||||
'recorded' => 'Resposta registrada; acaba d\'enviar-la des del teu correu',
|
||||
'elsewhere' => 'Respondre des del meu correu',
|
||||
'no_template' => 'Sense plantilla',
|
||||
'history' => 'Respostes',
|
||||
'by' => ':name, :when',
|
||||
'in_language' => 'Va escriure en :language: millor contestar en aquest idioma.',
|
||||
'channels' => [
|
||||
'mail' => 'enviada des d\'aquí',
|
||||
'mailto' => 'des del correu de qui va respondre',
|
||||
],
|
||||
'fields' => [
|
||||
'template' => 'Plantilla',
|
||||
'subject' => 'Assumpte',
|
||||
'body' => 'Missatge',
|
||||
],
|
||||
'templates' => [
|
||||
'label' => 'Plantilla de resposta',
|
||||
'plural' => 'Plantilles de resposta',
|
||||
'any_kind' => 'Qualsevol tipus',
|
||||
'placeholders' => 'A l\'assumpte i al missatge se substitueixen :list (qui va escriure, sobre què, i qui respon).',
|
||||
'fields' => [
|
||||
'name' => 'Nom',
|
||||
'name_hint' => 'Per triar-la a la llista; no s\'envia.',
|
||||
'kind' => 'Per a',
|
||||
'position' => 'Ordre',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'statistics' => [
|
||||
'title' => 'Estadístiques',
|
||||
'period' => 'Període',
|
||||
|
||||
@@ -144,6 +144,43 @@ return [
|
||||
'select_all' => 'Select all',
|
||||
],
|
||||
|
||||
'replies' => [
|
||||
'empty_template' => 'The template has no text in the language of this submission.',
|
||||
'heading' => 'Reply to :name',
|
||||
'sends' => 'Sent by this application, in the language they wrote in, and recorded here.',
|
||||
'hands_over' => 'This installation does not send mail: your mail program will open with the text already written, and the reply is recorded here.',
|
||||
'send' => 'Send',
|
||||
'open_mail' => 'Open in my mail',
|
||||
'sent' => 'Reply sent',
|
||||
'recorded' => 'Reply recorded; finish sending it from your mail',
|
||||
'elsewhere' => 'Reply from my mail',
|
||||
'no_template' => 'No template',
|
||||
'history' => 'Replies',
|
||||
'by' => ':name, :when',
|
||||
'in_language' => 'They wrote in :language: better to answer in it.',
|
||||
'channels' => [
|
||||
'mail' => 'sent from here',
|
||||
'mailto' => 'from the replier\'s mail',
|
||||
],
|
||||
'fields' => [
|
||||
'template' => 'Template',
|
||||
'subject' => 'Subject',
|
||||
'body' => 'Message',
|
||||
],
|
||||
'templates' => [
|
||||
'label' => 'Reply template',
|
||||
'plural' => 'Reply templates',
|
||||
'any_kind' => 'Any kind',
|
||||
'placeholders' => 'In the subject and message, :list are filled in (who wrote, what about, and who answers).',
|
||||
'fields' => [
|
||||
'name' => 'Name',
|
||||
'name_hint' => 'To pick it from the list; never sent.',
|
||||
'kind' => 'For',
|
||||
'position' => 'Order',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'statistics' => [
|
||||
'title' => 'Statistics',
|
||||
'period' => 'Period',
|
||||
|
||||
@@ -144,6 +144,43 @@ return [
|
||||
'select_all' => 'Seleccionar todos',
|
||||
],
|
||||
|
||||
'replies' => [
|
||||
'empty_template' => 'La plantilla no tiene texto en el idioma de esta solicitud.',
|
||||
'heading' => 'Responder a :name',
|
||||
'sends' => 'Se envía desde la aplicación, en el idioma en que escribió, y queda registrada aquí.',
|
||||
'hands_over' => 'Esta instalación no envía correo: se abrirá tu programa de correo con el texto ya escrito, y la respuesta queda registrada aquí.',
|
||||
'send' => 'Enviar',
|
||||
'open_mail' => 'Abrir en mi correo',
|
||||
'sent' => 'Respuesta enviada',
|
||||
'recorded' => 'Respuesta registrada; termina de enviarla desde tu correo',
|
||||
'elsewhere' => 'Responder desde mi correo',
|
||||
'no_template' => 'Sin plantilla',
|
||||
'history' => 'Respuestas',
|
||||
'by' => ':name, :when',
|
||||
'in_language' => 'Escribió en :language: mejor contestar en ese idioma.',
|
||||
'channels' => [
|
||||
'mail' => 'enviada desde aquí',
|
||||
'mailto' => 'desde el correo de quien respondió',
|
||||
],
|
||||
'fields' => [
|
||||
'template' => 'Plantilla',
|
||||
'subject' => 'Asunto',
|
||||
'body' => 'Mensaje',
|
||||
],
|
||||
'templates' => [
|
||||
'label' => 'Plantilla de respuesta',
|
||||
'plural' => 'Plantillas de respuesta',
|
||||
'any_kind' => 'Cualquier tipo',
|
||||
'placeholders' => 'En el asunto y el mensaje se sustituyen :list (nombre de quien escribió, a qué escribió, y quien responde).',
|
||||
'fields' => [
|
||||
'name' => 'Nombre',
|
||||
'name_hint' => 'Para elegirla en la lista; no se envía.',
|
||||
'kind' => 'Para',
|
||||
'position' => 'Orden',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'statistics' => [
|
||||
'title' => 'Estadísticas',
|
||||
'period' => 'Periodo',
|
||||
|
||||
@@ -4143,6 +4143,89 @@ paths:
|
||||
slug: { type: object, additionalProperties: { type: string } }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/applications/{application}/replies:
|
||||
get:
|
||||
tags: [Applications]
|
||||
summary: Replies given to an application
|
||||
description: Newest first.
|
||||
parameters:
|
||||
- name: application
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
"200":
|
||||
description: The replies
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/ApplicationReply" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/applications/{application}/reply:
|
||||
post:
|
||||
tags: [Applications]
|
||||
summary: Reply to an application
|
||||
description: |
|
||||
`subject` and `body`, or a `template_id` to fill whichever is left out
|
||||
— in the language the person wrote in. Sent by this application when
|
||||
it has a mailer that delivers; otherwise recorded, and the answer
|
||||
carries a `mailto` link to send it by hand. Moves a new application to
|
||||
`contacted`.
|
||||
parameters:
|
||||
- name: application
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
template_id: { type: integer, nullable: true }
|
||||
subject: { type: string, maxLength: 255 }
|
||||
body: { type: string }
|
||||
responses:
|
||||
"201":
|
||||
description: Reply recorded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ApplicationReply"
|
||||
- type: object
|
||||
properties:
|
||||
mailto: { type: string, nullable: true }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/v1/reply-templates:
|
||||
get:
|
||||
tags: [Applications]
|
||||
summary: Reply templates
|
||||
description: Every template, in every language. Managed in the panel.
|
||||
responses:
|
||||
"200":
|
||||
description: The templates
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/ReplyTemplate" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/v1/media:
|
||||
get:
|
||||
tags: [Media]
|
||||
@@ -6067,6 +6150,29 @@ components:
|
||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||
|
||||
ApplicationReply:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
subject: { type: string }
|
||||
body: { type: string }
|
||||
channel: { type: string, enum: [mail, mailto], description: "`mail` when sent from here; `mailto` when handed to the replier's mail program." }
|
||||
author:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, nullable: true }
|
||||
name: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
ReplyTemplate:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
kind: { type: string, nullable: true, enum: [contribute, request] }
|
||||
subject: { type: object, additionalProperties: { type: string }, description: "Language → text. `{name}`, `{subject}` and `{sender}` are filled in." }
|
||||
body: { type: object, additionalProperties: { type: string } }
|
||||
|
||||
MediaFor:
|
||||
type: string
|
||||
enum: [pages, technologies, projects, members, clients, testimonials]
|
||||
|
||||
1
resources/views/mail/application-reply.blade.php
Normal file
1
resources/views/mail/application-reply.blade.php
Normal file
@@ -0,0 +1 @@
|
||||
{!! $reply->body !!}
|
||||
@@ -13,6 +13,7 @@ use App\Http\Controllers\Api\PublicApi\V1\SiteController;
|
||||
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
|
||||
use App\Http\Controllers\Api\V1\ApiTokenController;
|
||||
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController;
|
||||
use App\Http\Controllers\Api\V1\ApplicationReplyController;
|
||||
use App\Http\Controllers\Api\V1\AuditController;
|
||||
use App\Http\Controllers\Api\V1\BatchController;
|
||||
use App\Http\Controllers\Api\V1\CallController;
|
||||
@@ -223,6 +224,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
// The inbox. Submissions arrive from the public site, never from here.
|
||||
Route::apiResource('applications', AdminApplicationController::class)
|
||||
->only(['index', 'show', 'update', 'destroy']);
|
||||
Route::get('applications/{application}/replies', [ApplicationReplyController::class, 'index'])->name('applications.replies.index');
|
||||
Route::post('applications/{application}/reply', [ApplicationReplyController::class, 'store'])->name('applications.replies.store');
|
||||
Route::get('reply-templates', [ApplicationReplyController::class, 'templates'])->name('reply-templates.index');
|
||||
Route::post('applications/{application}/restore', [AdminApplicationController::class, 'restore'])
|
||||
->withTrashed()
|
||||
->name('applications.restore');
|
||||
|
||||
@@ -15,6 +15,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\Setting;
|
||||
@@ -115,6 +116,8 @@ class AdminApiContractTest extends TestCase
|
||||
$talk = Talk::factory()->create();
|
||||
$call = Call::factory()->create();
|
||||
$application = Application::factory()->for($call)->create();
|
||||
$application->replies()->create(['user_id' => $this->caller->id, 'user_name' => $this->caller->name, 'subject' => 'Re', 'body' => 'Hola', 'channel' => 'mail']);
|
||||
ReplyTemplate::factory()->create();
|
||||
$redirect = Redirect::factory()->create();
|
||||
$missingUrl = MissingUrl::factory()->create();
|
||||
$repository = Repository::factory()->create();
|
||||
@@ -153,6 +156,8 @@ class AdminApiContractTest extends TestCase
|
||||
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
|
||||
'applications' => ['/v1/applications', '/v1/applications'],
|
||||
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
|
||||
'replies' => ["/v1/applications/{$application->id}/replies", '/v1/applications/{application}/replies'],
|
||||
'reply templates' => ['/v1/reply-templates', '/v1/reply-templates'],
|
||||
'redirects' => ['/v1/redirects', '/v1/redirects'],
|
||||
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
|
||||
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],
|
||||
|
||||
139
tests/Feature/Panel/InboxRepliesTest.php
Normal file
139
tests/Feature/Panel/InboxRepliesTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Filament\Resources\Applications\Pages\ViewApplication;
|
||||
use App\Filament\Resources\ReplyTemplates\Pages\CreateReplyTemplate;
|
||||
use App\Filament\Resources\ReplyTemplates\ReplyTemplateResource;
|
||||
use App\Mail\ApplicationReplyMail;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationReply;
|
||||
use App\Models\ReplyTemplate;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Answering the inbox from the panel and the API, with templates.
|
||||
*/
|
||||
class InboxRepliesTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Mail::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_template_is_filled_in_the_language_they_wrote_in(): void
|
||||
{
|
||||
$template = ReplyTemplate::factory()->create();
|
||||
$application = Application::factory()->create(['name' => 'Ada', 'locale' => 'en']);
|
||||
|
||||
$text = $template->renderFor($application, $this->userWithRoles('editor', ['name' => 'Linus']));
|
||||
|
||||
$this->assertSame('Thank you, Ada', $text['subject']);
|
||||
$this->assertStringContainsString('Linus', $text['body']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function with_a_real_mailer_the_reply_is_sent_and_recorded(): void
|
||||
{
|
||||
config(['mail.default' => 'smtp']);
|
||||
|
||||
$this->actingAs($editor = $this->userWithRoles('editor'));
|
||||
|
||||
$template = ReplyTemplate::factory()->create();
|
||||
$application = Application::factory()->create(['name' => 'Ada', 'locale' => 'es', 'email' => 'ada@example.org']);
|
||||
|
||||
Livewire::test(ViewApplication::class, ['record' => $application->getRouteKey()])
|
||||
->callAction('reply', ['template_id' => $template->id, 'subject' => 'Gracias, Ada', 'body' => 'Hola Ada'])
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
Mail::assertSent(ApplicationReplyMail::class, fn (ApplicationReplyMail $mail) => $mail->hasTo('ada@example.org'));
|
||||
|
||||
$reply = $application->replies()->sole();
|
||||
|
||||
$this->assertSame(ApplicationReply::MAIL, $reply->channel);
|
||||
$this->assertSame($editor->id, $reply->user_id);
|
||||
$this->assertSame(ApplicationStatus::Contacted, $application->fresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function without_one_it_is_recorded_and_handed_to_the_mail_program(): void
|
||||
{
|
||||
config(['mail.default' => 'log']);
|
||||
|
||||
$this->actingAs($this->userWithRoles('editor'));
|
||||
|
||||
$application = Application::factory()->create();
|
||||
|
||||
Livewire::test(ViewApplication::class, ['record' => $application->getRouteKey()])
|
||||
->callAction('reply', ['subject' => 'Re', 'body' => 'Gracias'])
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
$this->assertSame(ApplicationReply::MAILTO, $application->replies()->sole()->channel);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_decision_already_taken_is_not_undone_by_replying(): void
|
||||
{
|
||||
config(['mail.default' => 'smtp']);
|
||||
|
||||
$this->actingAs($this->userWithRoles('editor'));
|
||||
|
||||
$application = Application::factory()->create(['status' => ApplicationStatus::Accepted]);
|
||||
|
||||
Livewire::test(ViewApplication::class, ['record' => $application->getRouteKey()])
|
||||
->callAction('reply', ['subject' => 'Bienvenida', 'body' => '¡Adelante!']);
|
||||
|
||||
$this->assertSame(ApplicationStatus::Accepted, $application->fresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function templates_are_written_by_whoever_answers_the_inbox(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('editor'));
|
||||
|
||||
Livewire::test(CreateReplyTemplate::class)
|
||||
->fillForm(['name' => 'Gracias', 'subject' => ['es' => 'Gracias'], 'body' => ['es' => 'Hola {name}']])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$this->assertSame(1, ReplyTemplate::query()->count());
|
||||
|
||||
$this->actingAs($this->userWithRoles('writer'));
|
||||
$this->get(ReplyTemplateResource::getUrl('index'))->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_api_replies_from_a_template_and_lists_replies(): void
|
||||
{
|
||||
config(['mail.default' => 'log']);
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($this->userWithRoles('editor')));
|
||||
|
||||
$template = ReplyTemplate::factory()->create();
|
||||
$application = Application::factory()->create(['name' => 'Ada', 'locale' => 'ca']);
|
||||
|
||||
$this->postJson("/api/v1/applications/{$application->id}/reply", ['template_id' => $template->id])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.subject', 'Gràcies, Ada')
|
||||
->assertJsonPath('data.channel', 'mailto');
|
||||
|
||||
$this->getJson("/api/v1/applications/{$application->id}/replies")->assertJsonCount(1, 'data');
|
||||
$this->getJson('/api/v1/reply-templates')->assertJsonPath('data.0.subject.en', 'Thank you, {name}');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user