diff --git a/app/Http/Controllers/Admin/EmailLogController.php b/app/Http/Controllers/Admin/EmailLogController.php new file mode 100644 index 0000000..2f839e6 --- /dev/null +++ b/app/Http/Controllers/Admin/EmailLogController.php @@ -0,0 +1,72 @@ +authorize('viewAny', EmailTemplate::class); // reuse same permission gate for admin area + + $query = EmailLog::query() + ->with(['template:id,name']) + ->orderByDesc('created_at'); + + $status = trim((string) $request->input('status', '')); + if ($status !== '') { + $query->where('status', $status); + } + if ($email = trim((string) $request->input('to'))) { + $query->where('to_email', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $email).'%'); + } + if ($subject = trim((string) $request->input('subject'))) { + $query->where('subject', 'like', '%'.str_replace(['%', '_'], ['\%', '\_'], $subject).'%'); + } + if ($templateId = (int) $request->input('template_id')) { + $query->where('template_id', $templateId); + } + if ($from = $request->date('date_from')) { + $query->whereDate('created_at', '>=', $from); + } + if ($to = $request->date('date_to')) { + $query->whereDate('created_at', '<=', $to); + } + + $logs = $query->paginate(20)->withQueryString(); + $templates = EmailTemplate::query()->orderBy('name')->get(['id', 'name']); + + return Inertia::render('Admin/EmailLogs/Index', [ + 'logs' => $logs, + 'filters' => [ + 'status' => $status, + 'to' => $email ?? '', + 'subject' => $subject ?? '', + 'template_id' => $templateId ?: null, + 'date_from' => $request->input('date_from'), + 'date_to' => $request->input('date_to'), + ], + 'templates' => $templates, + ]); + } + + public function show(EmailLog $emailLog): Response + { + $this->authorize('viewAny', EmailTemplate::class); + + $emailLog->load(['template:id,name', 'body']); + + return Inertia::render('Admin/EmailLogs/Show', [ + 'log' => $emailLog, + ]); + } +} diff --git a/app/Http/Controllers/Admin/EmailTemplateController.php b/app/Http/Controllers/Admin/EmailTemplateController.php index 97e9cf1..65cebe4 100644 --- a/app/Http/Controllers/Admin/EmailTemplateController.php +++ b/app/Http/Controllers/Admin/EmailTemplateController.php @@ -4,26 +4,24 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StoreEmailTemplateRequest; -use App\Http\Requests\UpdateEmailTemplateRequest; +use App\Jobs\SendEmailTemplateJob; +use App\Models\Activity; use App\Models\Client; use App\Models\ClientCase; use App\Models\Contract; use App\Models\Document; +use App\Models\EmailLog; +use App\Models\EmailLogStatus; use App\Models\EmailTemplate; -use App\Models\MailProfile; -use App\Models\Person\Person; use App\Services\EmailTemplateRenderer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; -use Symfony\Component\Mailer\Mailer as SymfonyMailer; -use Symfony\Component\Mailer\Transport; -use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles; @@ -31,6 +29,19 @@ class EmailTemplateController extends Controller { use AuthorizesRequests; + public function update(\App\Http\Requests\UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate) + { + $this->authorize('update', $emailTemplate); + $data = $request->validated(); + $emailTemplate->fill($data)->save(); + // Move any tmp images referenced in HTML into permanent storage and attach as documents + $this->adoptTmpImages($emailTemplate); + + return redirect()->route('admin.email-templates.edit', $emailTemplate)->with('success', 'Template updated'); + } + + use AuthorizesRequests; + public function index(): Response { $this->authorize('viewAny', EmailTemplate::class); @@ -59,126 +70,45 @@ public function store(StoreEmailTemplateRequest $request) return redirect()->route('admin.email-templates.edit', $tpl)->with('success', 'Template created'); } - public function edit(EmailTemplate $emailTemplate): Response - { - $this->authorize('update', $emailTemplate); - $emailTemplate->load(['documents' => function ($q) { - $q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']); - }]); - - return Inertia::render('Admin/EmailTemplates/Edit', [ - 'template' => $emailTemplate, - ]); - } - - public function update(UpdateEmailTemplateRequest $request, EmailTemplate $emailTemplate) - { - $data = $request->validated(); - $emailTemplate->update($data); - $this->adoptTmpImages($emailTemplate); - - return back()->with('success', 'Template updated'); - } - - public function destroy(EmailTemplate $emailTemplate) - { - $this->authorize('delete', $emailTemplate); - $emailTemplate->delete(); - - return redirect()->route('admin.email-templates.index')->with('success', 'Template deleted'); - } - - public function preview(Request $request, EmailTemplate $emailTemplate) + /** + * Render a quick preview of the email template with the provided context. + * Does not persist any changes or inline CSS; intended for fast editor feedback. + */ + public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse { $this->authorize('view', $emailTemplate); - $renderer = app(EmailTemplateRenderer::class); - $subject = (string) $request->input('subject', $emailTemplate->subject_template); - $html = (string) $request->input('html', $emailTemplate->html_template); - $text = (string) $request->input('text', $emailTemplate->text_template); - - // Resolve sample entities by ID if given - $ctx = []; - // Prefer contract -> case -> client for deriving person - if ($id = $request->integer('contract_id')) { - $contract = Contract::query()->with(['clientCase.client.person'])->find($id); - if ($contract) { - $ctx['contract'] = $contract; - if ($contract->clientCase) { - $ctx['client_case'] = $contract->clientCase; - if ($contract->clientCase->client) { - $ctx['client'] = $contract->clientCase->client; - $ctx['person'] = optional($contract->clientCase->client)->person; - } - } - } - } - if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) { - $case = ClientCase::query()->with(['client.person'])->find($id); - if ($case) { - $ctx['client_case'] = $case; - if ($case->client) { - $ctx['client'] = $case->client; - $ctx['person'] = optional($case->client)->person; - } - } - } - if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) { - $client = Client::query()->with(['person'])->find($id); - if ($client) { - $ctx['client'] = $client; - $ctx['person'] = optional($client)->person; - } - } - $ctx['extra'] = (array) $request->input('extra', []); - - $result = $renderer->render([ - 'subject' => $subject, - 'html' => $html, - 'text' => $text, - ], $ctx); - - // Repair and attach images, then embed as requested - if (! empty($result['html'])) { - $result['html'] = $this->repairImgWithoutSrc($result['html']); - $result['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $result['html']); - - $embed = (string) $request->input('embed', 'base64'); // hosted | base64 - if ($embed === 'base64') { - try { - $imageInliner = app(\App\Services\EmailImageInliner::class); - $result['html'] = $imageInliner->inline($result['html']); - } catch (\Throwable $e) { - // ignore preview image inlining errors - } - } else { - $result['html'] = $this->absolutizeStorageUrls($request, $result['html']); - } - - try { - $inliner = new CssToInlineStyles; - $result['html'] = $inliner->convert($result['html']); - } catch (\Throwable $e) { - // ignore preview inlining errors - } - } - - return response()->json($result); - } - - public function sendTest(Request $request, EmailTemplate $emailTemplate) - { - $this->authorize('send', $emailTemplate); - $renderer = app(EmailTemplateRenderer::class); $subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template); $html = (string) ($request->input('html') ?? $emailTemplate->html_template); $text = (string) ($request->input('text') ?? $emailTemplate->text_template); - // Adopt tmp images (tmp/email-images) so test email can display images; also persist - $html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true); + // Do not persist tmp images for preview, but allow showing them if already accessible + // Optionally repair missing img src and attach from template documents for a better preview + if (! empty($html)) { + $html = $this->repairImgWithoutSrc($html); + $html = $this->attachSrcFromTemplateDocuments($emailTemplate, $html); + } + // Context resolution (shared logic with renderFinalHtml) $ctx = []; + if ($id = $request->integer('activity_id')) { + $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); + if ($activity) { + $ctx['activity'] = $activity; + // Derive base entities from activity when not explicitly provided + if ($activity->contract && ! isset($ctx['contract'])) { + $ctx['contract'] = $activity->contract; + } + if ($activity->clientCase && ! isset($ctx['client_case'])) { + $ctx['client_case'] = $activity->clientCase; + } + if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) { + $ctx['client'] = $ctx['client_case']->client; + $ctx['person'] = optional($ctx['client'])->person; + } + } + } if ($id = $request->integer('contract_id')) { $contract = Contract::query()->with(['clientCase.client.person'])->find($id); if ($contract) { @@ -217,107 +147,131 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate) 'text' => $text, ], $ctx); + return response()->json([ + 'subject' => $rendered['subject'] ?? $subject, + 'html' => (string) ($rendered['html'] ?? $html ?? ''), + 'text' => (string) ($rendered['text'] ?? $text ?? ''), + ]); + } + + public function edit(EmailTemplate $emailTemplate): Response + { + $this->authorize('update', $emailTemplate); + $emailTemplate->load(['documents' => function ($q) { + $q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']); + }]); + + return Inertia::render('Admin/EmailTemplates/Edit', [ + 'template' => $emailTemplate, + ]); + } + + public function sendTest(Request $request, EmailTemplate $emailTemplate) + { + $this->authorize('send', $emailTemplate); + + $renderer = app(EmailTemplateRenderer::class); + $subject = (string) ($request->input('subject') ?? $emailTemplate->subject_template); + $html = (string) ($request->input('html') ?? $emailTemplate->html_template); + $text = (string) ($request->input('text') ?? $emailTemplate->text_template); + + // Adopt tmp images (tmp/email-images) so test email can display images; also persist + $html = $this->adoptTmpImagesInHtml($emailTemplate, $html, true); + + // Context resolution + $ctx = []; + if ($id = $request->integer('activity_id')) { + $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); + if ($activity) { + $ctx['activity'] = $activity; + if ($activity->contract && ! isset($ctx['contract'])) { + $ctx['contract'] = $activity->contract; + } + if ($activity->clientCase && ! isset($ctx['client_case'])) { + $ctx['client_case'] = $activity->clientCase; + } + if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) { + $ctx['client'] = $ctx['client_case']->client; + $ctx['person'] = optional($ctx['client'])->person; + } + } + } + if ($id = $request->integer('contract_id')) { + $contract = Contract::query()->with(['clientCase.client.person'])->find($id); + if ($contract) { + $ctx['contract'] = $contract; + if ($contract->clientCase) { + $ctx['client_case'] = $contract->clientCase; + if ($contract->clientCase->client) { + $ctx['client'] = $contract->clientCase->client; + $ctx['person'] = optional($contract->clientCase->client)->person; + } + } + } + } + if (! isset($ctx['client_case']) && ($id = $request->integer('case_id'))) { + $case = ClientCase::query()->with(['client.person'])->find($id); + if ($case) { + $ctx['client_case'] = $case; + if ($case->client) { + $ctx['client'] = $case->client; + $ctx['person'] = optional($case->client)->person; + } + } + } + if (! isset($ctx['client']) && ($id = $request->integer('client_id'))) { + $client = Client::query()->with(['person'])->find($id); + if ($client) { + $ctx['client'] = $client; + $ctx['person'] = optional($client)->person; + } + } + $ctx['extra'] = (array) $request->input('extra', []); + + // Render preview values; we store a minimal snapshot on the log + $rendered = $renderer->render([ + 'subject' => $subject, + 'html' => $html, + 'text' => $text, + ], $ctx); + $to = (string) $request->input('to'); if ($to === '' || ! filter_var($to, FILTER_VALIDATE_EMAIL)) { return back()->with('error', 'Invalid target email'); } - // First repair images missing src if they are followed by a URL (editor artifact) - if (! empty($rendered['html'])) { - $rendered['html'] = $this->repairImgWithoutSrc($rendered['html']); - $rendered['html'] = $this->attachSrcFromTemplateDocuments($emailTemplate, $rendered['html']); - } - // Embed images as requested (default hosted for Gmail compatibility) - $htmlForSend = $rendered['html'] ?? ''; - $embed = (string) $request->input('embed', 'base64'); + // Prepare EmailLog record with queued status + $log = new EmailLog; + $log->fill([ + 'uuid' => (string) \Str::uuid(), + 'template_id' => $emailTemplate->id, + 'to_email' => $to, + 'to_name' => null, + 'subject' => (string) ($rendered['subject'] ?? $subject ?? ''), + 'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null, + 'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null, + 'embed_mode' => (string) $request->input('embed', 'base64'), + 'status' => EmailLogStatus::Queued, + 'queued_at' => now(), + 'client_id' => $ctx['client']->id ?? null, + 'client_case_id' => $ctx['client_case']->id ?? null, + 'contract_id' => $ctx['contract']->id ?? null, + 'extra_context' => $ctx['extra'] ?? null, + 'ip' => $request->ip(), + ]); + $log->save(); - // Prefer the active Mail Profile for sending test emails - $subject = $rendered['subject'] ?? ''; - $profile = MailProfile::query() - ->where('active', true) - ->orderBy('priority') - ->orderBy('id') - ->first(); + // Store bodies in companion table (optional, enabled here) + $log->body()->create([ + 'body_html' => (string) ($rendered['html'] ?? ''), + 'body_text' => (string) ($rendered['text'] ?? ''), + 'inline_css' => true, + ]); - try { - if ($profile) { - $host = $profile->host; - $port = (int) ($profile->port ?: 587); - $encryption = $profile->encryption ?: 'tls'; - $username = $profile->username ?: ''; - $password = (string) ($profile->decryptPassword() ?? ''); + // Dispatch the queued job + dispatch(new SendEmailTemplateJob($log->id)); - $scheme = $encryption === 'ssl' ? 'smtps' : 'smtp'; - $query = $encryption === 'tls' ? '?encryption=tls' : ''; - $dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query); - - $transport = Transport::fromDsn($dsn); - $mailer = new SymfonyMailer($transport); - - $fromAddr = $profile->from_address ?: ($username ?: (config('mail.from.address') ?? '')); - $fromName = $profile->from_name ?: (config('mail.from.name') ?? config('app.name')); - - $email = (new Email) - ->from(new Address($fromAddr ?: $to, $fromName ?: null)) - ->to($to) - ->subject($subject); - - if (! empty($rendered['text'])) { - $email->text($rendered['text']); - } - if (! empty($htmlForSend)) { - if ($embed === 'base64') { - try { - $imageInliner = app(\App\Services\EmailImageInliner::class); - $htmlForSend = $imageInliner->inline($htmlForSend); - } catch (\Throwable $e) { - } - } else { - $htmlForSend = $this->absolutizeStorageUrls($request, $htmlForSend); - } - try { - $inliner = new CssToInlineStyles; - $htmlForSend = $inliner->convert($htmlForSend); - } catch (\Throwable $e) { - } - $email->html($htmlForSend); - } - - $mailer->send($email); - } else { - // Fallback to default Laravel mailer - if (! empty($htmlForSend)) { - if ($embed === 'base64') { - try { - $imageInliner = app(\App\Services\EmailImageInliner::class); - $htmlForSend = $imageInliner->inline($htmlForSend); - } catch (\Throwable $e) { - } - } else { - $htmlForSend = $this->absolutizeStorageUrls($request, $htmlForSend); - } - try { - $inliner = new CssToInlineStyles; - $htmlForSend = $inliner->convert($htmlForSend); - } catch (\Throwable $e) { - } - Mail::html($htmlForSend, function ($message) use ($to, $subject, $rendered) { - $message->to($to)->subject($subject); - if (! empty($rendered['text'])) { - $message->text('mail::raw', ['slot' => $rendered['text']]); - } - }); - } else { - Mail::raw($rendered['text'] ?? '', function ($message) use ($to, $subject) { - $message->to($to)->subject($subject); - }); - } - } - - return back()->with('success', 'Test email sent to '.$to); - } catch (\Throwable $e) { - return back()->with('error', 'Failed to send test email: '.$e->getMessage()); - } + return back()->with('success', 'Test email queued for '.$to); } /** @@ -338,6 +292,22 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate) // Context resolution (same as sendTest) $ctx = []; + if ($id = $request->integer('activity_id')) { + $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); + if ($activity) { + $ctx['activity'] = $activity; + if ($activity->contract && ! isset($ctx['contract'])) { + $ctx['contract'] = $activity->contract; + } + if ($activity->clientCase && ! isset($ctx['client_case'])) { + $ctx['client_case'] = $activity->clientCase; + } + if (($ctx['client_case'] ?? null) && ! isset($ctx['client'])) { + $ctx['client'] = $ctx['client_case']->client; + $ctx['person'] = optional($ctx['client'])->person; + } + } + } if ($id = $request->integer('contract_id')) { $contract = Contract::query()->with(['clientCase.client.person'])->find($id); if ($contract) { @@ -712,6 +682,28 @@ public function replaceImage(Request $request, EmailTemplate $emailTemplate) ]); } + /** + * Delete an attached image Document from the given email template. + */ + public function deleteImage(Request $request, EmailTemplate $emailTemplate, Document $document) + { + $this->authorize('update', $emailTemplate); + + // Ensure the document belongs to this template (polymorphic relation) + if ((int) $document->documentable_id !== (int) $emailTemplate->id || $document->documentable_type !== EmailTemplate::class) { + return response()->json(['message' => 'Document does not belong to this template.'], 422); + } + + try { + // Force delete to remove underlying file as well (Document model handles file deletion on force delete) + $document->forceDelete(); + } catch (\Throwable $e) { + return response()->json(['message' => 'Failed to delete image: '.$e->getMessage()], 500); + } + + return response()->json(['deleted' => true]); + } + /** * Scan HTML for images stored in /storage/tmp/email-images and move them into a permanent * location under /storage/email-images, create Document records and update the HTML. diff --git a/app/Http/Controllers/Admin/MailProfileController.php b/app/Http/Controllers/Admin/MailProfileController.php index 9d7a0fb..9a62f8c 100644 --- a/app/Http/Controllers/Admin/MailProfileController.php +++ b/app/Http/Controllers/Admin/MailProfileController.php @@ -126,14 +126,15 @@ public function sendTest(Request $request, MailProfile $mailProfile) $mailer = new SymfonyMailer($transport); $fromAddr = $mailProfile->from_address ?: $username; - $fromName = $mailProfile->from_name ?: config('app.name'); + $fromName = (string) ($mailProfile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? '')); $html = '

