#15 - maintainer email template #20

Merged
Martas merged 1 commits from feat/15-maintainer-email-template into main 2026-06-16 08:34:07 +00:00
3 changed files with 78 additions and 7 deletions
Showing only changes of commit 1c2a176d97 - Show all commits
+4
View File
@@ -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}`.
+62 -5
View File
@@ -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<string, mixed>, 1: array<string, mixed>} 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);
+10
View File
@@ -167,6 +167,8 @@ class RsvFormsPage extends RsvAdminPage {
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
maintainer_subject: <?= json_encode('Nová rezervace čeká na schválení') ?>,
maintainer_body: <?= json_encode("<h1>Nový požadavek o rezervaci</h1>\n<p>Rezervace č. {{reservation_id}} čeká na vaše schválení.</p>\n<p><strong>Datum:</strong> {{date}}</p>\n<p><strong>Čas:</strong> {{start}} {{end}}</p>\n<p><reservation-actions></reservation-actions></p>") ?>,
};
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,6 +304,7 @@ class RsvFormsPage extends RsvAdminPage {
.input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const maintainer = data?.email_templates?.maintainer ?? {};
const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [
@@ -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)