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:
2026-10-01 10:45:47 +02:00
parent 697d333ad4
commit 76d022053b
27 changed files with 1096 additions and 0 deletions

View File

@@ -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 dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una
página publicada daba otra «publicada» sin URL. 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 **Calendario editorial** (Contenido → Calendario editorial) — un mes de un
vistazo: lo que se publicó o se publicará (programado), lo que sale de la web, 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 y las convocatorias que abren y cierran, cada cosa enlazada a su formulario. Las

View File

@@ -7,13 +7,19 @@ namespace App\Filament\Resources\Applications\Pages;
use App\Filament\Resources\Applications\ApplicationResource; use App\Filament\Resources\Applications\ApplicationResource;
use App\Filament\Resources\Applications\Tables\ApplicationsTable; use App\Filament\Resources\Applications\Tables\ApplicationsTable;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationReply;
use App\Models\ReplyTemplate;
use App\Support\Inbox\Replier;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction; use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction; use Filament\Actions\RestoreAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Support\Facades\Auth;
class ViewApplication extends ViewRecord class ViewApplication extends ViewRecord
{ {
@@ -24,10 +30,67 @@ class ViewApplication extends ViewRecord
return [ return [
// Answering is the point of the record, so the reply is the first // Answering is the point of the record, so the reply is the first
// button rather than something to copy out of the page. // 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') Action::make('reply')
->label(__('panel.applications.actions.reply')) ->label(__('panel.applications.actions.reply'))
->icon('heroicon-o-envelope') ->icon('heroicon-o-envelope')
->color('primary') ->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=' ->url(fn (Application $record) => 'mailto:'.$record->email.'?subject='
.rawurlencode(__('panel.applications.reply_subject', [ .rawurlencode(__('panel.applications.reply_subject', [
'subject' => $record->subject(), 'subject' => $record->subject(),

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\Applications\Schemas; namespace App\Filament\Resources\Applications\Schemas;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationReply;
use Filament\Infolists\Components\KeyValueEntry; use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@@ -69,6 +71,36 @@ class ApplicationInfolist
->visible(fn (Application $record) => filled($record->links)), ->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')) Section::make(__('panel.applications.sections.internal'))
->description(__('panel.applications.sections.internal_hint')) ->description(__('panel.applications.sections.internal_hint'))
->columns(4) ->columns(4)

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View 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'),
];
}
}

View 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(),
];
}
}

View 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');
}
}

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
@@ -59,6 +60,14 @@ class Application extends Model
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, $this>
*/ */
/**
* @return HasMany<ApplicationReply, $this>
*/
public function replies(): HasMany
{
return $this->hasMany(ApplicationReply::class)->latest('id');
}
public function handler(): BelongsTo public function handler(): BelongsTo
{ {
return $this->belongsTo(User::class, 'handled_by'); return $this->belongsTo(User::class, 'handled_by');

View 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);
}
}

View 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)),
];
}
}

View 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');
}
}

View File

@@ -14,6 +14,7 @@ use App\Models\MissingUrl;
use App\Models\Page; use App\Models\Page;
use App\Models\Project; use App\Models\Project;
use App\Models\Redirect; use App\Models\Redirect;
use App\Models\ReplyTemplate;
use App\Models\Repository; use App\Models\Repository;
use App\Models\Service; use App\Models\Service;
use App\Models\Talk; use App\Models\Talk;
@@ -31,6 +32,7 @@ use App\Policies\MissingUrlPolicy;
use App\Policies\PagePolicy; use App\Policies\PagePolicy;
use App\Policies\ProjectPolicy; use App\Policies\ProjectPolicy;
use App\Policies\RedirectPolicy; use App\Policies\RedirectPolicy;
use App\Policies\ReplyTemplatePolicy;
use App\Policies\RepositoryPolicy; use App\Policies\RepositoryPolicy;
use App\Policies\RolePolicy; use App\Policies\RolePolicy;
use App\Policies\ServicePolicy; use App\Policies\ServicePolicy;
@@ -66,6 +68,7 @@ class AuthServiceProvider extends ServiceProvider
Gate::policy(User::class, UserPolicy::class); Gate::policy(User::class, UserPolicy::class);
Gate::policy(Role::class, RolePolicy::class); Gate::policy(Role::class, RolePolicy::class);
Gate::policy(Webhook::class, WebhookPolicy::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 // The one place a permission is granted — spatie's own gate check is
// switched off (config/permission.php) so nothing answers before this. // switched off (config/permission.php) so nothing answers before this.

View 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);
}
}

