From 1c2a176d972823b120c1cf2e84c1dab2cb212234 Mon Sep 17 00:00:00 2001 From: Martin Slachta Date: Tue, 16 Jun 2026 10:33:05 +0200 Subject: [PATCH] #15 - maintainer email template --- FORMS.md | 4 ++ includes/Listeners/RsvEmailListener.php | 67 +++++++++++++++++++++++-- includes/Views/RsvFormsPage.php | 14 +++++- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/FORMS.md b/FORMS.md index 95b0897..71be0db 100644 --- a/FORMS.md +++ b/FORMS.md @@ -6,6 +6,10 @@ The form definition is an array of element it contains. Each element has a *type Keep the form structure and content separate -> when working with the form, take time to figure, if you are working with structure, or existence of something within. +## Rendering + +The rendering currently happens on the backend, but that might be wrong. It renders out structure, but interactive components gets rendered on the frontend anyway. + ## Submitting When user submits the form, the `RsvFormSubmitter.js` is called. It collects the values, which we describe in more detail, and then sends it using `fetch` as POST to `reservations/forms/{form_id}`. diff --git a/includes/Listeners/RsvEmailListener.php b/includes/Listeners/RsvEmailListener.php index 4d6ce89..7ec7d2c 100644 --- a/includes/Listeners/RsvEmailListener.php +++ b/includes/Listeners/RsvEmailListener.php @@ -8,10 +8,12 @@ use Reservair\Templating\RsvTemplateEngine; * * Two responsibilities: * 1. Maintainer approval request — fires per timetable item that needs - * confirmation; template is hardcoded (not admin-configurable). + * confirmation, carrying the accept/refuse links the maintainer acts on. * 2. User notification on form close — fires once per form submission when - * every reservation item reaches a terminal state. Subject/body come from - * the form definition's reservation element `email_templates` attr. + * every reservation item reaches a terminal state. + * + * Both subjects/bodies come from the reservation element's `email_templates` + * attr, falling back to the hardcoded defaults when the admin left them blank. * * Exceptions are swallowed so a mail failure never rolls back a transaction. */ @@ -52,7 +54,15 @@ class RsvEmailListener { $start_dt = (clone $event->reservation->start_utc)->setTimezone($tz); $end_dt = (clone $event->reservation->end_utc)->setTimezone($tz); - $body = $engine->render(self::DEFAULT_PENDING_BODY, [ + [$tpl, $form_values] = self::resolve_maintainer_template($event); + + // Treat blank (admin cleared the field) the same as "use the default". + $subject_tpl = trim((string) ($tpl['subject'] ?? '')) !== '' ? $tpl['subject'] : self::DEFAULT_PENDING_SUBJECT; + $body_tpl = trim((string) ($tpl['body'] ?? '')) !== '' ? $tpl['body'] : self::DEFAULT_PENDING_BODY; + + // Reservation context wins over form values on key collisions, so the + // accept/refuse links can never be shadowed by a submitted field. + $symbols = array_merge($form_values, [ 'reservation_id' => (string) $event->reservation_id, 'date' => $start_dt->format('Y-m-d'), 'start' => $start_dt->format('H:i'), @@ -61,12 +71,59 @@ class RsvEmailListener { 'refuse_url' => get_rest_url(null, 'reservations/v1/timetable-reservation/refuse/' . $event->code), ]); - (new RsvEmailSender())->send($event->maintainer_email, self::DEFAULT_PENDING_SUBJECT, $body); + $subject = sanitize_text_field($engine->render_plain($subject_tpl, $symbols)); + $body = $engine->render($body_tpl, $symbols); + + (new RsvEmailSender())->send($event->maintainer_email, $subject, $body); } catch (\Throwable $e) { Logger::error($e); } } + /** + * Resolves the admin-authored maintainer template for the timetable this + * reservation belongs to, together with the submission's field values so the + * template can reference the submitter's answers. + * + * @return array{0: array, 1: array} The + * maintainer template (empty when none is configured) and form values. + */ + private static function resolve_maintainer_template(RsvTimetableReservationPendingEvent $event): array { + $form_submit_id = (new RsvReservationRepository())->get_form_submit_id($event->reservation_id); + if ($form_submit_id === null) { + return [[], []]; + } + + $form_submit = (new RsvFormSubmitRepository())->get($form_submit_id); + if ($form_submit === null) { + return [[], []]; + } + + $form_values = is_array($form_submit['values'] ?? null) ? $form_submit['values'] : []; + + $definition = (new RsvFormDefinitionRepository())->get((int) $form_submit['form_id']); + if ($definition === null) { + return [[], $form_values]; + } + + $elements = $definition['definition']['elements'] ?? []; + $reservation_element = null; + foreach ($elements as $el) { + if (!is_array($el) || ($el['type'] ?? '') !== 'reservation') { + continue; + } + $reservation_element ??= $el; // fall back to the first reservation element + if ((int) ($el['timetable_id'] ?? 0) === $event->reservation->timetable_id) { + $reservation_element = $el; + break; + } + } + + $template = $reservation_element['email_templates']['maintainer'] ?? []; + + return [is_array($template) ? $template : [], $form_values]; + } + public static function on_form_submit_closed(RsvFormSubmitClosedEvent $event): void { try { $form_submit = (new RsvFormSubmitRepository())->get($event->form_submit_id); diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php index 84ca30e..593044b 100644 --- a/includes/Views/RsvFormsPage.php +++ b/includes/Views/RsvFormsPage.php @@ -167,6 +167,8 @@ class RsvFormsPage extends RsvAdminPage { accepted_body: Vaše rezervace byla přijata\n

