Package and individual mail sender, new report, and other changes
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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 ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEmailPackageFromContractsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Contract;
|
||||
use App\Models\Email;
|
||||
use App\Models\EmailLog;
|
||||
use App\Models\EmailLogStatus;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\MailProfile;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageItem;
|
||||
use App\Services\EmailSender;
|
||||
use App\Services\EmailTemplateRenderer;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PackageItemEmailJob implements ShouldQueue
|
||||
{
|
||||
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public int $packageItemId)
|
||||
{
|
||||
$this->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, ',', '.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Contact;
|
||||
|
||||
use App\Models\Email;
|
||||
use App\Models\Person\Person;
|
||||
|
||||
class EmailSelector
|
||||
{
|
||||
/**
|
||||
* Select the best email for a person following priority rules.
|
||||
* Priority:
|
||||
* 1) verified primary email that is active
|
||||
* 2) primary email that is active
|
||||
* 3) any active and valid email
|
||||
* 4) first active email
|
||||
*
|
||||
* Returns an array shape: ['email' => ?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];
|
||||
}
|
||||
}
|
||||
@@ -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 <br>.
|
||||
* In plain-text context the raw value is used.
|
||||
*/
|
||||
public function applyBodyText(?string $content, string $bodyText, bool $html = true): ?string
|
||||
{
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
$replacement = $html
|
||||
? nl2br(htmlspecialchars($bodyText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'))
|
||||
: $bodyText;
|
||||
|
||||
return preg_replace('/{{\s*body_text\s*}}/', $replacement, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{client?:Client, person?:Person, client_case?:ClientCase, contract?:Contract, activity?:Activity, extra?:array} $ctx
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user