This is a test email from profile '.e($mailProfile->name).' at '.e(now()->toDateTimeString()).'.

'; $text = 'This is a test email from profile "'.$mailProfile->name.'" at '.now()->toDateTimeString().'.'; // Build email + $fromAddress = $fromName !== '' ? new Address($fromAddr, $fromName) : new Address($fromAddr); $email = (new Email) - ->from(new Address($fromAddr, $fromName)) + ->from($fromAddress) ->to($to) ->subject('Test email - '.$mailProfile->name) ->text($text) diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 09f520b..7591391 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -21,7 +21,7 @@ class ClientCaseContoller extends Controller public function index(ClientCase $clientCase, Request $request) { $query = $clientCase::query() - ->with(['person', 'client.person']) + ->with(['person.client', 'client.person']) ->where('active', 1) ->when($request->input('search'), function ($que, $search) { $que->whereHas('person', function ($q) use ($search) { @@ -251,6 +251,7 @@ public function storeActivity(ClientCase $clientCase, Request $request) 'action_id' => 'exists:\App\Models\Action,id', 'decision_id' => 'exists:\App\Models\Decision,id', 'contract_uuid' => 'nullable|uuid', + 'send_auto_mail' => 'sometimes|boolean', ]); // Map contract_uuid to contract_id within the same client case, if provided @@ -279,6 +280,23 @@ public function storeActivity(ClientCase $clientCase, Request $request) logger()->info('Activity successfully inserted', $attributes); + // Auto mail dispatch (best-effort) + try { + $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true); + $row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']); + $result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag); + if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) { + // If template requires contract and user attempted to send, surface a validation message + return back()->with('warning', 'Email not queued: required contract is missing for the selected template.'); + } + if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) { + return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.'); + } + } catch (\Throwable $e) { + // Do not fail activity creation due to mailing issues + logger()->warning('Auto mail dispatch failed: '.$e->getMessage()); + } + // Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route. // Use 303 to align with Inertia's recommended POST/Redirect/GET behavior. return back(303)->with('success', 'Successful created!'); @@ -1020,7 +1038,7 @@ protected function streamDocumentForDisk(Document $document, bool $inline = true public function show(ClientCase $clientCase) { $case = $clientCase::with([ - 'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts']), + 'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']), ])->where('active', 1)->findOrFail($clientCase->id); $types = [ @@ -1174,7 +1192,7 @@ public function show(ClientCase $clientCase) } return Inertia::render('Cases/Show', [ - 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts']))->firstOrFail(), + 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(), 'client_case' => $case, 'contracts' => $contracts, 'archive_meta' => [ @@ -1209,11 +1227,17 @@ function ($p) { 'documents' => $mergedDocs, 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), 'account_types' => \App\Models\AccountType::all(), - 'actions' => \App\Models\Action::with('decisions') - /*->when($segmentId, function($q) use($segmentId) { - $q->where('segment_id', $segmentId)->orWhereNull('segment_id'); - })*/ - ->get(), + // Include decisions with auto-mail metadata and the linked email template entity_types for UI logic + 'actions' => \App\Models\Action::query() + ->with([ + 'decisions' => function ($q) { + $q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id'); + }, + 'decisions.emailTemplate' => function ($q) { + $q->select('id', 'name', 'entity_types'); + }, + ]) + ->get(['id', 'name', 'color_tag', 'segment_id']), 'types' => $types, 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 4e90441..924f2be 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -63,7 +63,7 @@ public function show(Client $client, Request $request) { $data = $client::query() - ->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails'])]) + ->with(['person' => fn ($que) => $que->with(['addresses', 'phones', 'bankAccounts', 'emails', 'client'])]) ->findOrFail($client->id); $types = [ diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php index a978d8c..6dba1a2 100644 --- a/app/Http/Controllers/PersonController.php +++ b/app/Http/Controllers/PersonController.php @@ -2,32 +2,26 @@ namespace App\Http\Controllers; -use App\Models\Person\Person; use App\Models\BankAccount; +use App\Models\Person\Person; use Illuminate\Http\Request; -use Inertia\Inertia; class PersonController extends Controller { // - public function show(Person $person){ - - } + public function show(Person $person) {} - public function create(Request $request){ + public function create(Request $request) {} - } + public function store(Request $request) {} - public function store(Request $request){ - - } - - public function update(Person $person, Request $request){ + public function update(Person $person, Request $request) + { $attributes = $request->validate([ 'full_name' => 'string|max:255', 'tax_number' => 'nullable|integer', 'social_security_number' => 'nullable|integer', - 'description' => 'nullable|string|max:500' + 'description' => 'nullable|string|max:500', ]); $person->update($attributes); @@ -37,17 +31,18 @@ public function update(Person $person, Request $request){ 'full_name' => $person->full_name, 'tax_number' => $person->tax_number, 'social_security_number' => $person->social_security_number, - 'description' => $person->description - ] + 'description' => $person->description, + ], ]); } - public function createAddress(Person $person, Request $request){ + public function createAddress(Person $person, Request $request) + { $attributes = $request->validate([ 'address' => 'required|string|max:150', 'country' => 'nullable|string', 'type_id' => 'required|integer|exists:address_types,id', - 'description' => 'nullable|string|max:125' + 'description' => 'nullable|string|max:125', ]); // Dedup: avoid duplicate address per person by (address, country) @@ -57,7 +52,7 @@ public function createAddress(Person $person, Request $request){ ], $attributes); return response()->json([ - 'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id) + 'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id), ]); } @@ -67,7 +62,7 @@ public function updateAddress(Person $person, int $address_id, Request $request) 'address' => 'required|string|max:150', 'country' => 'nullable|string', 'type_id' => 'required|integer|exists:address_types,id', - 'description' => 'nullable|string|max:125' + 'description' => 'nullable|string|max:125', ]); $address = $person->addresses()->with(['type'])->findOrFail($address_id); @@ -75,7 +70,7 @@ public function updateAddress(Person $person, int $address_id, Request $request) $address->update($attributes); return response()->json([ - 'address' => $address + 'address' => $address, ]); } @@ -83,6 +78,7 @@ public function deleteAddress(Person $person, int $address_id, Request $request) { $address = $person->addresses()->findOrFail($address_id); $address->delete(); // soft delete + return response()->json(['status' => 'ok']); } @@ -92,7 +88,7 @@ public function createPhone(Person $person, Request $request) 'nu' => 'required|string|max:50', 'country_code' => 'nullable|integer', 'type_id' => 'required|integer|exists:phone_types,id', - 'description' => 'nullable|string|max:125' + 'description' => 'nullable|string|max:125', ]); // Dedup: avoid duplicate phone per person by (nu, country_code) @@ -102,7 +98,7 @@ public function createPhone(Person $person, Request $request) ], $attributes); return response()->json([ - 'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id) + 'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id), ]); } @@ -112,7 +108,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request) 'nu' => 'required|string|max:50', 'country_code' => 'nullable|integer', 'type_id' => 'required|integer|exists:phone_types,id', - 'description' => 'nullable|string|max:125' + 'description' => 'nullable|string|max:125', ]); $phone = $person->phones()->with(['type'])->findOrFail($phone_id); @@ -120,7 +116,7 @@ public function updatePhone(Person $person, int $phone_id, Request $request) $phone->update($attributes); return response()->json([ - 'phone' => $phone + 'phone' => $phone, ]); } @@ -128,6 +124,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request) { $phone = $person->phones()->findOrFail($phone_id); $phone->delete(); // soft delete + return response()->json(['status' => 'ok']); } @@ -139,6 +136,7 @@ public function createEmail(Person $person, Request $request) 'is_primary' => 'boolean', 'is_active' => 'boolean', 'valid' => 'boolean', + 'receive_auto_mails' => 'sometimes|boolean', 'verified_at' => 'nullable|date', 'preferences' => 'nullable|array', 'meta' => 'nullable|array', @@ -149,9 +147,7 @@ public function createEmail(Person $person, Request $request) 'value' => $attributes['value'], ], $attributes); - return response()->json([ - 'email' => \App\Models\Email::findOrFail($email->id) - ]); + return back()->with('success', 'Email added successfully'); } public function updateEmail(Person $person, int $email_id, Request $request) @@ -162,6 +158,7 @@ public function updateEmail(Person $person, int $email_id, Request $request) 'is_primary' => 'boolean', 'is_active' => 'boolean', 'valid' => 'boolean', + 'receive_auto_mails' => 'sometimes|boolean', 'verified_at' => 'nullable|date', 'preferences' => 'nullable|array', 'meta' => 'nullable|array', @@ -171,15 +168,14 @@ public function updateEmail(Person $person, int $email_id, Request $request) $email->update($attributes); - return response()->json([ - 'email' => $email - ]); + return back()->with('success', 'Email updated successfully'); } public function deleteEmail(Person $person, int $email_id, Request $request) { $email = $person->emails()->findOrFail($email_id); $email->delete(); + return response()->json(['status' => 'ok']); } @@ -203,7 +199,7 @@ public function createTrr(Person $person, Request $request) $trr = $person->bankAccounts()->create($attributes); return response()->json([ - 'trr' => BankAccount::findOrFail($trr->id) + 'trr' => BankAccount::findOrFail($trr->id), ]); } @@ -227,7 +223,7 @@ public function updateTrr(Person $person, int $trr_id, Request $request) $trr->update($attributes); return response()->json([ - 'trr' => $trr + 'trr' => $trr, ]); } @@ -235,6 +231,7 @@ public function deleteTrr(Person $person, int $trr_id, Request $request) { $trr = $person->bankAccounts()->findOrFail($trr_id); $trr->delete(); + return response()->json(['status' => 'ok']); } } diff --git a/app/Http/Controllers/WorkflowController.php b/app/Http/Controllers/WorkflowController.php index 63601a0..85f62a3 100644 --- a/app/Http/Controllers/WorkflowController.php +++ b/app/Http/Controllers/WorkflowController.php @@ -4,9 +4,9 @@ use App\Models\Action; use App\Models\Decision; +use App\Models\EmailTemplate; use App\Models\Segment; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Inertia\Inertia; class WorkflowController extends Controller @@ -17,6 +17,7 @@ public function index(Request $request) 'actions' => Action::query()->with(['decisions', 'segment'])->withCount('activities')->get(), 'decisions' => Decision::query()->with('actions')->withCount('activities')->get(), 'segments' => Segment::query()->get(), + 'email_templates' => EmailTemplate::query()->where('active', true)->get(['id', 'name', 'entity_types']), ]); } @@ -41,7 +42,7 @@ public function storeAction(Request $request) 'segment_id' => $attributes['segment_id'] ?? null, ]); - if (!empty($decisionIds)) { + if (! empty($decisionIds)) { $row->decisions()->sync($decisionIds); } }); @@ -59,12 +60,12 @@ public function updateAction(int $id, Request $request) 'segment_id' => 'nullable|integer|exists:segments,id', 'decisions' => 'nullable|array', 'decisions.*.id' => 'required_with:decisions.*|integer|exists:decisions,id', - 'decisions.*.name' => 'required_with:decisions.*|string|max:50' + 'decisions.*.name' => 'required_with:decisions.*|string|max:50', ]); $decisionIds = collect($attributes['decisions'] ?? [])->pluck('id')->toArray(); - \DB::transaction(function() use ($attributes, $decisionIds, $row) { + \DB::transaction(function () use ($attributes, $decisionIds, $row) { $row->update([ 'name' => $attributes['name'], 'color_tag' => $attributes['color_tag'], @@ -81,6 +82,8 @@ public function storeDecision(Request $request) $attributes = $request->validate([ 'name' => 'required|string|max:50', 'color_tag' => 'nullable|string|max:25', + 'auto_mail' => 'sometimes|boolean', + 'email_template_id' => 'nullable|integer|exists:email_templates,id', 'actions' => 'nullable|array', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.name' => 'required_with:actions.*|string|max:50', @@ -93,9 +96,11 @@ public function storeDecision(Request $request) $row = Decision::create([ 'name' => $attributes['name'], 'color_tag' => $attributes['color_tag'] ?? null, + 'auto_mail' => (bool) ($attributes['auto_mail'] ?? false), + 'email_template_id' => $attributes['email_template_id'] ?? null, ]); - if (!empty($actionIds)) { + if (! empty($actionIds)) { $row->actions()->sync($actionIds); } }); @@ -110,6 +115,8 @@ public function updateDecision(int $id, Request $request) $attributes = $request->validate([ 'name' => 'required|string|max:50', 'color_tag' => 'nullable|string|max:25', + 'auto_mail' => 'sometimes|boolean', + 'email_template_id' => 'nullable|integer|exists:email_templates,id', 'actions' => 'nullable|array', 'actions.*.id' => 'required_with:actions.*|integer|exists:actions,id', 'actions.*.name' => 'required_with:actions.*|string|max:50', @@ -121,6 +128,8 @@ public function updateDecision(int $id, Request $request) $row->update([ 'name' => $attributes['name'], 'color_tag' => $attributes['color_tag'] ?? null, + 'auto_mail' => (bool) ($attributes['auto_mail'] ?? false), + 'email_template_id' => $attributes['email_template_id'] ?? null, ]); $row->actions()->sync($actionIds); }); @@ -139,6 +148,7 @@ public function destroyAction(int $id) $row->decisions()->detach(); $row->delete(); }); + return back()->with('success', 'Action deleted successfully!'); } @@ -153,6 +163,7 @@ public function destroyDecision(int $id) $row->actions()->detach(); $row->delete(); }); + return back()->with('success', 'Decision deleted successfully!'); } } diff --git a/app/Jobs/SendEmailTemplateJob.php b/app/Jobs/SendEmailTemplateJob.php new file mode 100644 index 0000000..f877714 --- /dev/null +++ b/app/Jobs/SendEmailTemplateJob.php @@ -0,0 +1,60 @@ +find($this->emailLogId); + if (! $log) { + return; + } + if ($log->status === EmailLogStatus::Sent) { + return; + } + + $start = microtime(true); + $log->status = EmailLogStatus::Sending; + $log->started_at = now(); + $log->attempt = (int) ($log->attempt ?: 0) + 1; + $log->save(); + + try { + /** @var EmailSender $sender */ + $sender = app(EmailSender::class); + $result = $sender->sendFromLog($log); + + $log->status = EmailLogStatus::Sent; + $log->message_id = $result['message_id'] ?? ($log->message_id ?? null); + $log->sent_at = now(); + $log->duration_ms = (int) round((microtime(true) - $start) * 1000); + $log->save(); + } catch (Throwable $e) { + $log->status = EmailLogStatus::Failed; + $log->failed_at = now(); + $log->error_message = $e->getMessage(); + $log->duration_ms = (int) round((microtime(true) - $start) * 1000); + $log->save(); + + throw $e; + } + } +} diff --git a/app/Models/Decision.php b/app/Models/Decision.php index b673be5..7cfaa76 100644 --- a/app/Models/Decision.php +++ b/app/Models/Decision.php @@ -13,7 +13,14 @@ class Decision extends Model /** @use HasFactory<\Database\Factories\DecisionFactory> */ use HasFactory; - protected $fillable = ['name', 'color_tag']; + protected $fillable = ['name', 'color_tag', 'auto_mail', 'email_template_id']; + + protected function casts(): array + { + return [ + 'auto_mail' => 'boolean', + ]; + } public function actions(): BelongsToMany { @@ -29,4 +36,9 @@ public function activities(): HasMany { return $this->hasMany(\App\Models\Activity::class); } + + public function emailTemplate(): BelongsTo + { + return $this->belongsTo(\App\Models\EmailTemplate::class, 'email_template_id'); + } } diff --git a/app/Models/Email.php b/app/Models/Email.php index 93e2ae2..a3fe24d 100644 --- a/app/Models/Email.php +++ b/app/Models/Email.php @@ -18,6 +18,7 @@ class Email extends Model 'is_primary', 'is_active', 'valid', + 'receive_auto_mails', 'verified_at', 'preferences', 'meta', @@ -27,6 +28,7 @@ class Email extends Model 'is_primary' => 'boolean', 'is_active' => 'boolean', 'valid' => 'boolean', + 'receive_auto_mails' => 'boolean', 'verified_at' => 'datetime', 'preferences' => 'array', 'meta' => 'array', diff --git a/app/Models/EmailLog.php b/app/Models/EmailLog.php new file mode 100644 index 0000000..1648638 --- /dev/null +++ b/app/Models/EmailLog.php @@ -0,0 +1,86 @@ + EmailLogStatus::class, + 'cc' => 'array', + 'bcc' => 'array', + 'to_recipients' => 'array', + 'attachments' => 'array', + 'headers' => 'array', + 'extra_context' => 'array', + 'attempt' => 'integer', + 'duration_ms' => 'integer', + 'queued_at' => 'datetime', + 'started_at' => 'datetime', + 'sent_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + } + + public function template(): BelongsTo + { + return $this->belongsTo(EmailTemplate::class, 'template_id'); + } + + public function body(): HasOne + { + return $this->hasOne(EmailLogBody::class, 'email_log_id'); + } +} diff --git a/app/Models/EmailLogBody.php b/app/Models/EmailLogBody.php new file mode 100644 index 0000000..40a2635 --- /dev/null +++ b/app/Models/EmailLogBody.php @@ -0,0 +1,28 @@ + 'boolean', + ]; + } + + public function log(): BelongsTo + { + return $this->belongsTo(EmailLog::class, 'email_log_id'); + } +} diff --git a/app/Services/AutoMailDispatcher.php b/app/Services/AutoMailDispatcher.php new file mode 100644 index 0000000..c8856a6 --- /dev/null +++ b/app/Services/AutoMailDispatcher.php @@ -0,0 +1,122 @@ + true, 'log_id' => int] or ['skipped' => 'reason']. + */ + public function maybeQueue(Activity $activity, bool $sendFlag = true): array + { + $decision = $activity->decision; + if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) { + return ['skipped' => 'disabled']; + } + + /** @var EmailTemplate|null $template */ + $template = EmailTemplate::query()->find($decision->email_template_id); + if (! $template || ! $template->active) { + return ['skipped' => 'no-template']; + } + + // Resolve context + $clientCase = $activity->clientCase; /** @var ClientCase|null $clientCase */ + $contract = $activity->contract; /** @var Contract|null $contract */ + $client = $clientCase?->client; /** @var Client|null $client */ + $person = $clientCase?->person; /** @var \App\Models\Person\Person|null $person */ + + // Validate required entity types from template + $required = (array) ($template->entity_types ?? []); + if (in_array('client', $required, true) && ! $client) { + return ['skipped' => 'missing-client']; + } + if (in_array('client_case', $required, true) && ! $clientCase) { + return ['skipped' => 'missing-client-case']; + } + if (in_array('contract', $required, true) && ! $contract) { + return ['skipped' => 'missing-contract']; + } + if (in_array('person', $required, true) && ! $person) { + return ['skipped' => 'missing-person']; + } + + // Resolve eligible recipients: client's person emails with receive_auto_mails = true + $recipients = []; + if ($client && $client->person) { + $recipients = Email::query() + ->where('person_id', $client->person->id) + ->where('is_active', true) + ->where('receive_auto_mails', true) + ->pluck('value') + ->map(fn ($v) => strtolower(trim((string) $v))) + ->filter(fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL)) + ->unique() + ->values() + ->all(); + } + if (empty($recipients)) { + return ['skipped' => 'no-recipients']; + } + + // Ensure related names are available without extra queries + $activity->loadMissing(['action', 'decision']); + + // Render content + $rendered = $this->renderer->render([ + 'subject' => (string) $template->subject_template, + 'html' => (string) $template->html_template, + 'text' => (string) $template->text_template, + ], [ + 'client' => $client, + 'client_case' => $clientCase, + 'contract' => $contract, + 'person' => $person, + 'activity' => $activity, + 'extra' => [], + ]); + + // Create the log and body + $log = new EmailLog; + $log->fill([ + 'uuid' => (string) \Str::uuid(), + 'template_id' => $template->id, + 'to_email' => (string) ($recipients[0] ?? ''), + 'to_recipients' => $recipients, + 'subject' => (string) ($rendered['subject'] ?? $template->subject_template ?? ''), + 'body_html_hash' => $rendered['html'] ? hash('sha256', $rendered['html']) : null, + 'body_text_preview' => isset($rendered['text']) ? mb_strimwidth((string) $rendered['text'], 0, 4096) : null, + 'embed_mode' => 'base64', + 'status' => EmailLogStatus::Queued, + 'queued_at' => now(), + 'client_id' => $client?->id, + 'client_case_id' => $clientCase?->id, + 'contract_id' => $contract?->id, + ]); + $log->save(); + + $log->body()->create([ + 'body_html' => (string) ($rendered['html'] ?? ''), + 'body_text' => (string) ($rendered['text'] ?? ''), + 'inline_css' => true, + ]); + + dispatch(new SendEmailTemplateJob($log->id)); + + return ['queued' => true, 'log_id' => $log->id]; + } +} diff --git a/app/Services/EmailSender.php b/app/Services/EmailSender.php new file mode 100644 index 0000000..bd11b46 --- /dev/null +++ b/app/Services/EmailSender.php @@ -0,0 +1,192 @@ + string|null]. + * Throws on transport errors so the Job can retry. + */ + public function sendFromLog(EmailLog $log): array + { + // Resolve sending profile + $profile = null; + if ($log->mail_profile_id) { + $profile = MailProfile::query()->find($log->mail_profile_id); + } + if (! $profile) { + $profile = MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(); + } + + $embed = $log->embed_mode ?: 'base64'; + + $subject = (string) $log->subject; + $html = (string) optional($log->body)->body_html ?? ''; + $text = (string) optional($log->body)->body_text ?? ''; + + // Inline CSS and handle images similarly to controller + if ($html !== '') { + if ($embed === 'base64') { + try { + $imageInliner = app(\App\Services\EmailImageInliner::class); + $html = $imageInliner->inline($html); + } catch (\Throwable $e) { + } + } else { + // Best effort absolutize /storage URLs using app.url + $base = (string) (config('app.asset_url') ?: config('app.url')); + $host = $base !== '' ? rtrim($base, '/') : null; + if ($host) { + $html = preg_replace_callback('#]+)src=["\']([^"\']+)["\']([^>]*)>#i', function (array $m) use ($host) { + $before = $m[1] ?? ''; + $src = $m[2] ?? ''; + $after = $m[3] ?? ''; + $path = $src; + if (preg_match('#^https?://#i', $src)) { + $parts = parse_url($src); + $path = $parts['path'] ?? ''; + if (! preg_match('#^/?storage/#i', (string) $path)) { + return $m[0]; + } + } else { + if (! preg_match('#^/?storage/#i', (string) $path)) { + return $m[0]; + } + } + $rel = '/'.ltrim(preg_replace('#^/?storage/#i', 'storage/', (string) $path), '/'); + $abs = rtrim($host, '/').$rel; + + return ''; + }, $html); + } + } + try { + $inliner = new CssToInlineStyles; + $html = $inliner->convert($html); + } catch (\Throwable $e) { + } + } + + // Transport setup (Symfony Mailer preferred when profile exists) + $messageId = null; + if ($profile) { + $host = $profile->host; + $port = (int) ($profile->port ?: 587); + $encryption = $profile->encryption ?: 'tls'; + $username = $profile->username ?: ''; + $password = (string) ($profile->decryptPassword() ?? ''); + + $scheme = $encryption === 'ssl' ? 'smtps' : 'smtp'; + $query = $encryption === 'tls' ? '?encryption=tls' : ''; + $dsn = sprintf('%s://%s:%s@%s:%d%s', $scheme, rawurlencode($username), rawurlencode($password), $host, $port, $query); + + $transport = Transport::fromDsn($dsn); + $mailer = new SymfonyMailer($transport); + + $fromAddr = (string) ($log->from_email ?: ($profile->from_address ?: ($username ?: (config('mail.from.address') ?? '')))); + $fromName = (string) ($log->from_name ?: ($profile->from_name ?: (config('mail.from.name') ?? config('app.name') ?? ''))); + + // Build email with safe Address instances (Symfony Address does not allow null name) + $fromAddress = $fromName !== '' + ? new Address($fromAddr ?: $log->to_email, $fromName) + : new Address($fromAddr ?: $log->to_email); + $toAddress = (string) ($log->to_name ?? '') !== '' + ? new Address($log->to_email, (string) $log->to_name) + : new Address($log->to_email); + + $email = (new Email) + ->from($fromAddress) + ->subject($subject); + + // If multiple recipients are present, address to all; otherwise single to + $toList = (array) ($log->to_recipients ?? []); + if (! empty($toList)) { + $addresses = []; + foreach ($toList as $addr) { + $addr = trim((string) $addr); + if ($addr !== '' && filter_var($addr, FILTER_VALIDATE_EMAIL)) { + $addresses[] = new Address($addr); + } + } + if (! empty($addresses)) { + $email->to(...$addresses); + } else { + $email->to($toAddress); + } + } else { + $email->to($toAddress); + } + + if (! empty($text)) { + $email->text($text); + } + if (! empty($html)) { + $email->html($html); + } + if (! empty($log->reply_to)) { + $email->replyTo($log->reply_to); + } + + $mailer->send($email); + $headers = $email->getHeaders(); + $messageIdHeader = $headers->get('Message-ID'); + $messageId = $messageIdHeader ? $messageIdHeader->getBodyAsString() : null; + } else { + // Fallback to Laravel mailer + if (! empty($html)) { + \Mail::html($html, function ($message) use ($log, $subject, $text) { + $toList = (array) ($log->to_recipients ?? []); + if (! empty($toList)) { + $message->to($toList); + } else { + $toName = (string) ($log->to_name ?? ''); + if ($toName !== '') { + $message->to($log->to_email, $toName); + } else { + $message->to($log->to_email); + } + } + $message->subject($subject); + if (! empty($log->reply_to)) { + $message->replyTo($log->reply_to); + } + if (! empty($text)) { + // Provide a plain text alternative when available + $message->text($text); + } + }); + } else { + \Mail::raw($text ?: '', function ($message) use ($log, $subject) { + $toList = (array) ($log->to_recipients ?? []); + if (! empty($toList)) { + $message->to($toList); + } else { + $toName = (string) ($log->to_name ?? ''); + if ($toName !== '') { + $message->to($log->to_email, $toName); + } else { + $message->to($log->to_email); + } + } + $message->subject($subject); + if (! empty($log->reply_to)) { + $message->replyTo($log->reply_to); + } + }); + } + } + + return ['message_id' => $messageId]; + } +} diff --git a/app/Services/EmailTemplateRenderer.php b/app/Services/EmailTemplateRenderer.php index c340522..b0330cf 100644 --- a/app/Services/EmailTemplateRenderer.php +++ b/app/Services/EmailTemplateRenderer.php @@ -2,19 +2,21 @@ 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 + * 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, extra?:array} $ctx + * @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 @@ -40,16 +42,66 @@ public function render(array $template, array $ctx): array } /** - * @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, extra?:array} $ctx + * @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'])) { @@ -68,6 +120,23 @@ protected function buildMap(array $ctx): array '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'])) { @@ -76,13 +145,30 @@ protected function buildMap(array $ctx): array 'id' => data_get($co, 'id'), 'uuid' => data_get($co, 'uuid'), 'reference' => data_get($co, 'reference'), - 'amount' => data_get($co, 'amount'), + // Format amounts in EU style for emails + 'amount' => $formatMoneyEu(data_get($co, 'amount')), ]; $meta = data_get($co, 'meta'); if (is_array($meta)) { $out['contract']['meta'] = $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']; } diff --git a/database/migrations/2025_10_11_174805_create_email_logs_table.php b/database/migrations/2025_10_11_174805_create_email_logs_table.php new file mode 100644 index 0000000..9033a04 --- /dev/null +++ b/database/migrations/2025_10_11_174805_create_email_logs_table.php @@ -0,0 +1,73 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('template_id')->nullable()->constrained('email_templates')->nullOnDelete(); + $table->foreignId('mail_profile_id')->nullable()->constrained('mail_profiles')->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->string('message_id')->nullable()->unique(); + $table->string('correlation_id', 100)->nullable(); + + $table->string('to_email'); + $table->string('to_name')->nullable(); + $table->json('cc')->nullable(); + $table->json('bcc')->nullable(); + $table->string('from_email')->nullable(); + $table->string('from_name')->nullable(); + $table->string('reply_to')->nullable(); + + $table->string('subject', 512); + $table->string('body_html_hash', 64)->nullable(); + $table->text('body_text_preview')->nullable(); + $table->json('attachments')->nullable(); + $table->string('embed_mode', 16)->default('base64'); + + $table->string('status', 20)->index(); + $table->string('error_code', 100)->nullable(); + $table->text('error_message')->nullable(); + $table->string('transport')->nullable(); + $table->json('headers')->nullable(); + $table->unsignedSmallInteger('attempt')->default(1); + $table->unsignedInteger('duration_ms')->nullable(); + + $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete(); + $table->foreignId('client_case_id')->nullable()->constrained('client_cases')->nullOnDelete(); + $table->foreignId('contract_id')->nullable()->constrained('contracts')->nullOnDelete(); + $table->json('extra_context')->nullable(); + + $table->timestamp('queued_at')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->string('ip', 45)->nullable(); + + $table->timestamps(); + + $table->index(['template_id', 'created_at']); + $table->index(['to_email', 'created_at']); + $table->index(['status', 'created_at']); + $table->index(['client_id', 'client_case_id', 'contract_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('email_logs'); + } +}; diff --git a/database/migrations/2025_10_11_174816_create_email_log_bodies_table.php b/database/migrations/2025_10_11_174816_create_email_log_bodies_table.php new file mode 100644 index 0000000..f86d97d --- /dev/null +++ b/database/migrations/2025_10_11_174816_create_email_log_bodies_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('email_log_id')->unique()->constrained('email_logs')->cascadeOnDelete(); + $table->longText('body_html')->nullable(); + $table->longText('body_text')->nullable(); + $table->boolean('inline_css')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('email_log_bodies'); + } +}; diff --git a/database/migrations/2025_10_11_190001_add_auto_mail_and_template_to_decisions_table.php b/database/migrations/2025_10_11_190001_add_auto_mail_and_template_to_decisions_table.php new file mode 100644 index 0000000..666d5c4 --- /dev/null +++ b/database/migrations/2025_10_11_190001_add_auto_mail_and_template_to_decisions_table.php @@ -0,0 +1,32 @@ +boolean('auto_mail')->default(false)->after('color_tag'); + } + if (! Schema::hasColumn('decisions', 'email_template_id')) { + $table->foreignId('email_template_id')->nullable()->after('auto_mail')->constrained('email_templates')->nullOnDelete(); + } + }); + } + + public function down(): void + { + Schema::table('decisions', function (Blueprint $table) { + if (Schema::hasColumn('decisions', 'email_template_id')) { + $table->dropConstrainedForeignId('email_template_id'); + } + if (Schema::hasColumn('decisions', 'auto_mail')) { + $table->dropColumn('auto_mail'); + } + }); + } +}; diff --git a/database/migrations/2025_10_11_190010_add_recipients_and_receive_auto_mails.php b/database/migrations/2025_10_11_190010_add_recipients_and_receive_auto_mails.php new file mode 100644 index 0000000..6191b8a --- /dev/null +++ b/database/migrations/2025_10_11_190010_add_recipients_and_receive_auto_mails.php @@ -0,0 +1,38 @@ +json('to_recipients')->nullable()->after('to_email'); + } + }); + + Schema::table('emails', function (Blueprint $table) { + if (! Schema::hasColumn('emails', 'receive_auto_mails')) { + $table->boolean('receive_auto_mails')->default(false)->after('is_active'); + } + }); + } + + public function down(): void + { + Schema::table('email_logs', function (Blueprint $table) { + if (Schema::hasColumn('email_logs', 'to_recipients')) { + $table->dropColumn('to_recipients'); + } + }); + + Schema::table('emails', function (Blueprint $table) { + if (Schema::hasColumn('emails', 'receive_auto_mails')) { + $table->dropColumn('receive_auto_mails'); + } + }); + } +}; diff --git a/resources/js/Components/EmailCreateForm.vue b/resources/js/Components/EmailCreateForm.vue index 79d8dd5..26d4c99 100644 --- a/resources/js/Components/EmailCreateForm.vue +++ b/resources/js/Components/EmailCreateForm.vue @@ -1,12 +1,12 @@ diff --git a/resources/js/Components/PersonInfoGrid.vue b/resources/js/Components/PersonInfoGrid.vue index 8f44857..0ad966a 100644 --- a/resources/js/Components/PersonInfoGrid.vue +++ b/resources/js/Components/PersonInfoGrid.vue @@ -350,6 +350,7 @@ const getTRRs = (p) => { @close="drawerAddEmail = false" :person="person" :types="types.email_types ?? []" + :is-client-context="!!person?.client" /> { :person="person" :types="types.email_types ?? []" :id="editEmailId" + :is-client-context="!!person?.client" /> diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index c76b3a1..544493d 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -108,6 +108,13 @@ const navGroups = computed(() => [ "admin.email-templates.edit", ], }, + { + key: "admin.email-logs.index", + label: "Email dnevniki", + route: "admin.email-logs.index", + icon: faEnvelope, + active: ["admin.email-logs.index", "admin.email-logs.show"], + }, { key: "admin.mail-profiles.index", label: "Mail profili", diff --git a/resources/js/Pages/Admin/EmailLogs/Index.vue b/resources/js/Pages/Admin/EmailLogs/Index.vue new file mode 100644 index 0000000..8704831 --- /dev/null +++ b/resources/js/Pages/Admin/EmailLogs/Index.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/resources/js/Pages/Admin/EmailLogs/Show.vue b/resources/js/Pages/Admin/EmailLogs/Show.vue new file mode 100644 index 0000000..b25fa5d --- /dev/null +++ b/resources/js/Pages/Admin/EmailLogs/Show.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/resources/js/Pages/Admin/EmailTemplates/Edit.vue b/resources/js/Pages/Admin/EmailTemplates/Edit.vue index 4772804..c5c0150 100644 --- a/resources/js/Pages/Admin/EmailTemplates/Edit.vue +++ b/resources/js/Pages/Admin/EmailTemplates/Edit.vue @@ -4,12 +4,7 @@ import { Head, Link, useForm, router, usePage } from "@inertiajs/vue3"; import { ref, watch, computed, onMounted, nextTick } from "vue"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { faArrowLeft, faEye } from "@fortawesome/free-solid-svg-icons"; -// Ensure Quill is available before importing the wrapper component -import Quill from "quill"; -if (typeof window !== "undefined" && !window.Quill) { - // @ts-ignore - window.Quill = Quill; -} +// Keep Quill CSS for nicer preview styling, but remove the Quill editor itself import "quill/dist/quill.snow.css"; const props = defineProps({ @@ -44,9 +39,8 @@ const previewHtml = computed(() => { return containsDocScaffold(html) ? extractBody(html) : html; } // Fallback to local content if server preview didn't provide HTML - return sourceMode.value - ? extractBody(form.html_template || "") - : stripDocScaffold(form.html_template || ""); + // We only use the advanced editor now, so just show the content when available + return extractBody(form.html_template || ""); }); const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []); const docs = computed(() => @@ -66,6 +60,12 @@ function updateLocalDoc(documentId, path, name = null, size = null) { docsRaw.value.splice(idx, 1, next); } } +async function removeLocalDoc(documentId) { + const idx = docsRaw.value.findIndex((d) => d.id === documentId); + if (idx !== -1) { + docsRaw.value.splice(idx, 1); + } +} function formatSize(bytes) { if (!bytes && bytes !== 0) return ""; const kb = bytes / 1024; @@ -97,6 +97,7 @@ const fetchPreview = () => { // Always send full HTML so head/styles can be respected and inlined server-side html: form.html_template, text: form.text_template, + activity_id: sample.value.activity_id || undefined, client_id: sample.value.client_id || undefined, case_id: sample.value.case_id || undefined, contract_id: sample.value.contract_id || undefined, @@ -130,6 +131,7 @@ async function fetchFinalHtml() { subject: form.subject_template, html: form.html_template, text: form.text_template, + activity_id: sample.value.activity_id || undefined, client_id: sample.value.client_id || undefined, case_id: sample.value.case_id || undefined, contract_id: sample.value.contract_id || undefined, @@ -186,62 +188,25 @@ onMounted(() => { // Populate cascading selects immediately so the Client dropdown isn't empty loadClients(); fetchPreview(); - // Mount Quill editor directly - try { - if (quillContainer.value) { - // instantiate lazily to ensure DOM is ready - const editor = new Quill(quillContainer.value, { - theme: "snow", - modules: quillModules.value, - }); - quill.value = editor; - if (form.html_template) { - const bodyOnly = stripDocScaffold(form.html_template); - if (bodyOnly) { - // Ensure Quill properly converts HTML to Delta - editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api"); - } else { - editor.setText( - "(Body is empty. Switch to Source HTML to edit full document.)", - "api" - ); - } - } - editor.on("text-change", (_delta, _old, source) => { - // Ignore programmatic changes; only persist user edits - if (source !== "user") { - return; - } - if (!sourceMode.value) { - const bodyHtml = editor.root.innerHTML; - form.html_template = containsDocScaffold(form.html_template) - ? replaceBody(form.html_template, bodyHtml) - : bodyHtml; - } - }); - } - } catch (e) { - console.error("Failed to mount Quill", e); - } - // Initialize iframe editor if advanced mode is on - if (advancedMode.value) { - initIframeEditor(); - } + // Advanced editor is the only mode + activeField.value = "html"; + initIframeEditor(); }); // --- Variable insertion and sample entity selection --- const subjectRef = ref(null); -const quillContainer = ref(null); const htmlSourceRef = ref(null); const textRef = ref(null); -const activeField = ref(null); // 'subject' | 'html' | 'text' -const quill = ref(null); -const sourceMode = ref(false); // toggle for HTML source editing +const activeField = ref("html"); // default to HTML for variable inserts +// Raw HTML editor toggle (full-document source) +const rawMode = ref(false); // Advanced full-document editor that renders styles from -const advancedMode = ref(false); +const advancedMode = ref(true); const iframeRef = ref(null); let iframeSyncing = false; const selectedImageSrc = ref(""); +// Preview iframe ref (to render preview HTML with styles applied) +const previewIframeRef = ref(null); // Detect and handle full-document HTML so Quill doesn't wipe content function containsDocScaffold(html) { @@ -261,20 +226,9 @@ function extractBody(html) { return m ? m[1] : html; } +// Retained for compatibility, but no longer used actively function stripDocScaffold(html) { - if (!html) return ""; - let out = html; - // Prefer body content when present - out = extractBody(out); - // Remove comments, doctype, html/head blocks, meta/title, and styles (Quill can't keep these) - out = out - .replace(//gi, "") - .replace(/<\/?html[^>]*>/gi, "") - .replace(/[\s\S]*?<\/head>/gi, "") - .replace(/<\/?meta[^>]*>/gi, "") - .replace(/[\s\S]*?<\/title>/gi, "") - .replace(/[\s\S]*?<\/style>/gi, ""); - return out.trim(); + return extractBody(html || ""); } // Replace only the inner content of ... in a full document @@ -287,82 +241,31 @@ function replaceBody(htmlDoc, newBody) { return htmlDoc.replace(/(]*>)[\s\S]*?(<\/body>)/i, `$1${newBody || ""}$2`); } -// Keep Quill and textarea in sync when toggling source mode -watch( - () => sourceMode.value, - (on) => { - if (on) { - // Switching to source view: if current model is NOT a full document, - // sync from Quill. Otherwise, keep the full source untouched. - if (quill.value && !containsDocScaffold(form.html_template)) { - form.html_template = quill.value.root.innerHTML; - } - } else { - // switching back to WYSIWYG: update editor html from model - if (quill.value) { - const bodyOnly = stripDocScaffold(form.html_template || ""); - if (bodyOnly) { - quill.value.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api"); - } else { - quill.value.setText( - "(Body is empty. Switch to Source HTML to edit full document.)", - "api" - ); - } - } else if (quillContainer.value) { - // if the instance doesn't exist for any reason, re-create it - const editor = new Quill(quillContainer.value, { - theme: "snow", - modules: quillModules.value, - }); - quill.value = editor; - const bodyOnly = stripDocScaffold(form.html_template || ""); - if (bodyOnly) { - editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api"); - } else { - editor.setText( - "(Body is empty. Switch to Source HTML to edit full document.)", - "api" - ); - } - editor.on("text-change", (_d, _o, source) => { - if (source !== "user") { - return; - } - if (!sourceMode.value) { - const bodyHtml = editor.root.innerHTML; - form.html_template = containsDocScaffold(form.html_template) - ? replaceBody(form.html_template, bodyHtml) - : bodyHtml; - } - }); - } - } - } -); +// Quill/source mode removed -// Keep advanced editor in a stable state with source/quill -watch( - () => advancedMode.value, - async (on) => { - if (on) { - sourceMode.value = false; - await nextTick(); - initIframeEditor(); - } - } -); +// Advanced mode is always on // When HTML changes externally, reflect it into iframe (unless we're the ones syncing) watch( () => form.html_template, async () => { - if (!advancedMode.value || iframeSyncing) return; + if (iframeSyncing) return; await nextTick(); writeIframeDocument(); } ); +// Re-initialize iframe editor when switching back from Raw HTML +watch( + () => rawMode.value, + async (on) => { + if (!on) { + await nextTick(); + initIframeEditor(); + } + } +); + function ensureFullDoc(html) { if (!html) return ''; @@ -401,6 +304,8 @@ function initIframeEditor() { } else { selectedImageSrc.value = ""; } + // Ensure variable buttons target HTML editor + activeField.value = "html"; }); const handler = debounce(() => { if (!advancedMode.value) return; @@ -416,6 +321,17 @@ function initIframeEditor() { doc.addEventListener("keyup", handler); } +function writePreviewDocument() { + const iframe = previewIframeRef.value; + if (!iframe) return; + const doc = iframe.contentDocument; + if (!doc) return; + const html = ensureFullDoc(preview.value?.html || form.html_template || ""); + doc.open(); + doc.write(html); + doc.close(); +} + function iframeExec(command, value = null) { const iframe = iframeRef.value; if (!iframe) return; @@ -522,6 +438,128 @@ function setActive(field) { activeField.value = field; } +async function deleteAttachedImage(doc) { + if (!props.template?.id) return; + if (!doc?.id) return; + const confirmed = window.confirm( + "Odstranim to sliko iz predloge? Datoteka bo izbrisana." + ); + if (!confirmed) return; + try { + await window.axios.delete( + route("admin.email-templates.images.delete", { + emailTemplate: props.template.id, + document: doc.id, + }) + ); + await removeLocalDoc(doc.id); + // After deletion, scrub or replace references in current HTML + tryReplaceOrRemoveDeletedImageReferences(doc); + } catch (e) { + console.error("Delete image failed", e); + alert("Brisanje slike ni uspelo."); + } +} + +function tryReplaceOrRemoveDeletedImageReferences(deletedDoc) { + const deletedPath = deletedDoc?.path || ""; + if (!deletedPath) return; + const targetRel = "/storage/" + deletedPath.replace(/^\/+/, ""); + + // Helper: does an src match the deleted doc path (relative or absolute)? + const srcMatches = (src) => { + if (!src) return false; + try { + // Absolute URL: compare its pathname + const u = new URL(src, window.location.origin); + return u.pathname === targetRel; + } catch { + // Relative path string + if (src === targetRel) return true; + // Also accept raw disk path variant (unlikely in HTML) + if (src.replace(/^\/+/, "") === targetRel.replace(/^\/+/, "")) return true; + return false; + } + }; + + // Choose a replacement doc based on when possible; else, if only one image remains, use that. + const pickReplacement = (altText) => { + const remaining = (docsRaw.value || []).slice(); + if (!remaining.length) return null; + const norm = (s) => (s || "").toString().toLowerCase(); + const stem = (name) => + (name || "") + .toString() + .toLowerCase() + .replace(/\.[^.]+$/, ""); + const simplify = (s) => norm(s).replace(/[^a-z0-9]+/g, ""); + + if (altText) { + const altKey = simplify(altText); + // exact name stem match (name, file_name, original_name) + const exact = remaining.find((d) => { + const candidates = [d.name, d.file_name, d.original_name].map(stem); + return candidates.some( + (c) => simplify(c) === altKey || norm(c) === norm(altText) + ); + }); + if (exact) return exact; + // relaxed contains on simplified stems + const relaxed = remaining.find((d) => { + const candidates = [d.name, d.file_name, d.original_name].map(stem).map(simplify); + return candidates.some((c) => c && altKey && c.includes(altKey)); + }); + if (relaxed) return relaxed; + } + if (remaining.length === 1) return remaining[0]; + return null; + }; + + const replaceInDocument = (docEl) => { + if (!docEl) return false; + let changed = false; + const imgs = Array.from(docEl.querySelectorAll("img")); + imgs.forEach((img) => { + const src = img.getAttribute("src"); + if (!srcMatches(src)) return; + const alt = img.getAttribute("alt") || ""; + const replacement = pickReplacement(alt); + if (replacement && replacement.path) { + img.setAttribute("src", "/storage/" + replacement.path.replace(/^\/+/, "")); + } else { + // No replacement – remove the image tag entirely + img.parentNode && img.parentNode.removeChild(img); + } + changed = true; + }); + return changed; + }; + + if (!rawMode.value) { + // Advanced iframe editor + const iframe = iframeRef.value; + const doc = iframe?.contentDocument; + if (doc && doc.documentElement) { + const changed = replaceInDocument(doc); + if (changed) { + iframeSyncing = true; + form.html_template = doc.documentElement.outerHTML; + iframeSyncing = false; + } + } + } else { + // Raw mode: parse and mutate via DOMParser + const html = form.html_template || ""; + const full = ensureFullDoc(html); + const parser = new DOMParser(); + const parsed = parser.parseFromString(full, "text/html"); + const changed = replaceInDocument(parsed); + if (changed) { + form.html_template = parsed.documentElement.outerHTML; + } + } +} + function insertAtCursor(el, value, modelGetter, modelSetter) { if (!el) return; const start = el.selectionStart ?? 0; @@ -549,8 +587,8 @@ function insertPlaceholder(token) { (v) => (form.subject_template = v) ); } else if (activeField.value === "html") { - // If in source mode, treat HTML as textarea - if (sourceMode.value && htmlSourceRef.value) { + // If editing raw source, insert at caret into textarea + if (rawMode.value && htmlSourceRef.value) { insertAtCursor( htmlSourceRef.value, content, @@ -559,16 +597,31 @@ function insertPlaceholder(token) { ); return; } - // Insert into Quill editor at current selection - if (quill.value) { - let range = quill.value.getSelection(true); - const index = range ? range.index : quill.value.getLength() - 1; - quill.value.insertText(index, content, "user"); - quill.value.setSelection(index + content.length, 0, "user"); - // Sync back to form model as HTML - form.html_template = quill.value.root.innerHTML; + // Otherwise, insert into the iframe at caret position + // Insert into the iframe at caret position without rewriting the document + const iframe = iframeRef.value; + const doc = iframe?.contentDocument; + if (doc) { + const sel = doc.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.deleteContents(); + const node = doc.createTextNode(content); + range.insertNode(node); + // place caret after inserted node + range.setStartAfter(node); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } else { + doc.body.appendChild(doc.createTextNode(content)); + } + // Sync back to model + iframeSyncing = true; + form.html_template = doc.documentElement.outerHTML; + iframeSyncing = false; } else { - // Fallback: append to the end of the model + // last resort form.html_template = (form.html_template || "") + content; } } else if (activeField.value === "text" && textRef.value) { @@ -581,53 +634,7 @@ function insertPlaceholder(token) { } } -// Quill toolbar & image upload handler -const quillModules = computed(() => ({ - toolbar: { - container: [ - ["bold", "italic", "underline", "strike"], - [{ header: [1, 2, 3, false] }], - [{ list: "ordered" }, { list: "bullet" }], - ["link", "image"], - [{ align: [] }], - ["clean"], - ], - handlers: { - image: () => onQuillImageUpload(), - }, - }, -})); - -function onQuillImageUpload() { - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/*"; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return; - const data = new FormData(); - data.append("file", file); - try { - const { data: res } = await window.axios.post( - route("admin.email-templates.upload-image"), - data, - { headers: { "Content-Type": "multipart/form-data" } } - ); - const url = res?.url; - if (url && quill.value) { - const range = quill.value.getSelection(true); - const index = range ? range.index : quill.value.getLength(); - quill.value.insertEmbed(index, "image", url, "user"); - quill.value.setSelection(index + 1, 0, "user"); - form.html_template = quill.value.root.innerHTML; - } - } catch (e) { - // optional: show toast - console.error("Image upload failed", e); - } - }; - input.click(); -} +// Quill handlers removed; image actions handled by iframe toolbar const placeholderGroups = computed(() => { const groups = []; @@ -643,10 +650,15 @@ const placeholderGroups = computed(() => { ]); } if (want.has("client")) { - add("client", "Client", ["client.id", "client.uuid"]); + add("client", "Client", ["client.id", "client.uuid", "client.person.full_name"]); } if (want.has("client_case")) { - add("case", "Case", ["case.id", "case.uuid", "case.reference"]); + add("case", "Case", [ + "case.id", + "case.uuid", + "case.reference", + "case.person.full_name", + ]); } if (want.has("contract")) { add("contract", "Contract", [ @@ -657,13 +669,28 @@ const placeholderGroups = computed(() => { "contract.meta.some_key", ]); } + // Activity placeholders (always useful if template references workflow actions/decisions) + add("activity", "Activity", [ + "activity.id", + "activity.note", + "activity.due_date", + "activity.amount", + "activity.action.name", + "activity.decision.name", + ]); // Extra is always useful for ad-hoc data add("extra", "Extra", ["extra.some_key"]); return groups; }); // Sample entity selection for preview -const sample = ref({ client_id: "", case_id: "", contract_id: "", extra: "" }); +const sample = ref({ + client_id: "", + case_id: "", + contract_id: "", + activity_id: "", + extra: "", +}); // Cascading select options const clients = ref([]); @@ -716,6 +743,10 @@ watch( () => sample.value.contract_id, () => doPreview() ); +watch( + () => sample.value.activity_id, + () => doPreview() +); function applySample() { fetchPreview(); @@ -745,6 +776,7 @@ function sendTest() { subject: form.subject_template, html: form.html_template, text: form.text_template, + activity_id: sample.value.activity_id || undefined, client_id: sample.value.client_id || undefined, person_id: sample.value.person_id || undefined, case_id: sample.value.case_id || undefined, @@ -780,6 +812,8 @@ function sendTest() { } } catch {} notify("Testni e-poštni naslov je bil poslan.", "success"); + // Slight UX tweak: controller now queues the email + // (flash success message from backend reflects 'queued') }, onError: () => { // Validation errors trigger onError; controller may also set flash('error') on redirect @@ -798,6 +832,15 @@ function sendTest() { }, }); } + +// Keep preview iframe in sync with server-rendered preview +watch( + () => preview.value?.html, + async () => { + await nextTick(); + writePreviewDocument(); + } +);