Vaše rezervace byla schválena. Těšíme se na vás!

") ?>, refused_subject: , refused_body: Vaše rezervace byla zamítnuta\n

Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.

") ?>, + maintainer_subject: , + maintainer_body: Nový požadavek o rezervaci\n

Rezervace č. {{reservation_id}} čeká na vaše schválení.

\n

Datum: {{date}}

\n

Čas: {{start}} – {{end}}

\n

") ?>, }; const rsv_elements_source = (function(initial_items, next_id_start) { @@ -205,6 +207,10 @@ class RsvFormsPage extends RsvAdminPage { timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null, price_per_block: parseFloat(data.price_per_block ?? '0') || 0, email_templates: { + maintainer: { + subject: data.email_maintainer_subject ?? RSV_EMAIL_DEFAULTS.maintainer_subject, + body: data.email_maintainer_body ?? RSV_EMAIL_DEFAULTS.maintainer_body, + }, on_accepted: { enabled: !!data.email_accepted_enabled, subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject, @@ -298,8 +304,9 @@ class RsvFormsPage extends RsvAdminPage { .input_checkbox('required', 'Required', data?.required ?? false); if ((data?.type ?? rsv_element_types[0]) === 'reservation') { - const accepted = data?.email_templates?.on_accepted ?? {}; - const refused = data?.email_templates?.on_refused ?? {}; + const maintainer = data?.email_templates?.maintainer ?? {}; + const accepted = data?.email_templates?.on_accepted ?? {}; + const refused = data?.email_templates?.on_refused ?? {}; const timetable_options = [ { value: '', label: '— none —' }, ...rsv_timetables.map(t => ({ value: t.id, label: t.name })), @@ -307,6 +314,9 @@ class RsvFormsPage extends RsvAdminPage { builder .input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '') .input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0) + .fieldset('Email - for maintainer', '100%') + .input_text('email_maintainer_subject', 'Subject', maintainer.subject ?? RSV_EMAIL_DEFAULTS.maintainer_subject) + .input_textarea('email_maintainer_body', 'Body', maintainer.body ?? RSV_EMAIL_DEFAULTS.maintainer_body) .fieldset('Email — accepted', '100%') .input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true) .input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject) -- 2.52.0