View File

@@ -115,4 +115,11 @@ return [
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), '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'))),
],
]; ];

View 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}",
],
];
}
}

View File

@@ -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');
}
};

View File

@@ -130,6 +130,7 @@ x-app-env: &app-env
MAIL_PASSWORD: ${MAIL_PASSWORD:-} MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-admin@example.org} MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-admin@example.org}
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Pireon Admin} MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Pireon Admin}
MAIL_REPLY_TO_ADDRESS: ${MAIL_REPLY_TO_ADDRESS:-}
services: services:
landing-app: landing-app:

View File

@@ -144,6 +144,43 @@ return [
'select_all' => 'Selecciona-ho tot', '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' => [ 'statistics' => [
'title' => 'Estadístiques', 'title' => 'Estadístiques',
'period' => 'Període', 'period' => 'Període',

View File

@@ -144,6 +144,43 @@ return [
'select_all' => 'Select all', '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' => [ 'statistics' => [
'title' => 'Statistics', 'title' => 'Statistics',
'period' => 'Period', 'period' => 'Period',

View File

@@ -144,6 +144,43 @@ return [
'select_all' => 'Seleccionar todos', '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' => [ 'statistics' => [
'title' => 'Estadísticas', 'title' => 'Estadísticas',
'period' => 'Periodo', 'period' => 'Periodo',

View File

@@ -4143,6 +4143,89 @@ paths:
slug: { type: object, additionalProperties: { type: string } } slug: { type: object, additionalProperties: { type: string } }
"403": { $ref: "#/components/responses/Forbidden" } "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: /v1/media:
get: get:
tags: [Media] tags: [Media]
@@ -6067,6 +6150,29 @@ components:
links: { $ref: "#/components/schemas/PaginationLinks" } links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" } 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: MediaFor:
type: string type: string
enum: [pages, technologies, projects, members, clients, testimonials] enum: [pages, technologies, projects, members, clients, testimonials]

View File

@@ -0,0 +1 @@
{!! $reply->body !!}

View File

@@ -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\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\ApiTokenController; use App\Http\Controllers\Api\V1\ApiTokenController;
use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationController; 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\AuditController;
use App\Http\Controllers\Api\V1\BatchController; use App\Http\Controllers\Api\V1\BatchController;
use App\Http\Controllers\Api\V1\CallController; 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. // The inbox. Submissions arrive from the public site, never from here.
Route::apiResource('applications', AdminApplicationController::class) Route::apiResource('applications', AdminApplicationController::class)
->only(['index', 'show', 'update', 'destroy']); ->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']) Route::post('applications/{application}/restore', [AdminApplicationController::class, 'restore'])
->withTrashed() ->withTrashed()
->name('applications.restore'); ->name('applications.restore');

View File

@@ -15,6 +15,7 @@ use App\Models\MissingUrl;
use App\Models\Page; use App\Models\Page;
use App\Models\Project; use App\Models\Project;
use App\Models\Redirect; use App\Models\Redirect;
use App\Models\ReplyTemplate;
use App\Models\Repository; use App\Models\Repository;
use App\Models\Service; use App\Models\Service;
use App\Models\Setting; use App\Models\Setting;
@@ -115,6 +116,8 @@ class AdminApiContractTest extends TestCase
$talk = Talk::factory()->create(); $talk = Talk::factory()->create();
$call = Call::factory()->create(); $call = Call::factory()->create();
$application = Application::factory()->for($call)->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(); $redirect = Redirect::factory()->create();
$missingUrl = MissingUrl::factory()->create(); $missingUrl = MissingUrl::factory()->create();
$repository = Repository::factory()->create(); $repository = Repository::factory()->create();
@@ -153,6 +156,8 @@ class AdminApiContractTest extends TestCase
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'], 'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
'applications' => ['/v1/applications', '/v1/applications'], 'applications' => ['/v1/applications', '/v1/applications'],
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'], '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'], 'redirects' => ['/v1/redirects', '/v1/redirects'],
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'], 'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'], 'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],

View 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}');
}
}