From e3bc5da7e3319e91528eb5f166cabf9533b6ca37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Mon, 11 May 2026 21:32:30 +0200 Subject: [PATCH] Package and individual mail sender, new report, and other changes Co-authored-by: Copilot --- .../Controllers/Admin/EmailLogController.php | 12 + .../Admin/EmailTemplateController.php | 28 +- .../Admin/MailProfileController.php | 2 +- .../Controllers/Admin/PackageController.php | 307 ++++++- app/Http/Controllers/ClientCaseContoller.php | 167 ++++ app/Http/Controllers/PersonController.php | 2 + app/Http/Controllers/ReportController.php | 49 +- .../StoreEmailPackageFromContractsRequest.php | 35 + .../Requests/StoreEmailTemplateRequest.php | 3 + app/Http/Requests/StoreMailProfileRequest.php | 2 + .../Requests/UpdateEmailTemplateRequest.php | 3 + .../Requests/UpdateMailProfileRequest.php | 2 + app/Jobs/PackageItemEmailJob.php | 296 +++++++ app/Jobs/SendEmailTemplateJob.php | 5 + app/Models/Activity.php | 6 + app/Models/Email.php | 2 + app/Models/EmailLog.php | 6 + app/Models/EmailTemplate.php | 15 + app/Models/MailProfile.php | 3 +- app/Models/Package.php | 2 + app/Services/AutoMailDispatcher.php | 23 +- app/Services/ClientCaseDataService.php | 3 +- app/Services/Contact/EmailSelector.php | 54 ++ app/Services/EmailTemplateRenderer.php | 41 +- ...1_add_signature_to_mail_profiles_table.php | 22 + ...add_action_decision_to_email_templates.php | 25 + ...00002_create_activity_email_logs_table.php | 25 + ...5_11_184026_add_failed_to_emails_table.php | 28 + ...09_add_client_to_email_templates_table.php | 28 + database/seeders/ReportsSeeder.php | 262 ++++++ .../Components/PersonInfo/EmailCreateForm.vue | 30 + .../PersonInfo/PersonInfoEmailDialog.vue | 483 +++++++++++ .../PersonInfo/PersonInfoEmailsTab.vue | 37 +- .../Components/PersonInfo/PersonInfoGrid.vue | 34 + resources/js/Layouts/AppLayout.vue | 2 +- .../js/Pages/Admin/EmailTemplates/Edit.vue | 76 +- .../js/Pages/Admin/MailProfiles/Index.vue | 118 ++- .../js/Pages/Cases/Partials/ActivityTable.vue | 69 +- resources/js/Pages/Cases/Show.vue | 1 + resources/js/Pages/Packages/Create.vue | 52 +- resources/js/Pages/Packages/Index.vue | 203 +---- resources/js/Pages/Packages/Mail/Create.vue | 592 +++++++++++++ resources/js/Pages/Packages/Mail/Index.vue | 177 ++++ resources/js/Pages/Packages/Mail/Show.vue | 242 ++++++ resources/js/Pages/Packages/Sms/Create.vue | 791 ++++++++++++++++++ resources/js/Pages/Packages/Sms/Index.vue | 177 ++++ resources/js/Pages/Packages/Sms/Show.vue | 333 ++++++++ resources/js/Pages/Reports/Show.vue | 84 +- routes/web.php | 44 +- 49 files changed, 4754 insertions(+), 249 deletions(-) create mode 100644 app/Http/Requests/StoreEmailPackageFromContractsRequest.php create mode 100644 app/Jobs/PackageItemEmailJob.php create mode 100644 app/Services/Contact/EmailSelector.php create mode 100644 database/migrations/2026_05_10_000001_add_signature_to_mail_profiles_table.php create mode 100644 database/migrations/2026_05_11_000001_add_action_decision_to_email_templates.php create mode 100644 database/migrations/2026_05_11_000002_create_activity_email_logs_table.php create mode 100644 database/migrations/2026_05_11_184026_add_failed_to_emails_table.php create mode 100644 database/migrations/2026_05_11_193109_add_client_to_email_templates_table.php create mode 100644 resources/js/Components/PersonInfo/PersonInfoEmailDialog.vue create mode 100644 resources/js/Pages/Packages/Mail/Create.vue create mode 100644 resources/js/Pages/Packages/Mail/Index.vue create mode 100644 resources/js/Pages/Packages/Mail/Show.vue create mode 100644 resources/js/Pages/Packages/Sms/Create.vue create mode 100644 resources/js/Pages/Packages/Sms/Index.vue create mode 100644 resources/js/Pages/Packages/Sms/Show.vue diff --git a/app/Http/Controllers/Admin/EmailLogController.php b/app/Http/Controllers/Admin/EmailLogController.php index 2f839e6..6170a4d 100644 --- a/app/Http/Controllers/Admin/EmailLogController.php +++ b/app/Http/Controllers/Admin/EmailLogController.php @@ -6,6 +6,7 @@ use App\Models\EmailLog; use App\Models\EmailTemplate; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -69,4 +70,15 @@ public function show(EmailLog $emailLog): Response 'log' => $emailLog, ]); } + + public function body(EmailLog $emailLog): JsonResponse + { + $this->authorize('viewAny', EmailTemplate::class); + + $emailLog->load('body'); + + return response()->json([ + 'html' => $emailLog->body?->body_html ?? '', + ]); + } } diff --git a/app/Http/Controllers/Admin/EmailTemplateController.php b/app/Http/Controllers/Admin/EmailTemplateController.php index 65cebe4..b55fa64 100644 --- a/app/Http/Controllers/Admin/EmailTemplateController.php +++ b/app/Http/Controllers/Admin/EmailTemplateController.php @@ -13,6 +13,7 @@ use App\Models\EmailLog; use App\Models\EmailLogStatus; use App\Models\EmailTemplate; +use App\Models\MailProfile; use App\Services\EmailTemplateRenderer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; @@ -55,8 +56,14 @@ public function create(): Response { $this->authorize('create', EmailTemplate::class); + $actions = \App\Models\Action::query() + ->with(['decisions:id,name']) + ->orderBy('name') + ->get(['id', 'name']); + return Inertia::render('Admin/EmailTemplates/Edit', [ 'template' => null, + 'actions' => $actions, ]); } @@ -93,7 +100,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes // 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); + $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id); if ($activity) { $ctx['activity'] = $activity; // Derive base entities from activity when not explicitly provided @@ -110,7 +117,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes } } if ($id = $request->integer('contract_id')) { - $contract = Contract::query()->with(['clientCase.client.person'])->find($id); + $contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id); if ($contract) { $ctx['contract'] = $contract; if ($contract->clientCase) { @@ -140,6 +147,7 @@ public function preview(Request $request, EmailTemplate $emailTemplate): JsonRes } } $ctx['extra'] = (array) $request->input('extra', []); + $ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first(); $rendered = $renderer->render([ 'subject' => $subject, @@ -161,8 +169,14 @@ public function edit(EmailTemplate $emailTemplate): Response $q->select(['id', 'documentable_id', 'documentable_type', 'name', 'path', 'size', 'created_at']); }]); + $actions = \App\Models\Action::query() + ->with(['decisions:id,name']) + ->orderBy('name') + ->get(['id', 'name']); + return Inertia::render('Admin/EmailTemplates/Edit', [ 'template' => $emailTemplate, + 'actions' => $actions, ]); } @@ -181,7 +195,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate) // Context resolution $ctx = []; if ($id = $request->integer('activity_id')) { - $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'clientCase.client.person'])->find($id); + $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id); if ($activity) { $ctx['activity'] = $activity; if ($activity->contract && ! isset($ctx['contract'])) { @@ -197,7 +211,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate) } } if ($id = $request->integer('contract_id')) { - $contract = Contract::query()->with(['clientCase.client.person'])->find($id); + $contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id); if ($contract) { $ctx['contract'] = $contract; if ($contract->clientCase) { @@ -227,6 +241,7 @@ public function sendTest(Request $request, EmailTemplate $emailTemplate) } } $ctx['extra'] = (array) $request->input('extra', []); + $ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first(); // Render preview values; we store a minimal snapshot on the log $rendered = $renderer->render([ @@ -293,7 +308,7 @@ 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); + $activity = Activity::query()->with(['action', 'decision', 'contract.clientCase.client.person', 'contract.account', 'clientCase.client.person'])->find($id); if ($activity) { $ctx['activity'] = $activity; if ($activity->contract && ! isset($ctx['contract'])) { @@ -309,7 +324,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate) } } if ($id = $request->integer('contract_id')) { - $contract = Contract::query()->with(['clientCase.client.person'])->find($id); + $contract = Contract::query()->with(['clientCase.client.person', 'account'])->find($id); if ($contract) { $ctx['contract'] = $contract; if ($contract->clientCase) { @@ -339,6 +354,7 @@ public function renderFinalHtml(Request $request, EmailTemplate $emailTemplate) } } $ctx['extra'] = (array) $request->input('extra', []); + $ctx['mail_profile'] = MailProfile::query()->orderBy('active', 'desc')->orderBy('priority')->orderBy('id')->first(); $rendered = $renderer->render([ 'subject' => $subject, diff --git a/app/Http/Controllers/Admin/MailProfileController.php b/app/Http/Controllers/Admin/MailProfileController.php index 9a62f8c..a4dfeb9 100644 --- a/app/Http/Controllers/Admin/MailProfileController.php +++ b/app/Http/Controllers/Admin/MailProfileController.php @@ -26,7 +26,7 @@ public function index(): Response ->orderBy('priority') ->orderBy('id') ->get([ - 'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at', + 'id', 'name', 'active', 'host', 'port', 'username', 'from_name', 'encryption', 'from_address', 'priority', 'signature', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at', ]); return Inertia::render('Admin/MailProfiles/Index', [ diff --git a/app/Http/Controllers/Admin/PackageController.php b/app/Http/Controllers/Admin/PackageController.php index 4db3f98..1ea8b4c 100644 --- a/app/Http/Controllers/Admin/PackageController.php +++ b/app/Http/Controllers/Admin/PackageController.php @@ -3,13 +3,16 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreEmailPackageFromContractsRequest; use App\Http\Requests\StorePackageFromContractsRequest; use App\Http\Requests\StorePackageRequest; +use App\Jobs\PackageItemEmailJob; use App\Jobs\PackageItemSmsJob; use App\Models\Contract; use App\Models\Package; use App\Models\PackageItem; use App\Models\SmsTemplate; +use App\Services\Contact\EmailSelector; use App\Services\Contact\PhoneSelector; use App\Services\Sms\SmsService; use Illuminate\Http\RedirectResponse; @@ -21,20 +24,40 @@ class PackageController extends Controller { - public function index(Request $request): Response + public function landing(): Response + { + return Inertia::render('Packages/Index'); + } + + public function smsIndex(Request $request): Response { $perPage = $request->input('per_page') ?? 25; $packages = Package::query() + ->where('type', Package::TYPE_SMS) ->latest('id') ->paginate($perPage); - return Inertia::render('Packages/Index', [ + return Inertia::render('Packages/Sms/Index', [ 'packages' => $packages, ]); } - public function create(Request $request): Response + public function emailIndex(Request $request): Response + { + $perPage = $request->input('per_page') ?? 25; + + $packages = Package::query() + ->where('type', Package::TYPE_EMAIL) + ->latest('id') + ->paginate($perPage); + + return Inertia::render('Packages/Mail/Index', [ + 'packages' => $packages, + ]); + } + + public function smsCreate(Request $request): Response { // Minimal lookups for create form (active only) $profiles = \App\Models\SmsProfile::query() @@ -69,7 +92,7 @@ public function create(Request $request): Response }) ->values(); - return Inertia::render('Packages/Create', [ + return Inertia::render('Packages/Sms/Create', [ 'profiles' => $profiles, 'senders' => $senders, 'templates' => $templates, @@ -78,7 +101,53 @@ public function create(Request $request): Response ]); } - public function show(Package $package, SmsService $sms): Response + public function emailCreate(): Response + { + $emailTemplates = \App\Models\EmailTemplate::query() + ->where('active', true) + ->where('client', false) + ->orderBy('name') + ->get(['id', 'name', 'subject_template', 'text_template', 'html_template']) + ->map(fn ($t) => [ + 'id' => $t->id, + 'name' => $t->name, + 'subject_template' => $t->subject_template, + 'text_template' => $t->text_template, + 'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $t->html_template), + ])->values(); + $mailProfiles = \App\Models\MailProfile::query() + ->where('active', true) + ->orderBy('priority') + ->get(['id', 'name']); + $segments = \App\Models\Segment::query() + ->where('active', true) + ->where('exclude', false) + ->orderBy('name') + ->get(['id', 'name']); + $clients = \App\Models\Client::query() + ->with(['person' => function ($q) { + $q->select('id', 'uuid', 'full_name'); + }]) + ->latest('id') + ->get(['id', 'uuid', 'person_id']) + ->map(function ($c) { + return [ + 'id' => $c->id, + 'uuid' => $c->uuid, + 'name' => $c->person?->full_name ?? ('Client #'.$c->id), + ]; + }) + ->values(); + + return Inertia::render('Packages/Mail/Create', [ + 'emailTemplates' => $emailTemplates, + 'mailProfiles' => $mailProfiles, + 'segments' => $segments, + 'clients' => $clients, + ]); + } + + public function smsShow(Package $package, SmsService $sms): Response { $items = $package->items()->latest('id')->paginate(25); @@ -212,13 +281,23 @@ public function show(Package $package, SmsService $sms): Response } } - return Inertia::render('Packages/Show', [ + return Inertia::render('Packages/Sms/Show', [ 'package' => $package, 'items' => $items, 'preview' => $preview, ]); } + public function emailShow(Package $package): Response + { + $items = $package->items()->latest('id')->paginate(25); + + return Inertia::render('Packages/Mail/Show', [ + 'package' => $package, + 'items' => $items, + ]); + } + public function store(StorePackageRequest $request): RedirectResponse { $data = $request->validated(); @@ -260,7 +339,11 @@ public function dispatch(Package $package): RedirectResponse return back()->with('error', 'Package not in a dispatchable state.'); } - $jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) { + $jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) use ($package) { + if ($package->type === Package::TYPE_EMAIL) { + return new PackageItemEmailJob($item->id); + } + return new PackageItemSmsJob($item->id); })->all(); @@ -286,7 +369,7 @@ public function dispatch(Package $package): RedirectResponse $package->save(); } }) - ->onQueue('sms') + ->onQueue($package->type === Package::TYPE_EMAIL ? 'email' : 'sms') ->dispatch(); return back()->with('success', 'Package dispatched'); @@ -445,6 +528,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat 'number' => $phone->nu, 'validated' => $phone->validated, 'type' => $phone->phone_type?->value, + 'description' => $phone->description, ] : null, 'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'), ]; @@ -541,6 +625,213 @@ public function storeFromContracts(StorePackageFromContractsRequest $request, Ph return back()->with('success', 'Package created from contracts'); } + /** + * List contracts with selected email per person (for email packages). + */ + public function contractsForEmail(Request $request, EmailSelector $selector): \Illuminate\Http\JsonResponse + { + $request->validate([ + 'segment_id' => ['nullable', 'integer', 'exists:segments,id'], + 'q' => ['nullable', 'string'], + 'client_id' => ['nullable', 'integer', 'exists:clients,id'], + 'only_verified' => ['nullable', 'boolean'], + 'only_with_email' => ['nullable', 'boolean'], + 'start_date_from' => ['nullable', 'date'], + 'start_date_to' => ['nullable', 'date'], + 'promise_date_from' => ['nullable', 'date'], + 'promise_date_to' => ['nullable', 'date'], + ]); + + $segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null; + + $query = Contract::query() + ->with([ + 'clientCase.person.emails', + 'clientCase.client.person', + 'account', + 'segments:id,name', + ]) + ->select('contracts.*') + ->latest('contracts.id'); + + if ($segmentId) { + $query->join('contract_segment', function ($j) use ($segmentId) { + $j->on('contract_segment.contract_id', '=', 'contracts.id') + ->where('contract_segment.segment_id', '=', $segmentId) + ->where('contract_segment.active', true); + }); + } else { + $query->whereExists(fn ($exist) => $exist->select(\DB::raw(1)) + ->from('contract_segment') + ->join('segments', 'segments.id', '=', 'contract_segment.segment_id') + ->where('contract_segment.active', true) + ->where('segments.exclude', false) + ->whereColumn('contract_segment.contract_id', 'contracts.id') + ); + } + + if ($q = trim((string) $request->input('q'))) { + $query->where('contracts.reference', 'ILIKE', "%{$q}%"); + } + + if ($clientId = $request->integer('client_id')) { + $query->join('client_cases', 'client_cases.id', '=', 'contracts.client_case_id') + ->where('client_cases.client_id', $clientId); + } + + if ($startDateFrom = $request->input('start_date_from')) { + $query->where('contracts.start_date', '>=', $startDateFrom); + } + + if ($startDateTo = $request->input('start_date_to')) { + $query->where('contracts.start_date', '<=', $startDateTo); + } + + $promiseDateFrom = $request->input('promise_date_from'); + $promiseDateTo = $request->input('promise_date_to'); + + if ($promiseDateFrom || $promiseDateTo) { + $query->whereHas('account', function ($q) use ($promiseDateFrom, $promiseDateTo) { + if ($promiseDateFrom) { + $q->where('promise_date', '>=', $promiseDateFrom); + } + if ($promiseDateTo) { + $q->where('promise_date', '<=', $promiseDateTo); + } + }); + } + + if ($request->boolean('only_verified')) { + $query->whereHas('clientCase.person.emails', function ($q) { + $q->where('is_active', true)->whereNotNull('verified_at'); + }); + } + + if ($request->boolean('only_with_email')) { + $query->whereHas('clientCase.person.emails', function ($q) { + $q->where('is_active', true); + }); + } + + $contracts = $query->limit(500)->get(); + + $data = collect($contracts)->map(function (Contract $contract) use ($selector) { + $person = $contract->clientCase?->person; + $selected = $person ? $selector->selectForPerson($person) : ['email' => null, 'reason' => 'no_person']; + $email = $selected['email']; + $clientPerson = $contract->clientCase?->client?->person; + $segment = collect($contract->segments)->last(); + + return [ + 'id' => $contract->id, + 'uuid' => $contract->uuid, + 'reference' => $contract->reference, + 'start_date' => $contract->start_date, + 'promise_date' => $contract->account?->promise_date, + 'case' => [ + 'id' => $contract->clientCase?->id, + 'uuid' => $contract->clientCase?->uuid, + ], + 'person' => [ + 'id' => $person?->id, + 'uuid' => $person?->uuid, + 'full_name' => $person?->full_name, + ], + 'segment' => $segment, + 'client' => $clientPerson ? [ + 'id' => $contract->clientCase?->client?->id, + 'uuid' => $contract->clientCase?->client?->uuid, + 'name' => $clientPerson->full_name, + ] : null, + 'selected_email' => $email ? [ + 'id' => $email->id, + 'value' => $email->value, + 'is_primary' => $email->is_primary, + 'verified' => $email->verified_at !== null, + 'label' => $email->label, + ] : null, + 'no_email_reason' => $email ? null : ($selected['reason'] ?? 'unknown'), + ]; + }); + + return response()->json(['data' => $data]); + } + + /** + * Create an email package from a list of contracts by selecting recipient emails. + */ + public function storeEmailFromContracts(StoreEmailPackageFromContractsRequest $request, EmailSelector $selector): RedirectResponse + { + $data = $request->validated(); + + $contracts = Contract::query() + ->with(['clientCase.person', 'account.type']) + ->whereIn('id', $data['contract_ids']) + ->get(); + + $items = []; + $skipped = 0; + foreach ($contracts as $contract) { + $person = $contract->clientCase?->person; + if (! $person) { + $skipped++; + + continue; + } + $selected = $selector->selectForPerson($person); + /** @var ?\App\Models\Email $email */ + $email = $selected['email']; + if (! $email) { + $skipped++; + + continue; + } + $items[] = [ + 'email' => $email->value, + 'email_id' => $email->id, + 'payload' => $data['payload'] ?? [], + 'contract_id' => $contract->id, + 'account_id' => $contract->account?->id, + ]; + } + + if (empty($items)) { + return back()->with('error', 'No recipients found for selected contracts.'); + } + + $package = Package::query()->create([ + 'uuid' => (string) Str::uuid(), + 'type' => Package::TYPE_EMAIL, + 'status' => Package::STATUS_DRAFT, + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'meta' => array_merge($data['meta'] ?? [], [ + 'source' => 'contracts', + 'skipped' => $skipped, + ]), + 'created_by' => optional($request->user())->id, + ]); + + $packageItems = collect($items)->map(function (array $row) { + return new PackageItem([ + 'status' => 'queued', + 'target_json' => [ + 'email' => $row['email'], + 'email_id' => $row['email_id'], + 'contract_id' => $row['contract_id'] ?? null, + 'account_id' => $row['account_id'] ?? null, + ], + 'payload_json' => $row['payload'] ?? [], + ]); + }); + + $package->items()->saveMany($packageItems); + $package->total_items = $packageItems->count(); + $package->save(); + + return back()->with('success', 'Email package created from contracts'); + } + /** * Flatten nested meta structure into dot-notation key-value pairs. * Extracts 'value' from objects with {title, value, type} structure. diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index f7e2285..4a9468a 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -921,6 +921,18 @@ public function show(ClientCase $clientCase) ->select(['id', 'name', 'content', 'allow_custom_body']) ->orderBy('name') ->get(), + 'email_templates' => \App\Models\EmailTemplate::query() + ->select(['id', 'name', 'subject_template', 'text_template', 'action_id', 'decision_id']) + ->where('active', true) + ->where('client', false) + ->orderBy('name') + ->get(), + 'mail_profiles' => \App\Models\MailProfile::query() + ->select(['id', 'name']) + ->where('active', true) + ->orderBy('priority') + ->orderBy('name') + ->get(), 'auto_mail_decisions' => \App\Models\Decision::query()->where('auto_mail', true)->orderBy('name')->get(['id', 'name']), ]); } @@ -1575,6 +1587,161 @@ public function previewSms(ClientCase $clientCase, Request $request, SmsService * Extracts 'value' from objects with {title, value, type} structure. * Also creates direct access aliases for nested fields (skipping numeric keys). */ + /** + * Render an email template preview with context from the client case. + */ + public function previewEmailForEmail(ClientCase $clientCase, Request $request, int $email_id): \Illuminate\Http\JsonResponse + { + $validated = $request->validate([ + 'template_id' => ['required', 'integer', 'exists:email_templates,id'], + 'contract_uuid' => ['sometimes', 'nullable', 'uuid'], + 'body_text' => ['sometimes', 'nullable', 'string', 'max:10000'], + ]); + + $email = \App\Models\Email::query() + ->where('id', $email_id) + ->where('person_id', $clientCase->person_id) + ->firstOrFail(); + + $template = \App\Models\EmailTemplate::findOrFail((int) $validated['template_id']); + + $contract = null; + if (! empty($validated['contract_uuid'])) { + $contract = $clientCase->contracts() + ->where('uuid', $validated['contract_uuid']) + ->first(); + } + + $ctx = $this->buildCaseEmailContext($clientCase, $contract); + $ctx['body_text'] = (string) ($validated['body_text'] ?? ''); + + $renderer = app(\App\Services\EmailTemplateRenderer::class); + $rendered = $renderer->render([ + 'subject' => (string) $template->subject_template, + 'html' => (string) $template->html_template, + 'text' => (string) $template->text_template, + ], $ctx); + + return response()->json([ + 'subject' => $rendered['subject'] ?? '', + 'html' => (string) ($rendered['html'] ?? ''), + 'has_body_text' => (bool) preg_match('/{{\s*body_text\s*}}/', (string) $template->html_template), + ]); + } + + /** + * Send a (possibly templated) email to a person email address belonging to this case. + */ + public function sendEmailToEmail(ClientCase $clientCase, Request $request, int $email_id) + { + $validated = $request->validate([ + 'subject' => ['required', 'string', 'max:255'], + 'html_body' => ['nullable', 'string'], + 'body_text' => ['nullable', 'string', 'max:10000'], + 'template_id' => ['sometimes', 'nullable', 'integer', 'exists:email_templates,id'], + 'mail_profile_id' => ['sometimes', 'nullable', 'integer', 'exists:mail_profiles,id'], + 'contract_uuid' => ['sometimes', 'nullable', 'uuid'], + ]); + + // Ensure the email belongs to the person of this case + $email = \App\Models\Email::query() + ->where('id', $email_id) + ->where('person_id', $clientCase->person_id) + ->firstOrFail(); + + $to = (string) $email->value; + + /** @var \App\Models\MailProfile|null $mailProfile */ + $mailProfile = ! empty($validated['mail_profile_id']) + ? \App\Models\MailProfile::query()->where('id', $validated['mail_profile_id'])->where('active', true)->first() + : \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(); + + if (! $mailProfile) { + return back()->with('error', 'Ni aktivnega e-poštnega profila.'); + } + + $contract = null; + if (! empty($validated['contract_uuid'])) { + $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first(); + } + + $htmlBody = (string) ($validated['html_body'] ?? ''); + $bodyText = (string) ($validated['body_text'] ?? ''); + + // Apply {{body_text}} substitution if the html body contains the placeholder + if ($bodyText !== '' && preg_match('/{{\s*body_text\s*}}/', $htmlBody)) { + $renderer = app(\App\Services\EmailTemplateRenderer::class); + $htmlBody = $renderer->applyBodyText($htmlBody, $bodyText, html: true) ?? $htmlBody; + } + + $subject = (string) $validated['subject']; + + $log = new \App\Models\EmailLog; + $log->fill([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'template_id' => $validated['template_id'] ?? null, + 'mail_profile_id' => $mailProfile->id, + 'to_email' => $to, + 'to_recipients' => [$to], + 'subject' => $subject, + 'body_html_hash' => $htmlBody !== '' ? hash('sha256', $htmlBody) : null, + 'body_text_preview' => null, + 'embed_mode' => 'base64', + 'status' => \App\Models\EmailLogStatus::Queued, + 'queued_at' => now(), + 'client_id' => $clientCase->client_id, + 'client_case_id' => $clientCase->id, + 'contract_id' => $contract?->id, + 'ip' => $request->ip(), + ]); + $log->save(); + + $log->body()->create([ + 'body_html' => $htmlBody, + 'body_text' => $bodyText, + 'inline_css' => false, + ]); + + dispatch(new \App\Jobs\SendEmailTemplateJob($log->id)); + + // Create activity if template has action/decision + if (! empty($validated['template_id'])) { + $template = \App\Models\EmailTemplate::find((int) $validated['template_id']); + if ($template && ($template->action_id || $template->decision_id)) { + $activity = $clientCase->activities()->create(array_filter([ + 'contract_id' => $contract?->id, + 'action_id' => $template->action_id, + 'decision_id' => $template->decision_id, + 'note' => 'Poslano: '.$to.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''), + 'user_id' => optional($request->user())->id, + ], fn ($v) => ! is_null($v))); + $activity->emailLogs()->attach($log->id); + } + } + + return back()->with('success', "E-pošta poslana na {$to}."); + } + + /** + * Build a template rendering context from the given client case and optional contract. + */ + private function buildCaseEmailContext(ClientCase $clientCase, ?\App\Models\Contract $contract = null): array + { + $clientCase->loadMissing('client.person'); + $ctx = [ + 'client_case' => $clientCase, + 'client' => $clientCase->client, + 'person' => optional($clientCase->client)->person, + 'mail_profile' => \App\Models\MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(), + ]; + if ($contract) { + $contract->loadMissing(['clientCase.client.person', 'account.type']); + $ctx['contract'] = $contract; + } + + return $ctx; + } + private function flattenMeta(array $meta, string $prefix = ''): array { $result = []; diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php index 657b0b0..0687191 100644 --- a/app/Http/Controllers/PersonController.php +++ b/app/Http/Controllers/PersonController.php @@ -136,6 +136,7 @@ public function createEmail(Person $person, Request $request) 'is_primary' => 'boolean', 'is_active' => 'boolean', 'valid' => 'boolean', + 'failed' => 'boolean', 'receive_auto_mails' => 'sometimes|boolean', 'verified_at' => 'nullable|date', 'preferences' => 'nullable|array', @@ -164,6 +165,7 @@ public function updateEmail(Person $person, int $email_id, Request $request) 'is_primary' => 'boolean', 'is_active' => 'boolean', 'valid' => 'boolean', + 'failed' => 'boolean', 'receive_auto_mails' => 'sometimes|boolean', 'verified_at' => 'nullable|date', 'preferences' => 'nullable|array', diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index a4e3f97..b3b785a 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -43,7 +43,7 @@ public function show(string $slug, Request $request) $inputs = $this->buildInputsArray($report); $filters = $this->validateFilters($inputs, $request); \Log::info('Report filters', ['filters' => $filters, 'request' => $request->all()]); - + $perPage = (int) ($request->integer('per_page') ?: 25); $query = $this->queryBuilder->build($report, $filters); $paginator = $query->paginate($perPage); @@ -279,16 +279,51 @@ public function clients(Request $request) $clients = \App\Models\Client::query() ->with('person:id,full_name') ->get() - ->map(fn($c) => [ + ->map(fn ($c) => [ 'id' => $c->uuid, - 'name' => $c->person->full_name ?? 'Unknown' + 'name' => $c->person->full_name ?? 'Unknown', ]) ->sortBy('name') ->values(); - + return response()->json($clients); } + /** + * Lightweight actions lookup for select:action filters. + */ + public function actions(Request $request) + { + $actions = \App\Models\Action::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn ($a) => ['id' => $a->id, 'name' => $a->name]) + ->values(); + + return response()->json($actions); + } + + /** + * Lightweight decisions lookup for select:decision filters. + * Optionally filtered by action_id (for dependent filter UI). + */ + public function decisions(Request $request) + { + $actionId = $request->integer('action_id', 0) ?: null; + + $q = \App\Models\Decision::query()->orderBy('name'); + + if ($actionId !== null) { + $q->whereHas('actions', fn ($qq) => $qq->where('actions.id', $actionId)); + } + + $decisions = $q->get(['id', 'name']) + ->map(fn ($d) => ['id' => $d->id, 'name' => $d->name]) + ->values(); + + return response()->json($decisions); + } + /** * Build validation rules based on inputs descriptor and validate. * @@ -307,6 +342,8 @@ protected function validateFilters(array $inputs, Request $request): array 'integer' => [$nullable, 'integer'], 'select:user' => [$nullable, 'integer', 'exists:users,id'], 'select:client' => [$nullable, 'string', 'exists:clients,uuid'], + 'select:action' => [$nullable, 'integer', 'exists:actions,id'], + 'select:decision' => [$nullable, 'integer', 'exists:decisions,id'], default => [$nullable, 'string'], }; } @@ -319,7 +356,7 @@ protected function validateFilters(array $inputs, Request $request): array */ protected function buildInputsArray(Report $report): array { - return $report->filters->map(fn($filter) => [ + return $report->filters->map(fn ($filter) => [ 'key' => $filter->key, 'type' => $filter->type, 'label' => $filter->label, @@ -336,7 +373,7 @@ protected function buildColumnsArray(Report $report): array { return $report->columns ->where('visible', true) - ->map(fn($col) => [ + ->map(fn ($col) => [ 'key' => $col->key, 'label' => $col->label, ]) diff --git a/app/Http/Requests/StoreEmailPackageFromContractsRequest.php b/app/Http/Requests/StoreEmailPackageFromContractsRequest.php new file mode 100644 index 0000000..fa57b1c --- /dev/null +++ b/app/Http/Requests/StoreEmailPackageFromContractsRequest.php @@ -0,0 +1,35 @@ +user()?->can('manage-settings') ?? false; + } + + public function rules(): array + { + return [ + 'type' => ['required', 'in:email'], + 'name' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'meta' => ['nullable', 'array'], + + // Common payload for all items + 'payload' => ['required', 'array'], + 'payload.mail_profile_id' => ['nullable', 'integer', 'exists:mail_profiles,id'], + 'payload.template_id' => ['nullable', 'integer', 'exists:email_templates,id'], + 'payload.subject' => ['nullable', 'string', 'max:255'], + 'payload.body_text' => ['nullable', 'string', 'max:10000'], + 'payload.variables' => ['nullable', 'array'], + + // Source contracts to derive items from + 'contract_ids' => ['required', 'array', 'min:1'], + 'contract_ids.*' => ['integer', 'exists:contracts,id'], + ]; + } +} diff --git a/app/Http/Requests/StoreEmailTemplateRequest.php b/app/Http/Requests/StoreEmailTemplateRequest.php index 081d86a..597e46f 100644 --- a/app/Http/Requests/StoreEmailTemplateRequest.php +++ b/app/Http/Requests/StoreEmailTemplateRequest.php @@ -23,6 +23,9 @@ public function rules(): array 'entity_types.*' => ['string', 'in:client,client_case,contract,person'], 'allow_attachments' => ['sometimes', 'boolean'], 'active' => ['boolean'], + 'client' => ['sometimes', 'boolean'], + 'action_id' => ['nullable', 'integer', 'exists:actions,id'], + 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], ]; } } diff --git a/app/Http/Requests/StoreMailProfileRequest.php b/app/Http/Requests/StoreMailProfileRequest.php index a56af50..1f008c0 100644 --- a/app/Http/Requests/StoreMailProfileRequest.php +++ b/app/Http/Requests/StoreMailProfileRequest.php @@ -26,6 +26,8 @@ public function rules(): array 'reply_to_name' => ['nullable', 'string', 'max:190'], 'priority' => ['nullable', 'integer', 'between:0,65535'], 'max_daily_quota' => ['nullable', 'integer', 'min:0'], + 'signature' => ['nullable', 'array'], + 'signature.*' => ['nullable', 'string', 'max:1000'], ]; } } diff --git a/app/Http/Requests/UpdateEmailTemplateRequest.php b/app/Http/Requests/UpdateEmailTemplateRequest.php index 6ede1d7..8c1b3bf 100644 --- a/app/Http/Requests/UpdateEmailTemplateRequest.php +++ b/app/Http/Requests/UpdateEmailTemplateRequest.php @@ -25,6 +25,9 @@ public function rules(): array 'entity_types.*' => ['string', 'in:client,client_case,contract,person'], 'allow_attachments' => ['sometimes', 'boolean'], 'active' => ['boolean'], + 'client' => ['sometimes', 'boolean'], + 'action_id' => ['nullable', 'integer', 'exists:actions,id'], + 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], ]; } } diff --git a/app/Http/Requests/UpdateMailProfileRequest.php b/app/Http/Requests/UpdateMailProfileRequest.php index d879154..1da0b01 100644 --- a/app/Http/Requests/UpdateMailProfileRequest.php +++ b/app/Http/Requests/UpdateMailProfileRequest.php @@ -27,6 +27,8 @@ public function rules(): array 'priority' => ['nullable', 'integer', 'between:0,65535'], 'max_daily_quota' => ['nullable', 'integer', 'min:0'], 'active' => ['nullable', 'boolean'], + 'signature' => ['nullable', 'array'], + 'signature.*' => ['nullable', 'string', 'max:1000'], ]; } } diff --git a/app/Jobs/PackageItemEmailJob.php b/app/Jobs/PackageItemEmailJob.php new file mode 100644 index 0000000..ef7b568 --- /dev/null +++ b/app/Jobs/PackageItemEmailJob.php @@ -0,0 +1,296 @@ +onQueue('email'); + } + + public function handle(EmailTemplateRenderer $renderer, EmailSender $sender): void + { + /** @var PackageItem|null $item */ + $item = PackageItem::query()->find($this->packageItemId); + if (! $item) { + return; + } + + /** @var Package $package */ + $package = $item->package; + if (! $package || $package->status === Package::STATUS_CANCELED) { + return; + } + + if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) { + return; + } + + if ($item->status === 'queued') { + $item->status = 'processing'; + $item->save(); + $package->increment('processing_count'); + } + + $payload = (array) $item->payload_json; + $target = (array) $item->target_json; + + $to = $target['email'] ?? null; + if (! is_string($to) || ! filter_var($to, FILTER_VALIDATE_EMAIL)) { + $item->status = 'failed'; + $item->last_error = 'Missing or invalid recipient email.'; + $item->save(); + $this->updatePackageCounters($item, $package); + + return; + } + + $templateId = $payload['template_id'] ?? null; + $mailProfileId = $payload['mail_profile_id'] ?? null; + $variables = (array) ($payload['variables'] ?? []); + $subjectOverride = isset($payload['subject']) ? trim((string) $payload['subject']) : null; + if ($subjectOverride === '') { + $subjectOverride = null; + } + $bodyText = isset($payload['body_text']) ? (string) $payload['body_text'] : ''; + + // Enrich variables with contract/account context when available + $contract = null; + if (! empty($target['contract_id'])) { + $contract = Contract::query()->with(['clientCase.person', 'account.type'])->find($target['contract_id']); + if ($contract) { + $variables['contract'] = [ + 'id' => $contract->id, + 'uuid' => $contract->uuid, + 'reference' => $contract->reference, + 'start_date' => (string) ($contract->start_date ?? ''), + 'end_date' => (string) ($contract->end_date ?? ''), + ]; + if (is_array($contract->meta) && ! empty($contract->meta)) { + $variables['contract']['meta'] = $this->flattenMeta($contract->meta); + } + if ($contract->account) { + $initialRaw = (string) $contract->account->initial_amount; + $balanceRaw = (string) $contract->account->balance_amount; + $variables['account'] = [ + 'id' => $contract->account->id, + 'reference' => $contract->account->reference, + 'initial_amount' => $this->formatAmountEu($initialRaw), + 'balance_amount' => $this->formatAmountEu($balanceRaw), + 'initial_amount_raw' => $initialRaw, + 'balance_amount_raw' => $balanceRaw, + 'type' => $contract->account->type?->name, + ]; + } + if ($contract->clientCase?->person) { + $person = $contract->clientCase->person; + $variables['person'] = [ + 'full_name' => $person->full_name, + 'first_name' => $person->first_name, + 'last_name' => $person->last_name, + ]; + } + } + } + + /** @var EmailTemplate|null $template */ + $template = $templateId ? EmailTemplate::with(['action', 'decision'])->find((int) $templateId) : null; + + /** @var MailProfile|null $mailProfile */ + $mailProfile = $mailProfileId + ? MailProfile::find((int) $mailProfileId) + : MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first(); + + try { + if (! $template && ! $subjectOverride) { + throw new \RuntimeException('No email template or subject provided.'); + } + + $rendered = $template + ? $renderer->render([ + 'subject' => $subjectOverride ?? (string) $template->subject_template, + 'html' => (string) $template->html_template, + 'text' => (string) $template->text_template, + ], array_filter([ + 'contract' => $contract, + 'person' => $contract?->clientCase?->person, + 'client' => $contract?->clientCase?->client, + 'client_case' => $contract?->clientCase, + 'mail_profile' => $mailProfile, + 'extra' => $variables, + 'body_text' => $bodyText !== '' ? $bodyText : null, + ])) + : [ + 'subject' => $subjectOverride ?? '', + 'html' => null, + 'text' => null, + ]; + + $log = new EmailLog; + $log->fill([ + 'uuid' => (string) Str::uuid(), + 'template_id' => $template?->id, + 'mail_profile_id' => $mailProfile?->id, + 'to_email' => $to, + 'to_recipients' => [$to], + 'subject' => $rendered['subject'], + 'body_html_hash' => isset($rendered['html']) ? hash('sha256', (string) $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(), + 'contract_id' => $contract?->id, + 'client_id' => $contract?->clientCase?->client?->id, + 'client_case_id' => $contract?->clientCase?->id, + 'extra_context' => ['package_id' => $item->package_id, 'package_item_id' => $item->id], + ]); + $log->save(); + + $log->body()->create([ + 'body_html' => (string) ($rendered['html'] ?? ''), + 'body_text' => (string) ($rendered['text'] ?? ''), + 'inline_css' => true, + ]); + + // Send directly (synchronous within job context) + $start = microtime(true); + $log->status = EmailLogStatus::Sending; + $log->started_at = now(); + $log->attempt = 1; + $log->save(); + + $sender->sendFromLog($log); + + $log->status = EmailLogStatus::Sent; + $log->sent_at = now(); + $log->duration_ms = (int) round((microtime(true) - $start) * 1000); + $log->save(); + + $item->status = 'sent'; + $item->result_json = ['email_log_id' => $log->id, 'subject' => $rendered['subject']]; + $item->last_error = null; + $item->save(); + + // Clear failed flag on successful delivery + Email::query()->where('value', $to)->where('failed', true)->update(['failed' => false]); + + // Create activity if the template has action/decision configured + if ($template && ($template->action_id || $template->decision_id) && $contract && $contract->client_case_id) { + $activity = \App\Models\Activity::create(array_filter([ + 'client_case_id' => $contract->client_case_id, + 'contract_id' => $contract->id, + 'action_id' => $template->action_id, + 'decision_id' => $template->decision_id, + 'note' => 'Poslano: '.$to.', Uspešno'.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''), + ])); + $activity->emailLogs()->attach($log->id); + } + } catch (\Throwable $e) { + $item->status = 'failed'; + $item->last_error = $e->getMessage(); + $item->save(); + + // Create activity for failed send if the template has action/decision configured + if ($template && ($template->action_id || $template->decision_id) && isset($contract) && $contract && $contract->client_case_id) { + $shortError = mb_strimwidth($e->getMessage(), 0, 120, '…'); + $activity = \App\Models\Activity::create(array_filter([ + 'client_case_id' => $contract->client_case_id, + 'contract_id' => $contract->id, + 'action_id' => $template->action_id, + 'decision_id' => $template->decision_id, + 'note' => 'Poslano: '.$to.', Napaka pri pošiljanju: '.$shortError.($bodyText !== '' ? ' | Vsebina: '.mb_strimwidth($bodyText, 0, 500, '…') : ''), + ])); + if (isset($log) && $log->exists) { + $activity->emailLogs()->attach($log->id); + } + } + + // Mark the email address as failed in the DB. + if (isset($to)) { + Email::query() + ->where('value', $to) + ->update(['failed' => true]); + } + + // Permanent SMTP rejection (550 user unknown, 551 not local, 553 invalid address) + // means the address definitively does not exist — also mark it invalid. + if ($e instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface + && preg_match('/\b55[013]\b/', $e->getMessage()) + && isset($to)) { + Email::query() + ->where('value', $to) + ->update(['valid' => false]); + } + } + + $this->updatePackageCounters($item, $package); + } + + private function updatePackageCounters(PackageItem $item, Package $package): void + { + if ($item->status === 'sent') { + $package->increment('sent_count'); + } else { + $package->increment('failed_count'); + } + + $package->decrement('processing_count'); + + $package->refresh(); + $done = $package->sent_count + $package->failed_count; + if ($done >= $package->total_items) { + $package->status = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED; + $package->finished_at = now(); + $package->save(); + } + } + + private function flattenMeta(array $meta, string $prefix = ''): array + { + $result = []; + foreach ($meta as $key => $value) { + $newKey = $prefix === '' ? $key : "{$prefix}.{$key}"; + if (is_array($value)) { + if (isset($value['value'])) { + $result[$newKey] = $value['value']; + } else { + $nested = $this->flattenMeta($value, $newKey); + $result = array_merge($result, $nested); + } + } else { + $result[$newKey] = $value; + } + } + + return $result; + } + + private function formatAmountEu(string $raw): string + { + $numeric = preg_replace('/[^0-9.]/', '', $raw); + $float = (float) $numeric; + + return number_format($float, 2, ',', '.'); + } +} diff --git a/app/Jobs/SendEmailTemplateJob.php b/app/Jobs/SendEmailTemplateJob.php index 8ef8a12..fb32512 100644 --- a/app/Jobs/SendEmailTemplateJob.php +++ b/app/Jobs/SendEmailTemplateJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Models\Email; use App\Models\EmailLog; use App\Models\EmailLogStatus; use App\Services\EmailSender; @@ -53,6 +54,10 @@ public function handle(): void $log->duration_ms = (int) round((microtime(true) - $start) * 1000); $log->save(); + if ($log->to_email) { + Email::query()->where('value', $log->to_email)->update(['failed' => true]); + } + throw $e; } } diff --git a/app/Models/Activity.php b/app/Models/Activity.php index 5585085..f6d1175 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; class Activity extends Model @@ -159,4 +160,9 @@ public function callLaters(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(\App\Models\CallLater::class); } + + public function emailLogs(): BelongsToMany + { + return $this->belongsToMany(EmailLog::class, 'activity_email_logs'); + } } diff --git a/app/Models/Email.php b/app/Models/Email.php index a3fe24d..44969c0 100644 --- a/app/Models/Email.php +++ b/app/Models/Email.php @@ -18,6 +18,7 @@ class Email extends Model 'is_primary', 'is_active', 'valid', + 'failed', 'receive_auto_mails', 'verified_at', 'preferences', @@ -28,6 +29,7 @@ class Email extends Model 'is_primary' => 'boolean', 'is_active' => 'boolean', 'valid' => 'boolean', + 'failed' => 'boolean', 'receive_auto_mails' => 'boolean', 'verified_at' => 'datetime', 'preferences' => 'array', diff --git a/app/Models/EmailLog.php b/app/Models/EmailLog.php index b3ba936..a193ed7 100644 --- a/app/Models/EmailLog.php +++ b/app/Models/EmailLog.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasOne; enum EmailLogStatus: string @@ -83,4 +84,9 @@ public function body(): HasOne { return $this->hasOne(EmailLogBody::class, 'email_log_id'); } + + public function activities(): BelongsToMany + { + return $this->belongsToMany(Activity::class, 'activity_email_logs'); + } } diff --git a/app/Models/EmailTemplate.php b/app/Models/EmailTemplate.php index 89d5c7d..835bad6 100644 --- a/app/Models/EmailTemplate.php +++ b/app/Models/EmailTemplate.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphMany; class EmailTemplate extends Model @@ -19,10 +20,14 @@ class EmailTemplate extends Model 'entity_types', 'allow_attachments', 'active', + 'action_id', + 'decision_id', + 'client', ]; protected $casts = [ 'active' => 'boolean', + 'client' => 'boolean', 'entity_types' => 'array', 'allow_attachments' => 'boolean', ]; @@ -31,4 +36,14 @@ public function documents(): MorphMany { return $this->morphMany(Document::class, 'documentable'); } + + public function action(): BelongsTo + { + return $this->belongsTo(Action::class); + } + + public function decision(): BelongsTo + { + return $this->belongsTo(Decision::class); + } } diff --git a/app/Models/MailProfile.php b/app/Models/MailProfile.php index 1c4a6cc..8cd45f2 100644 --- a/app/Models/MailProfile.php +++ b/app/Models/MailProfile.php @@ -11,12 +11,13 @@ class MailProfile extends Model protected $fillable = [ 'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name', - 'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today', + 'reply_to_address', 'reply_to_name', 'priority', 'signature', 'max_daily_quota', 'emails_sent_today', 'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at', ]; protected $casts = [ 'active' => 'boolean', + 'signature' => 'array', 'last_success_at' => 'datetime', 'last_error_at' => 'datetime', 'test_checked_at' => 'datetime', diff --git a/app/Models/Package.php b/app/Models/Package.php index d5be77a..94b0e59 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -34,6 +34,8 @@ public function items() public const TYPE_SMS = 'sms'; + public const TYPE_EMAIL = 'email'; + public const STATUS_DRAFT = 'draft'; public const STATUS_QUEUED = 'queued'; diff --git a/app/Services/AutoMailDispatcher.php b/app/Services/AutoMailDispatcher.php index 5630cb0..fef6d9b 100644 --- a/app/Services/AutoMailDispatcher.php +++ b/app/Services/AutoMailDispatcher.php @@ -90,7 +90,24 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt // Ensure related names are available without extra queries $activity->loadMissing(['action', 'decision']); + // Ensure account is available on contract (needed for contract.account.* tokens) + if ($contract && ! $contract->relationLoaded('account')) { + $contract->load('account'); + } + + // Resolve the sending profile once — used both for signature tokens and as the actual sender. + // Prefer the profile explicitly requested via options, fall back to highest-priority active one. + $mailProfile = isset($options['mail_profile_id']) + ? MailProfile::query()->find($options['mail_profile_id']) + : null; + $mailProfile ??= MailProfile::query() + ->where('active', true) + ->orderBy('priority') + ->orderBy('id') + ->first(); + // Render content + $bodyText = isset($options['body_text']) ? (string) $options['body_text'] : ''; $rendered = $this->renderer->render([ 'subject' => (string) $template->subject_template, 'html' => (string) $template->html_template, @@ -102,6 +119,8 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt 'person' => $person, 'activity' => $activity, 'extra' => [], + 'mail_profile' => $mailProfile, + 'body_text' => $bodyText, ]); // Create the log and body @@ -109,7 +128,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt $log->fill([ 'uuid' => (string) \Str::uuid(), 'template_id' => $template->id, - 'mail_profile_id' => optional(MailProfile::query()->where('active', true)->orderBy('priority')->orderBy('id')->first())->id, + 'mail_profile_id' => $mailProfile?->id, 'user_id' => auth()->id(), 'to_email' => (string) ($recipients[0] ?? ''), 'to_recipients' => $recipients, @@ -149,7 +168,7 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true, array $opt $log->body()->create([ 'body_html' => (string) ($rendered['html'] ?? ''), - 'body_text' => (string) ($rendered['text'] ?? ''), + 'body_text' => $bodyText !== '' ? $bodyText : (string) ($rendered['text'] ?? ''), 'inline_css' => true, ]); diff --git a/app/Services/ClientCaseDataService.php b/app/Services/ClientCaseDataService.php index c367592..8760004 100644 --- a/app/Services/ClientCaseDataService.php +++ b/app/Services/ClientCaseDataService.php @@ -40,7 +40,6 @@ public function getContracts(ClientCase $clientCase, ?int $segmentId = null): Co $query->forSegment($segmentId); } - return $query->get(); } @@ -55,7 +54,7 @@ public function getActivities( int $perPage = 20 ): LengthAwarePaginator { $query = $clientCase->activities() - ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) + ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name', 'emailLogs:id']) ->orderByDesc('created_at'); if (! empty($segmentId)) { diff --git a/app/Services/Contact/EmailSelector.php b/app/Services/Contact/EmailSelector.php new file mode 100644 index 0000000..3c86546 --- /dev/null +++ b/app/Services/Contact/EmailSelector.php @@ -0,0 +1,54 @@ + ?Email, 'reason' => ?string] + */ + public function selectForPerson(Person $person): array + { + $emails = Email::query() + ->where('person_id', $person->id) + ->where('is_active', true) + ->orderBy('is_primary', 'desc') + ->orderBy('id') + ->get(); + + if ($emails->isEmpty()) { + return ['email' => null, 'reason' => 'no_active_emails']; + } + + // 1) verified primary + $email = $emails->first(fn (Email $e) => $e->is_primary && $e->verified_at !== null); + if ($email) { + return ['email' => $email, 'reason' => null]; + } + + // 2) primary (any verification) + $email = $emails->first(fn (Email $e) => $e->is_primary); + if ($email) { + return ['email' => $email, 'reason' => null]; + } + + // 3) valid (any) + $email = $emails->first(fn (Email $e) => $e->valid); + if ($email) { + return ['email' => $email, 'reason' => null]; + } + + // 4) first active + return ['email' => $emails->first(), 'reason' => null]; + } +} diff --git a/app/Services/EmailTemplateRenderer.php b/app/Services/EmailTemplateRenderer.php index b0330cf..24b22fb 100644 --- a/app/Services/EmailTemplateRenderer.php +++ b/app/Services/EmailTemplateRenderer.php @@ -30,17 +30,41 @@ public function render(array $template, array $ctx): array 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]; + } + return (string) data_get($map, $key, ''); }, $input); }; + $bodyText = isset($ctx['body_text']) ? (string) $ctx['body_text'] : ''; + return [ 'subject' => $replacer($template['subject']) ?? '', - 'html' => $replacer($template['html'] ?? null) ?? null, - 'text' => $replacer($template['text'] ?? null) ?? null, + '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
. + * 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 */ @@ -145,8 +169,11 @@ protected function buildMap(array $ctx): array 'id' => data_get($co, 'id'), 'uuid' => data_get($co, 'uuid'), 'reference' => data_get($co, 'reference'), - // Format amounts in EU style for emails - 'amount' => $formatMoneyEu(data_get($co, 'amount')), + // 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_array($meta)) { @@ -172,6 +199,12 @@ protected function buildMap(array $ctx): array 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; } diff --git a/database/migrations/2026_05_10_000001_add_signature_to_mail_profiles_table.php b/database/migrations/2026_05_10_000001_add_signature_to_mail_profiles_table.php new file mode 100644 index 0000000..4a4f34f --- /dev/null +++ b/database/migrations/2026_05_10_000001_add_signature_to_mail_profiles_table.php @@ -0,0 +1,22 @@ +jsonb('signature')->nullable()->after('priority'); + }); + } + + public function down(): void + { + Schema::table('mail_profiles', function (Blueprint $table) { + $table->dropColumn('signature'); + }); + } +}; diff --git a/database/migrations/2026_05_11_000001_add_action_decision_to_email_templates.php b/database/migrations/2026_05_11_000001_add_action_decision_to_email_templates.php new file mode 100644 index 0000000..2459421 --- /dev/null +++ b/database/migrations/2026_05_11_000001_add_action_decision_to_email_templates.php @@ -0,0 +1,25 @@ +foreignId('action_id')->nullable()->after('active')->constrained('actions')->nullOnDelete(); + $table->foreignId('decision_id')->nullable()->after('action_id')->constrained('decisions')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('email_templates', function (Blueprint $table): void { + $table->dropForeign(['action_id']); + $table->dropForeign(['decision_id']); + $table->dropColumn(['action_id', 'decision_id']); + }); + } +}; diff --git a/database/migrations/2026_05_11_000002_create_activity_email_logs_table.php b/database/migrations/2026_05_11_000002_create_activity_email_logs_table.php new file mode 100644 index 0000000..c60da58 --- /dev/null +++ b/database/migrations/2026_05_11_000002_create_activity_email_logs_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('activity_id')->constrained('activities')->cascadeOnDelete(); + $table->foreignId('email_log_id')->constrained('email_logs')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['activity_id', 'email_log_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_email_logs'); + } +}; diff --git a/database/migrations/2026_05_11_184026_add_failed_to_emails_table.php b/database/migrations/2026_05_11_184026_add_failed_to_emails_table.php new file mode 100644 index 0000000..45f4d4e --- /dev/null +++ b/database/migrations/2026_05_11_184026_add_failed_to_emails_table.php @@ -0,0 +1,28 @@ +boolean('failed')->default(false)->after('valid'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('emails', function (Blueprint $table) { + $table->dropColumn('failed'); + }); + } +}; diff --git a/database/migrations/2026_05_11_193109_add_client_to_email_templates_table.php b/database/migrations/2026_05_11_193109_add_client_to_email_templates_table.php new file mode 100644 index 0000000..4620fa8 --- /dev/null +++ b/database/migrations/2026_05_11_193109_add_client_to_email_templates_table.php @@ -0,0 +1,28 @@ +boolean('client')->default(false)->after('active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('email_templates', function (Blueprint $table): void { + $table->dropColumn('client'); + }); + } +}; diff --git a/database/seeders/ReportsSeeder.php b/database/seeders/ReportsSeeder.php index e9c11c3..cb67063 100644 --- a/database/seeders/ReportsSeeder.php +++ b/database/seeders/ReportsSeeder.php @@ -21,6 +21,7 @@ public function run(): void $this->seedSegmentActivityCountsReport(); $this->seedActionsDecisionsCountReport(); $this->seedActivitiesPerPeriodReport(); + $this->seedActivitiesDetailReport(); } protected function seedActiveContractsReport(): void @@ -783,4 +784,265 @@ protected function seedActivitiesPerPeriodReport(): void 'order' => 0, ]); } + + protected function seedActivitiesDetailReport(): void + { + $report = Report::create([ + 'slug' => 'activities-detail', + 'name' => 'Aktivnosti – pregled', + 'description' => 'Podroben pregled aktivnosti z možnostjo filtriranja po stranki, datumu, akciji in odločitvi.', + 'category' => 'activities', + 'enabled' => true, + 'order' => 7, + ]); + + // Entities (joins) + $report->entities()->create([ + 'model_class' => 'App\\Models\\Activity', + 'join_type' => 'base', + 'order' => 0, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Action', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.action_id', + 'join_operator' => '=', + 'join_second' => 'actions.id', + 'order' => 1, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Decision', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.decision_id', + 'join_operator' => '=', + 'join_second' => 'decisions.id', + 'order' => 2, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Contract', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.contract_id', + 'join_operator' => '=', + 'join_second' => 'contracts.id', + 'order' => 3, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\ClientCase', + 'join_type' => 'leftJoin', + 'join_first' => 'activities.client_case_id', + 'join_operator' => '=', + 'join_second' => 'client_cases.id', + 'order' => 4, + ]); + + $report->entities()->create([ + 'model_class' => 'App\\Models\\Client', + 'join_type' => 'leftJoin', + 'join_first' => 'client_cases.client_id', + 'join_operator' => '=', + 'join_second' => 'clients.id', + 'order' => 5, + ]); + + $report->entities()->createMany([ + [ + 'model_class' => 'App\\Models\\Person\\Person', + 'alias' => 'client_people', + 'join_type' => 'leftJoin', + 'join_first' => 'clients.person_id', + 'join_operator' => '=', + 'join_second' => 'client_people.id', + 'order' => 6, + ], + [ + 'model_class' => 'App\\Models\\Person\\Person', + 'alias' => 'subject_people', + 'join_type' => 'leftJoin', + 'join_first' => 'client_cases.person_id', + 'join_operator' => '=', + 'join_second' => 'subject_people.id', + 'order' => 7, + ], + ]); + + // Columns + $report->columns()->createMany([ + [ + 'key' => 'contract_reference', + 'label' => 'Pogodba', + 'type' => 'string', + 'expression' => 'contracts.reference', + 'sortable' => true, + 'visible' => true, + 'order' => 0, + ], + [ + 'key' => 'naziv', + 'label' => 'Naziv', + 'type' => 'string', + 'expression' => 'subject_people.full_name', + 'sortable' => true, + 'visible' => true, + 'order' => 1, + ], + [ + 'key' => 'stranka', + 'label' => 'Stranka', + 'type' => 'string', + 'expression' => 'client_people.full_name', + 'sortable' => true, + 'visible' => true, + 'order' => 2, + ], + [ + 'key' => 'aktivnost', + 'label' => 'Aktivnost', + 'type' => 'string', + 'expression' => "CONCAT(COALESCE(actions.name, ''), ' / ', COALESCE(decisions.name, ''))", + 'sortable' => false, + 'visible' => true, + 'order' => 3, + ], + [ + 'key' => 'datum', + 'label' => 'Datum', + 'type' => 'date', + 'expression' => 'DATE(activities.created_at)', + 'sortable' => true, + 'visible' => true, + 'order' => 4, + ], + [ + 'key' => 'opomba', + 'label' => 'Opomba', + 'type' => 'string', + 'expression' => 'activities.note', + 'sortable' => false, + 'visible' => true, + 'order' => 5, + ], + [ + 'key' => 'zapadlost', + 'label' => 'Zapadlost', + 'type' => 'date', + 'expression' => 'activities.due_date', + 'sortable' => true, + 'visible' => true, + 'order' => 6, + ], + [ + 'key' => 'znesek', + 'label' => 'Znesek', + 'type' => 'currency', + 'expression' => 'activities.amount', + 'sortable' => true, + 'visible' => true, + 'order' => 7, + ], + ]); + + // Filters + $report->filters()->createMany([ + [ + 'key' => 'client_uuid', + 'label' => 'Stranka', + 'type' => 'select:client', + 'nullable' => true, + 'order' => 0, + ], + [ + 'key' => 'from', + 'label' => 'Datum od', + 'type' => 'date', + 'nullable' => true, + 'order' => 1, + ], + [ + 'key' => 'to', + 'label' => 'Datum do', + 'type' => 'date', + 'nullable' => true, + 'order' => 2, + ], + [ + 'key' => 'action_id', + 'label' => 'Akcija', + 'type' => 'select:action', + 'nullable' => true, + 'order' => 3, + ], + [ + 'key' => 'decision_id', + 'label' => 'Odločitev', + 'type' => 'select:decision', + 'nullable' => true, + 'order' => 4, + ], + ]); + + // Conditions (all filter-based, skipped when null) + $report->conditions()->createMany([ + [ + 'column' => 'activities.created_at', + 'operator' => '>=', + 'value_type' => 'filter', + 'filter_key' => 'from', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.created_at', + 'operator' => '<=', + 'value_type' => 'filter', + 'filter_key' => 'to', + 'logical_operator' => 'AND', + 'group_id' => 1, + 'order' => 1, + 'enabled' => true, + ], + [ + 'column' => 'clients.uuid', + 'operator' => '=', + 'value_type' => 'filter', + 'filter_key' => 'client_uuid', + 'logical_operator' => 'AND', + 'group_id' => 2, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.action_id', + 'operator' => '=', + 'value_type' => 'filter', + 'filter_key' => 'action_id', + 'logical_operator' => 'AND', + 'group_id' => 3, + 'order' => 0, + 'enabled' => true, + ], + [ + 'column' => 'activities.decision_id', + 'operator' => '=', + 'value_type' => 'filter', + 'filter_key' => 'decision_id', + 'logical_operator' => 'AND', + 'group_id' => 4, + 'order' => 0, + 'enabled' => true, + ], + ]); + + // Order + $report->orders()->create([ + 'column' => 'activities.created_at', + 'direction' => 'DESC', + 'order' => 0, + ]); + } } diff --git a/resources/js/Components/PersonInfo/EmailCreateForm.vue b/resources/js/Components/PersonInfo/EmailCreateForm.vue index d3266bf..cba3a5f 100644 --- a/resources/js/Components/PersonInfo/EmailCreateForm.vue +++ b/resources/js/Components/PersonInfo/EmailCreateForm.vue @@ -43,6 +43,8 @@ const formSchema = toTypedSchema( value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."), label: z.string().optional(), receive_auto_mails: z.boolean().optional(), + valid: z.boolean().default(true), + failed: z.boolean().default(false), decision_ids: z.array(z.string()).optional().default([]), }) ); @@ -54,6 +56,8 @@ const form = useForm({ value: "", label: "", receive_auto_mails: false, + valid: true, + failed: false, decision_ids: [], }, }); @@ -78,6 +82,8 @@ const resetForm = () => { value: "", label: "", receive_auto_mails: false, + valid: true, + failed: false, decision_ids: [], }, }); @@ -182,6 +188,8 @@ watch( value: email.value ?? email.email ?? email.address ?? "", label: email.label ?? "", receive_auto_mails: !!email.receive_auto_mails, + valid: email.valid !== undefined ? !!email.valid : true, + failed: !!email.failed, decision_ids: existingDecisionIds, }); } else { @@ -272,6 +280,28 @@ const onConfirm = () => { + + + + + +
+ Veljavna +
+
+
+ + + + + + +
+ Neuspešna dostava +
+
+
+