Files
Teren-app/app/Services/EmailTemplateRenderer.php
T
Simon Pocrnjič 7ab890005b fixed the fixed
2026-05-18 12:37:12 +02:00

260 lines
10 KiB
PHP

<?php
namespace App\Services;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Person\Person;
use Carbon\Carbon;
class EmailTemplateRenderer
{
/**
* Render subject and bodies using a simple {{ key }} replacement.
* Supported entities: client, person, client_case, contract, activity
*
* @param array{subject:string, html?:string|null, text?:string|null} $template
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
* @return array{subject:string, html?:string, text?:string}
*/
public function render(array $template, array $ctx): array
{
$map = $this->buildMap($ctx);
$replacer = static function (?string $input) use ($map): ?string {
if ($input === null) {
return null;
}
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($m) use ($map) {
$key = $m[1];
// body_text is handled separately by applyBodyText(); preserve as literal
if ($key === 'body_text') {
return $m[0];
}
$value = data_get($map, $key, '');
// If the resolved value is an array (e.g. {{ contract.meta }} used directly),
// return empty string instead of triggering "Array to string conversion".
if (is_array($value)) {
return '';
}
return (string) $value;
}, $input);
};
$bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : '';
return [
'subject' => $replacer($template['subject']) ?? '',
'html' => $this->applyBodyText($replacer($template['html'] ?? null) ?? null, $bodyText, html: true),
'text' => $this->applyBodyText($replacer($template['text'] ?? null) ?? null, $bodyText, html: false),
];
}
/**
* Substitute the literal {{body_text}} placeholder with the user-supplied body text.
* In HTML context the text is HTML-escaped and newlines are converted to <br>.
* In plain-text context the raw value is used.
*/
public function applyBodyText(?string $content, string $bodyText, bool $html = true): ?string
{
if ($content === null) {
return null;
}
$replacement = $html
? nl2br(htmlspecialchars($bodyText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
: $bodyText;
return preg_replace('/{{\s*body_text\s*}}/', $replacement, $content);
}
/**
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
*/
protected function buildMap(array $ctx): array
{
$formatDateEu = static function ($value): string {
if ($value === null || $value === '') {
return '';
}
try {
if ($value instanceof \DateTimeInterface) {
return Carbon::instance($value)->format('d.m.Y');
}
// Accept common formats (Y-m-d, Y-m-d H:i:s, etc.)
return Carbon::parse((string) $value)->format('d.m.Y');
} catch (\Throwable $e) {
return (string) $value;
}
};
$formatMoneyEu = static function ($value): string {
if ($value === null || $value === '') {
return '';
}
$num = null;
if (is_numeric($value)) {
$num = (float) $value;
} elseif (is_string($value)) {
// Try to normalize string numbers like "1,234.56" or "1.234,56"
$normalized = str_replace([' ', '\u{00A0}'], '', $value);
$normalized = str_replace(['.', ','], ['.', '.'], $normalized);
$num = is_numeric($normalized) ? (float) $normalized : null;
}
if ($num === null) {
return (string) $value;
}
return number_format($num, 2, ',', '.').' €';
};
$out = [];
if (isset($ctx['client'])) {
$c = $ctx['client'];
$out['client'] = [
'id' => data_get($c, 'id'),
'uuid' => data_get($c, 'uuid'),
// Expose nested person for {{ client.person.full_name }} etc.
'person' => [
'first_name' => data_get($c, 'person.first_name'),
'last_name' => data_get($c, 'person.last_name'),
'full_name' => (function ($c) {
$fn = (string) data_get($c, 'person.first_name', '');
$ln = (string) data_get($c, 'person.last_name', '');
$stored = data_get($c, 'person.full_name');
return (string) ($stored ?: trim(trim($fn.' '.$ln)));
})($c),
'email' => data_get($c, 'person.email'),
'phone' => data_get($c, 'person.phone'),
],
];
}
if (isset($ctx['person'])) {
$p = $ctx['person'];
$out['person'] = [
'first_name' => data_get($p, 'first_name'),
'last_name' => data_get($p, 'last_name'),
'full_name' => trim((data_get($p, 'first_name', '')).' '.(data_get($p, 'last_name', ''))),
'email' => data_get($p, 'email'),
'phone' => data_get($p, 'phone'),
];
}
if (isset($ctx['client_case'])) {
$c = $ctx['client_case'];
$out['case'] = [
'id' => data_get($c, 'id'),
'uuid' => data_get($c, 'uuid'),
'reference' => data_get($c, 'reference'),
// Expose nested person for {{ case.person.full_name }}; prefer direct relation, fallback to client.person
'person' => [
'first_name' => data_get($c, 'person.first_name') ?? data_get($c, 'client.person.first_name'),
'last_name' => data_get($c, 'person.last_name') ?? data_get($c, 'client.person.last_name'),
'full_name' => (function ($c) {
$stored = data_get($c, 'person.full_name') ?? data_get($c, 'client.person.full_name');
if ($stored) {
return (string) $stored;
}
$fn = (string) (data_get($c, 'person.first_name') ?? data_get($c, 'client.person.first_name') ?? '');
$ln = (string) (data_get($c, 'person.last_name') ?? data_get($c, 'client.person.last_name') ?? '');
return trim(trim($fn.' '.$ln));
})($c),
'email' => data_get($c, 'person.email') ?? data_get($c, 'client.person.email'),
'phone' => data_get($c, 'person.phone') ?? data_get($c, 'client.person.phone'),
],
];
}
if (isset($ctx['contract'])) {
$co = $ctx['contract'];
$out['contract'] = [
'id' => data_get($co, 'id'),
'uuid' => data_get($co, 'uuid'),
'reference' => data_get($co, 'reference'),
// Account amounts — sourced from the related Account model
'account' => [
'balance_amount' => $formatMoneyEu(data_get($co, 'account.balance_amount')),
'initial_amount' => $formatMoneyEu(data_get($co, 'account.initial_amount')),
],
];
$meta = data_get($co, 'meta');
if (is_string($meta)) {
$meta = json_decode($meta, true) ?? [];
}
if (is_array($meta)) {
$out['contract']['meta'] = $this->flattenMetaForTemplate($meta);
}
}
if (isset($ctx['activity'])) {
$a = $ctx['activity'];
$out['activity'] = [
'id' => data_get($a, 'id'),
'note' => data_get($a, 'note'),
// EU formatted date and amount by default in emails
'due_date' => $formatDateEu(data_get($a, 'due_date')),
'amount' => $formatMoneyEu(data_get($a, 'amount')),
'action' => [
'name' => data_get($a, 'action.name'),
],
'decision' => [
'name' => data_get($a, 'decision.name'),
],
];
}
if (! empty($ctx['extra']) && is_array($ctx['extra'])) {
$out['extra'] = $ctx['extra'];
}
if (isset($ctx['mail_profile'])) {
$mp = $ctx['mail_profile'];
$out['profile'] = [
'signature' => is_array($mp->signature) ? $mp->signature : [],
];
}
return $out;
}
/**
* Flatten a contract meta array so every leaf value is accessible by its bare key.
*
* Handles three formats stored in the wild:
* 1. Numeric wrapper: { "1": { "sklic": "SI00…", "job_days": 1 } }
* → { "sklic": "SI00…", "job_days": 1 }
* 2. Structured entry: { "sklic": { "value": "SI00…", "type": "string" } }
* → { "sklic": "SI00…" }
* 3. Already flat: { "sklic": "SI00…" }
* → { "sklic": "SI00…" }
*/
private function flattenMetaForTemplate(array $meta): array
{
$flat = [];
foreach ($meta as $key => $item) {
if (!is_array($item)) {
// Plain scalar — keep as-is (format 3)
if (!array_key_exists($key, $flat)) {
$flat[$key] = $item;
}
} elseif (array_key_exists('value', $item)) {
// Structured { value, type, title } entry (format 2)
$flat[$key] = $item['value'];
} elseif (is_numeric($key)) {
// Numeric wrapper key — recurse and alias without the prefix (format 1)
foreach ($this->flattenMetaForTemplate($item) as $nk => $nv) {
if (!array_key_exists($nk, $flat)) {
$flat[$nk] = $nv;
}
}
}
// Non-numeric nested arrays without a 'value' key are silently skipped
}
return $flat;
}
}