diff --git a/README.md b/README.md index cf2d00d..b8ad80e 100644 --- a/README.md +++ b/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 diff --git a/app/Filament/Resources/Applications/Pages/ViewApplication.php b/app/Filament/Resources/Applications/Pages/ViewApplication.php index dcfe0c1..9476f59 100644 --- a/app/Filament/Resources/Applications/Pages/ViewApplication.php +++ b/app/Filament/Resources/Applications/Pages/ViewApplication.php @@ -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(), diff --git a/app/Filament/Resources/Applications/Schemas/ApplicationInfolist.php b/app/Filament/Resources/Applications/Schemas/ApplicationInfolist.php index b83fb0c..682741e 100644 --- a/app/Filament/Resources/Applications/Schemas/ApplicationInfolist.php +++ b/app/Filament/Resources/Applications/Schemas/ApplicationInfolist.php @@ -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) diff --git a/app/Filament/Resources/ReplyTemplates/Pages/CreateReplyTemplate.php b/app/Filament/Resources/ReplyTemplates/Pages/CreateReplyTemplate.php new file mode 100644 index 0000000..0da2e6a --- /dev/null +++ b/app/Filament/Resources/ReplyTemplates/Pages/CreateReplyTemplate.php @@ -0,0 +1,13 @@ +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'), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/ApplicationReplyController.php b/app/Http/Controllers/Api/V1/ApplicationReplyController.php new file mode 100644 index 0000000..7a1c738 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApplicationReplyController.php @@ -0,0 +1,98 @@ +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 + */ + 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(), + ]; + } +} diff --git a/app/Mail/ApplicationReplyMail.php b/app/Mail/ApplicationReplyMail.php new file mode 100644 index 0000000..6702c5f --- /dev/null +++ b/app/Mail/ApplicationReplyMail.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index fc6ec52..ddf436c 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -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 */ + /** + * @return HasMany + */ + public function replies(): HasMany + { + return $this->hasMany(ApplicationReply::class)->latest('id'); + } + public function handler(): BelongsTo { return $this->belongsTo(User::class, 'handled_by'); diff --git a/app/Models/ApplicationReply.php b/app/Models/ApplicationReply.php new file mode 100644 index 0000000..299f536 --- /dev/null +++ b/app/Models/ApplicationReply.php @@ -0,0 +1,31 @@ + + */ + public function application(): BelongsTo + { + return $this->belongsTo(Application::class); + } +} diff --git a/app/Models/ReplyTemplate.php b/app/Models/ReplyTemplate.php new file mode 100644 index 0000000..4ffee38 --- /dev/null +++ b/app/Models/ReplyTemplate.php @@ -0,0 +1,64 @@ + + */ + 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)), + ]; + } +} diff --git a/app/Policies/ReplyTemplatePolicy.php b/app/Policies/ReplyTemplatePolicy.php new file mode 100644 index 0000000..de0677e --- /dev/null +++ b/app/Policies/ReplyTemplatePolicy.php @@ -0,0 +1,44 @@ +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'); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 2b50eb5..2f0e3df 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -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. diff --git a/app/Support/Inbox/Replier.php b/app/Support/Inbox/Replier.php new file mode 100644 index 0000000..8760c63 --- /dev/null +++ b/app/Support/Inbox/Replier.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/config/mail.php b/config/mail.php index e32e88d..f4f3397 100644 --- a/config/mail.php +++ b/config/mail.php @@ -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'))), + ], + ]; diff --git a/database/factories/ReplyTemplateFactory.php b/database/factories/ReplyTemplateFactory.php new file mode 100644 index 0000000..b303a91 --- /dev/null +++ b/database/factories/ReplyTemplateFactory.php @@ -0,0 +1,28 @@ + + */ +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}", + ], + ]; + } +} diff --git a/database/migrations/2026_10_01_120000_create_reply_tables.php b/database/migrations/2026_10_01_120000_create_reply_tables.php new file mode 100644 index 0000000..94d3a53 --- /dev/null +++ b/database/migrations/2026_10_01_120000_create_reply_tables.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 3646a85..6692bc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/lang/ca/panel.php b/lang/ca/panel.php index 50e78bd..e18c6af 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -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', diff --git a/lang/en/panel.php b/lang/en/panel.php index fa13b2d..c1a390f 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -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', diff --git a/lang/es/panel.php b/lang/es/panel.php index dec293f..ad6c25b 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -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', diff --git a/public/openapi.yaml b/public/openapi.yaml index 6a3f979..7737b56 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -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] diff --git a/resources/views/mail/application-reply.blade.php b/resources/views/mail/application-reply.blade.php new file mode 100644 index 0000000..e264268 --- /dev/null +++ b/resources/views/mail/application-reply.blade.php @@ -0,0 +1 @@ +{!! $reply->body !!} diff --git a/routes/api.php b/routes/api.php index 81eccd5..f381cea 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index e7c878f..962f930 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -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'], diff --git a/tests/Feature/Panel/InboxRepliesTest.php b/tests/Feature/Panel/InboxRepliesTest.php new file mode 100644 index 0000000..669465a --- /dev/null +++ b/tests/Feature/Panel/InboxRepliesTest.php @@ -0,0 +1,139 @@ +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}'); + } +}