Package system sms

This commit is contained in:
Simon Pocrnjič
2025-10-26 12:57:09 +01:00
parent 266af6595e
commit 369af34ad4
29 changed files with 2639 additions and 330 deletions
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum PersonPhoneType: string
{
case Mobile = 'mobile';
case Landline = 'landline';
case Voip = 'voip';
}
@@ -0,0 +1,470 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePackageFromContractsRequest;
use App\Http\Requests\StorePackageRequest;
use App\Jobs\PackageItemSmsJob;
use App\Models\Contract;
use App\Models\Package;
use App\Models\PackageItem;
use App\Models\SmsTemplate;
use App\Services\Contact\PhoneSelector;
use App\Services\Sms\SmsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class PackageController extends Controller
{
public function index(Request $request): Response
{
$packages = Package::query()
->latest('id')
->paginate(20);
// Minimal lookups for create form (active only)
$profiles = \App\Models\SmsProfile::query()
->where('active', true)
->orderBy('name')
->get(['id', 'name']);
$senders = \App\Models\SmsSender::query()
->where('active', true)
->orderBy('sname')
->get(['id', 'profile_id', 'sname', 'phone_number']);
$templates = \App\Models\SmsTemplate::query()
->orderBy('name')
->get(['id', 'name']);
$segments = \App\Models\Segment::query()
->where('active', true)
->orderBy('name')
->get(['id', 'name']);
// Provide a lightweight list of recent clients with person names for filtering
$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('Admin/Packages/Index', [
'packages' => $packages,
'profiles' => $profiles,
'senders' => $senders,
'templates' => $templates,
'segments' => $segments,
'clients' => $clients,
]);
}
public function show(Package $package, SmsService $sms): Response
{
$items = $package->items()->latest('id')->paginate(25);
// Preload contracts/accounts for current page items to compute per-item previews
$contractIds = collect($items->items())
->map(fn ($it) => (array) ($it->target_json ?? []))
->map(fn ($t) => $t['contract_id'] ?? null)
->filter()
->unique()
->values();
$contracts = $contractIds->isNotEmpty()
? Contract::query()->with('account.type')->whereIn('id', $contractIds)->get()->keyBy('id')
: collect();
// Attach rendered_preview to each item
$collection = collect($items->items());
$collection = $collection->transform(function ($it) use ($sms, $contracts) {
$payload = (array) ($it->payload_json ?? []);
$tgt = (array) ($it->target_json ?? []);
$vars = (array) ($payload['variables'] ?? []);
if (! empty($tgt['contract_id']) && $contracts->has($tgt['contract_id'])) {
$c = $contracts->get($tgt['contract_id']);
$vars['contract'] = [
'id' => $c->id,
'uuid' => $c->uuid,
'reference' => $c->reference,
'start_date' => (string) ($c->start_date ?? ''),
'end_date' => (string) ($c->end_date ?? ''),
];
if ($c->account) {
$initialRaw = (string) $c->account->initial_amount;
$balanceRaw = (string) $c->account->balance_amount;
$vars['account'] = [
'id' => $c->account->id,
'reference' => $c->account->reference,
// Use EU formatted values for SMS previews
'initial_amount' => $sms->formatAmountEu($initialRaw),
'balance_amount' => $sms->formatAmountEu($balanceRaw),
// Also expose raw values
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $c->account->type?->name,
];
}
}
// Prefer recorded message from result_json if available (sent items)
$result = (array) ($it->result_json ?? []);
$rendered = $result['message'] ?? null;
if (! $rendered) {
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
if ($body !== '') {
$rendered = $body;
} elseif (! empty($payload['template_id'])) {
$tpl = \App\Models\SmsTemplate::find((int) $payload['template_id']);
if ($tpl) {
$rendered = $sms->renderContent($tpl->content, $vars);
}
}
}
$it->rendered_preview = $rendered;
return $it;
});
// Replace paginator collection
if (method_exists($items, 'setCollection')) {
$items->setCollection($collection);
}
// Build a preview of message content from the first item (shared payload across package)
$preview = null;
$firstItem = $package->items()->oldest('id')->first();
if ($firstItem) {
$payload = (array) ($firstItem->payload_json ?? []);
$body = isset($payload['body']) ? trim((string) $payload['body']) : '';
// Enrich variables with contract/account for preview if available
$vars = (array) ($payload['variables'] ?? []);
$tgt = (array) ($firstItem->target_json ?? []);
if (! empty($tgt['contract_id'])) {
$c = Contract::query()->with('account.type')->find($tgt['contract_id']);
if ($c) {
$vars['contract'] = [
'id' => $c->id,
'uuid' => $c->uuid,
'reference' => $c->reference,
'start_date' => (string) ($c->start_date ?? ''),
'end_date' => (string) ($c->end_date ?? ''),
];
if ($c->account) {
$initialRaw = (string) $c->account->initial_amount;
$balanceRaw = (string) $c->account->balance_amount;
$vars['account'] = [
'id' => $c->account->id,
'reference' => $c->account->reference,
'initial_amount' => $sms->formatAmountEu($initialRaw),
'balance_amount' => $sms->formatAmountEu($balanceRaw),
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $c->account->type?->name,
];
}
}
}
if ($body !== '') {
$preview = [
'source' => 'body',
'content' => $body,
];
} elseif (! empty($payload['template_id'])) {
/** @var SmsTemplate|null $tpl */
$tpl = SmsTemplate::find((int) $payload['template_id']);
if ($tpl) {
$content = $sms->renderContent($tpl->content, $vars);
$preview = [
'source' => 'template',
'template' => [
'id' => $tpl->id,
'name' => $tpl->name,
],
'content' => $content,
];
}
}
}
return Inertia::render('Admin/Packages/Show', [
'package' => $package,
'items' => $items,
'preview' => $preview,
]);
}
public function store(StorePackageRequest $request): RedirectResponse
{
$data = $request->validated();
$package = Package::query()->create([
'uuid' => (string) Str::uuid(),
'type' => $data['type'],
'status' => Package::STATUS_DRAFT,
'name' => $data['name'] ?? null,
'description' => $data['description'] ?? null,
'meta' => $data['meta'] ?? [],
'created_by' => optional($request->user())->id,
]);
$items = collect($data['items'])
->map(function (array $row) {
return new PackageItem([
'status' => 'queued',
'target_json' => [
'number' => (string) $row['number'],
'phone_id' => $row['phone_id'] ?? null,
],
'payload_json' => $row['payload'] ?? [],
]);
});
$package->items()->saveMany($items);
$package->total_items = $items->count();
$package->save();
return back()->with('success', 'Package created');
}
public function dispatch(Package $package): RedirectResponse
{
if (! in_array($package->status, [Package::STATUS_DRAFT, Package::STATUS_FAILED], true)) {
return back()->with('error', 'Package not in a dispatchable state.');
}
$jobs = $package->items()->whereIn('status', ['queued', 'failed'])->get()->map(function (PackageItem $item) {
return new PackageItemSmsJob($item->id);
})->all();
if (empty($jobs)) {
return back()->with('error', 'No items to dispatch.');
}
$package->status = Package::STATUS_QUEUED;
$package->save();
Bus::batch($jobs)
->name('pkg:'.$package->id.' ('.$package->type.')')
->then(function () use ($package) {
// If finished counters not set by items (e.g., empty), finalize
$package->refresh();
if (($package->sent_count + $package->failed_count) >= $package->total_items) {
$finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
$package->status = $finalStatus;
$package->finished_at = now();
$package->save();
} else {
$package->status = Package::STATUS_RUNNING;
$package->save();
}
})
->onQueue('sms')
->dispatch();
return back()->with('success', 'Package dispatched');
}
public function cancel(Package $package): RedirectResponse
{
$package->status = Package::STATUS_CANCELED;
$package->save();
return back()->with('success', 'Package canceled');
}
/**
* List contracts for a given segment and include selected phone per person.
*/
public function contracts(Request $request, PhoneSelector $selector): \Illuminate\Http\JsonResponse
{
$request->validate([
'segment_id' => ['required', 'integer', 'exists:segments,id'],
'q' => ['nullable', 'string'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'only_mobile' => ['nullable', 'boolean'],
'only_validated' => ['nullable', 'boolean'],
]);
$segmentId = (int) $request->input('segment_id');
$perPage = (int) ($request->input('per_page') ?? 25);
$query = Contract::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);
})
->with([
'clientCase.person.phones',
'clientCase.client.person',
])
->select('contracts.*')
->latest('contracts.id');
if ($q = trim((string) $request->input('q'))) {
$query->where(function ($w) use ($q) {
$w->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);
}
// Optional phone filters
if ($request->boolean('only_mobile') || $request->boolean('only_validated')) {
$query->whereHas('clientCase.person.phones', function ($q) use ($request) {
if ($request->boolean('only_mobile')) {
$q->where('person_phones.phone_type', 'mobile');
}
if ($request->boolean('only_validated')) {
$q->where('person_phones.validated', true);
}
});
}
$contracts = $query->paginate($perPage);
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
$person = $contract->clientCase?->person;
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
$phone = $selected['phone'];
$clientPerson = $contract->clientCase?->client?->person;
return [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'case' => [
'id' => $contract->clientCase?->id,
'uuid' => $contract->clientCase?->uuid,
],
// Primer: the case person
'person' => [
'id' => $person?->id,
'uuid' => $person?->uuid,
'full_name' => $person?->full_name,
],
// Stranka: the client person
'client' => $clientPerson ? [
'id' => $contract->clientCase?->client?->id,
'uuid' => $contract->clientCase?->client?->uuid,
'name' => $clientPerson->full_name,
] : null,
'selected_phone' => $phone ? [
'id' => $phone->id,
'number' => $phone->nu,
'validated' => $phone->validated,
'type' => $phone->phone_type?->value,
] : null,
'no_phone_reason' => $phone ? null : ($selected['reason'] ?? 'unknown'),
];
});
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $contracts->currentPage(),
'last_page' => $contracts->lastPage(),
'per_page' => $contracts->perPage(),
'total' => $contracts->total(),
],
]);
}
/**
* Create an SMS package from a list of contracts by selecting recipient phones.
*/
public function storeFromContracts(StorePackageFromContractsRequest $request, PhoneSelector $selector): RedirectResponse
{
$data = $request->validated();
// Load contracts with people, phones and account (for template placeholders)
$contracts = Contract::query()
->with(['clientCase.person.phones', 'account.type'])
->whereIn('id', $data['contract_ids'])
->get();
$items = [];
$seen = collect(); // de-dup by phone_id or number
$skipped = 0;
foreach ($contracts as $contract) {
$person = $contract->clientCase?->person;
if (! $person) {
$skipped++;
continue;
}
$selected = $selector->selectForPerson($person);
/** @var ?\App\Models\Person\PersonPhone $phone */
$phone = $selected['phone'];
if (! $phone) {
$skipped++;
continue;
}
$key = $phone->id ? 'id:'.$phone->id : 'num:'.$phone->nu;
if ($seen->contains($key)) {
// skip duplicates across multiple contracts/persons
$skipped++;
continue;
}
$seen->push($key);
$items[] = [
'number' => (string) $phone->nu,
'phone_id' => $phone->id,
'payload' => $data['payload'] ?? [],
// Keep context for variable rendering during send
'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' => $data['type'],
'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' => [
'number' => $row['number'],
'phone_id' => $row['phone_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', 'Package created from contracts');
}
}
@@ -7,6 +7,7 @@
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use App\Services\Sms\SmsService;
use Exception;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
@@ -1693,6 +1694,7 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
'template_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_templates,id'],
'profile_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_profiles,id'],
'sender_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_senders,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
]);
// Ensure the phone belongs to the person of this case
@@ -1801,4 +1803,93 @@ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $ph
return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.');
}
}
/**
* Return contracts for the given client case (for SMS dialog dropdown).
*/
public function listContracts(ClientCase $clientCase)
{
$contracts = $clientCase->contracts()
->with('account.type')
->select('id', 'uuid', 'reference', 'active', 'start_date', 'end_date')
->latest('id')
->get()
->map(function ($c) {
/** @var SmsService $sms */
$sms = app(SmsService::class);
$acc = $c->account;
$initialRaw = $acc?->initial_amount !== null ? (string) $acc->initial_amount : null;
$balanceRaw = $acc?->balance_amount !== null ? (string) $acc->balance_amount : null;
return [
'uuid' => $c->uuid,
'reference' => $c->reference,
'active' => (bool) $c->active,
'start_date' => (string) ($c->start_date ?? ''),
'end_date' => (string) ($c->end_date ?? ''),
'account' => $acc ? [
'reference' => $acc->reference,
'type' => $acc->type?->name,
'initial_amount' => $initialRaw !== null ? $sms->formatAmountEu($initialRaw) : null,
'balance_amount' => $balanceRaw !== null ? $sms->formatAmountEu($balanceRaw) : null,
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
] : null,
];
});
return response()->json(['data' => $contracts]);
}
/**
* Render an SMS template preview with optional contract/account placeholders filled.
*/
public function previewSms(ClientCase $clientCase, Request $request, SmsService $sms)
{
$validated = $request->validate([
'template_id' => ['required', 'integer', 'exists:sms_templates,id'],
'contract_uuid' => ['sometimes', 'nullable', 'uuid'],
]);
/** @var \App\Models\SmsTemplate $template */
$template = \App\Models\SmsTemplate::findOrFail((int) $validated['template_id']);
$vars = [];
$contractUuid = $validated['contract_uuid'] ?? null;
if ($contractUuid) {
// Ensure the contract belongs to this client case
$contract = $clientCase->contracts()->where('uuid', $contractUuid)
->with('account.type')
->first();
if ($contract) {
$vars['contract'] = [
'id' => $contract->id,
'uuid' => $contract->uuid,
'reference' => $contract->reference,
'start_date' => (string) ($contract->start_date ?? ''),
'end_date' => (string) ($contract->end_date ?? ''),
];
if ($contract->account) {
$initialRaw = (string) $contract->account->initial_amount;
$balanceRaw = (string) $contract->account->balance_amount;
$vars['account'] = [
'id' => $contract->account->id,
'reference' => $contract->account->reference,
'initial_amount' => $sms->formatAmountEu($initialRaw),
'balance_amount' => $sms->formatAmountEu($balanceRaw),
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $contract->account->type?->name,
];
}
}
}
$content = $sms->renderContent($template->content, $vars);
return response()->json([
'content' => $content,
'variables' => $vars,
]);
}
}
+7 -19
View File
@@ -112,6 +112,8 @@ public function createPhone(Person $person, Request $request)
'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id',
'description' => 'nullable|string|max:125',
'validated' => 'sometimes|boolean',
'phone_type' => 'nullable|in:mobile,landline,voip',
]);
// Dedup: avoid duplicate phone per person by (nu, country_code)
@@ -120,13 +122,7 @@ public function createPhone(Person $person, Request $request)
'country_code' => $attributes['country_code'] ?? null,
], $attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Phone added successfully');
}
return response()->json([
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id),
]);
return back()->with('success', 'Phone added successfully');
}
public function updatePhone(Person $person, int $phone_id, Request $request)
@@ -136,19 +132,15 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
'country_code' => 'nullable|integer',
'type_id' => 'required|integer|exists:phone_types,id',
'description' => 'nullable|string|max:125',
'validated' => 'sometimes|boolean',
'phone_type' => 'nullable|in:mobile,landline,voip',
]);
$phone = $person->phones()->with(['type'])->findOrFail($phone_id);
$phone->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Phone updated successfully');
}
return response()->json([
'phone' => $phone,
]);
return back()->with('success', 'Phone updated successfully');
}
public function deletePhone(Person $person, int $phone_id, Request $request)
@@ -156,11 +148,7 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
$phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete
if ($request->header('X-Inertia')) {
return back()->with('success', 'Phone deleted');
}
return response()->json(['status' => 'ok']);
return back()->with('success', 'Phone deleted');
}
public function createEmail(Person $person, Request $request)
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePackageFromContractsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'type' => ['required', 'in:sms'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'meta' => ['nullable', 'array'],
// Common payload for all items
'payload' => ['required', 'array'],
'payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
'payload.delivery_report' => ['nullable', 'boolean'],
'payload.variables' => ['nullable', 'array'],
'payload.body' => ['nullable', 'string'],
// Source contracts to derive items from
'contract_ids' => ['required', 'array', 'min:1'],
'contract_ids.*' => ['integer', 'exists:contracts,id'],
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePackageRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'type' => ['required', 'in:sms'],
'name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'meta' => ['nullable', 'array'],
// items
'items' => ['required', 'array', 'min:1'],
'items.*.number' => ['required', 'string'],
'items.*.phone_id' => ['nullable', 'integer'],
'items.*.payload' => ['nullable', 'array'],
'items.*.payload.profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'items.*.payload.sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'items.*.payload.template_id' => ['nullable', 'integer', 'exists:sms_templates,id'],
'items.*.payload.delivery_report' => ['nullable', 'boolean'],
'items.*.payload.variables' => ['nullable', 'array'],
'items.*.payload.body' => ['nullable', 'string'],
];
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace App\Jobs;
use App\Models\Contract;
use App\Models\Package;
use App\Models\PackageItem;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use App\Models\SmsTemplate;
use App\Services\Sms\SmsService;
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\Facades\Redis;
class PackageItemSmsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $packageItemId)
{
$this->onQueue('sms');
}
public function handle(SmsService $sms): 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; // canceled or missing
}
// Skip if already finalized to avoid double counting on retries
if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) {
return;
}
// Mark processing on first entry
if ($item->status === 'queued') {
$item->status = 'processing';
$item->save();
$package->increment('processing_count');
}
$payload = (array) $item->payload_json;
$target = (array) $item->target_json;
$profileId = $payload['profile_id'] ?? null;
$senderId = $payload['sender_id'] ?? null;
$templateId = $payload['template_id'] ?? null;
$deliveryReport = (bool) ($payload['delivery_report'] ?? false);
$variables = (array) ($payload['variables'] ?? []);
// Enrich variables with contract/account context when available (contracts-based packages)
if (! empty($target['contract_id'])) {
$contract = Contract::query()->with('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 ($contract->account) {
// Preserve raw values and provide EU-formatted versions for SMS rendering
$initialRaw = (string) $contract->account->initial_amount;
$balanceRaw = (string) $contract->account->balance_amount;
$variables['account'] = [
'id' => $contract->account->id,
'reference' => $contract->account->reference,
// Override placeholders with EU formatted values for SMS
'initial_amount' => $sms->formatAmountEu($initialRaw),
'balance_amount' => $sms->formatAmountEu($balanceRaw),
// Expose raw values too in case templates need them explicitly
'initial_amount_raw' => $initialRaw,
'balance_amount_raw' => $balanceRaw,
'type' => $contract->account->type?->name,
];
}
}
}
$bodyOverride = isset($payload['body']) ? trim((string) $payload['body']) : null;
if ($bodyOverride === '') {
$bodyOverride = null;
}
/** @var SmsProfile|null $profile */
$profile = $profileId ? SmsProfile::find($profileId) : null;
/** @var SmsSender|null $sender */
$sender = $senderId ? SmsSender::find($senderId) : null;
/** @var SmsTemplate|null $template */
$template = $templateId ? SmsTemplate::find($templateId) : null;
$to = $target['number'] ?? null;
if (! is_string($to) || $to === '') {
$item->status = 'failed';
$item->last_error = 'Missing recipient number.';
$item->save();
return;
}
// Compute throttle key
$scope = config('services.sms.throttle.scope', 'global');
$provider = config('services.sms.throttle.provider_key', 'smsapi_si');
$allow = (int) config('services.sms.throttle.allow', 30);
$every = (int) config('services.sms.throttle.every', 60);
$jitter = (int) config('services.sms.throttle.jitter_seconds', 2);
$key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}";
// Throttle
$sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride) {
// Idempotency key (optional external use)
if (empty($item->idempotency_key)) {
$hash = sha1(implode('|', [
'sms', (string) ($profile?->id ?? ''), (string) ($sender?->id ?? ''), (string) ($template?->id ?? ''), $to, (string) ($bodyOverride ?? ''), json_encode($variables),
]));
$item->idempotency_key = "pkgitem:{$item->id}:{$hash}";
$item->save();
}
// Decide whether to use template or raw content
$useTemplate = false;
if ($template) {
if ($bodyOverride) {
// If custom body is provided but template does not allow it, force template
$useTemplate = ! (bool) ($template->allow_custom_body ?? false);
} else {
// No custom body provided -> use template
$useTemplate = true;
}
}
if ($useTemplate) {
$log = $sms->sendFromTemplate(
template: $template,
to: $to,
variables: $variables,
profile: $profile,
sender: $sender,
countryCode: null,
deliveryReport: $deliveryReport,
clientReference: "pkg:{$item->package_id}:item:{$item->id}",
);
} else {
// Either explicit body override or no template
$effectiveBody = (string) ($bodyOverride ?? '');
if ($effectiveBody === '') {
// Avoid provider error for empty body
throw new \RuntimeException('Empty SMS body and no template provided.');
}
if (! $profile && $template) {
$profile = $template->defaultProfile;
}
if (! $profile) {
throw new \RuntimeException('Missing SMS profile for raw send.');
}
$log = $sms->sendRaw(
profile: $profile,
to: $to,
content: $effectiveBody,
sender: $sender,
countryCode: null,
deliveryReport: $deliveryReport,
clientReference: "pkg:{$item->package_id}:item:{$item->id}",
);
}
$newStatus = $log->status === 'sent' ? 'sent' : 'failed';
$item->status = $newStatus;
$item->provider_message_id = $log->provider_message_id;
$item->cost = $log->cost;
$item->currency = $log->currency;
// Persist useful result info including final rendered message for auditing
$result = $log->meta ?? [];
$result['message'] = $log->message ?? (($useTemplate && isset($template)) ? $sms->renderContent($template->content, $variables) : ($bodyOverride ?? null));
$result['template_id'] = $template?->id;
$result['render_source'] = $useTemplate ? 'template' : 'body';
$item->result_json = $result;
$item->last_error = $log->status === 'sent' ? null : ($log->meta['error_message'] ?? 'Failed');
$item->save();
// Update package counters atomically
if ($newStatus === 'sent') {
$package->increment('sent_count');
} else {
$package->increment('failed_count');
}
// If all items processed, finalize package
$package->refresh();
if (($package->sent_count + $package->failed_count) >= $package->total_items) {
$finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED;
$package->status = $finalStatus;
$package->finished_at = now();
$package->save();
}
};
try {
Redis::throttle($key)->allow($allow)->every($every)->then($sendClosure, function () use ($jitter) {
return $this->release(max(1, rand(1, $jitter)));
});
} catch (\Throwable $e) {
// Fallback to direct send when Redis unavailable (e.g., test environment)
$sendClosure();
}
}
}
+37 -10
View File
@@ -12,6 +12,7 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
class SendSmsJob implements ShouldQueue
{
@@ -49,16 +50,42 @@ public function handle(SmsService $sms): void
/** @var SmsSender|null $sender */
$sender = $this->senderId ? SmsSender::find($this->senderId) : null;
// Send and get log (handles queued->sent/failed transitions internally)
$log = $sms->sendRaw(
profile: $profile,
to: $this->to,
content: $this->content,
sender: $sender,
countryCode: $this->countryCode,
deliveryReport: $this->deliveryReport,
clientReference: $this->clientReference,
);
// Apply Redis throttle from config to avoid provider rate limits
$scope = config('services.sms.throttle.scope', 'global');
$provider = config('services.sms.throttle.provider_key', 'smsapi_si');
$allow = (int) config('services.sms.throttle.allow', 30);
$every = (int) config('services.sms.throttle.every', 60);
$jitter = (int) config('services.sms.throttle.jitter_seconds', 2);
$key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}";
$log = null;
try {
Redis::throttle($key)->allow($allow)->every($every)->then(function () use (&$log, $sms, $profile, $sender) {
// Send and get log (handles queued->sent/failed transitions internally)
$log = $sms->sendRaw(
profile: $profile,
to: $this->to,
content: $this->content,
sender: $sender,
countryCode: $this->countryCode,
deliveryReport: $this->deliveryReport,
clientReference: $this->clientReference,
);
}, function () use ($jitter) {
return $this->release(max(1, rand(1, $jitter)));
});
} catch (\Throwable $e) {
// Fallback if Redis is unavailable in test or local env
$log = $sms->sendRaw(
profile: $profile,
to: $this->to,
content: $this->content,
sender: $sender,
countryCode: $this->countryCode,
deliveryReport: $this->deliveryReport,
clientReference: $this->clientReference,
);
}
// If invoked from the case UI with a selected template, create an Activity
if ($this->templateId && $this->clientCaseId && $log) {
try {
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Package extends Model
{
use HasFactory;
protected $fillable = [
'uuid', 'type', 'status', 'name', 'description', 'meta',
'total_items', 'processing_count', 'sent_count', 'failed_count',
'created_by', 'finished_at',
];
protected function casts(): array
{
return [
'meta' => 'array',
'finished_at' => 'datetime',
'total_items' => 'integer',
'processing_count' => 'integer',
'sent_count' => 'integer',
'failed_count' => 'integer',
];
}
public function items()
{
return $this->hasMany(PackageItem::class);
}
public const TYPE_SMS = 'sms';
public const STATUS_DRAFT = 'draft';
public const STATUS_QUEUED = 'queued';
public const STATUS_RUNNING = 'running';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELED = 'canceled';
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PackageItem extends Model
{
use HasFactory;
protected $fillable = [
'package_id', 'status', 'target_json', 'payload_json', 'attempts', 'last_error', 'result_json', 'provider_message_id', 'cost', 'currency', 'idempotency_key',
];
protected function casts(): array
{
return [
'target_json' => 'array',
'payload_json' => 'array',
'result_json' => 'array',
'attempts' => 'integer',
'cost' => 'decimal:4',
];
}
public function package()
{
return $this->belongsTo(Package::class);
}
}
+18 -6
View File
@@ -2,7 +2,7 @@
namespace App\Models\Person;
use Blade;
use App\Enums\PersonPhoneType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,6 +13,7 @@ class PersonPhone extends Model
{
/** @use HasFactory<\Database\Factories\Person/PersonPhoneFactory> */
use HasFactory;
use Searchable;
use SoftDeletes;
@@ -22,25 +23,28 @@ class PersonPhone extends Model
'type_id',
'description',
'person_id',
'user_id'
'user_id',
'validated',
'phone_type',
];
protected $hidden = [
'user_id',
'person_id',
'deleted'
'deleted',
];
public function toSearchableArray(): array
{
return [
'nu' => $this->nu
'nu' => $this->nu,
];
}
protected static function booted(){
protected static function booted()
{
static::creating(function (PersonPhone $personPhone) {
if(!isset($personPhone->user_id)){
if (! isset($personPhone->user_id)) {
$personPhone->user_id = auth()->id();
}
});
@@ -55,4 +59,12 @@ public function type(): BelongsTo
{
return $this->belongsTo(\App\Models\Person\PhoneType::class, 'type_id');
}
protected function casts(): array
{
return [
'validated' => 'boolean',
'phone_type' => PersonPhoneType::class,
];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Services\Contact;
use App\Enums\PersonPhoneType;
use App\Models\Person\Person;
use App\Models\Person\PersonPhone;
class PhoneSelector
{
/**
* Select the best phone for a person following priority rules.
* Priority:
* 1) validated mobile
* 2) validated (any type)
* 3) mobile (any validation)
* 4) first active phone
*
* Returns an array shape: ['phone' => ?PersonPhone, 'reason' => ?string]
*/
public function selectForPerson(Person $person): array
{
// Load active phones only (Person relation already filters active=1)
$phones = $person->phones;
if ($phones->isEmpty()) {
return ['phone' => null, 'reason' => 'no_active_phones'];
}
// 1) validated mobile
$phone = $phones->first(function (PersonPhone $p) {
return ($p->validated === true) && ($p->phone_type === PersonPhoneType::Mobile);
});
if ($phone) {
return ['phone' => $phone, 'reason' => null];
}
// 2) validated (any type)
$phone = $phones->first(fn (PersonPhone $p) => $p->validated === true);
if ($phone) {
return ['phone' => $phone, 'reason' => null];
}
// 3) mobile (any validation)
$phone = $phones->first(fn (PersonPhone $p) => $p->phone_type === PersonPhoneType::Mobile);
if ($phone) {
return ['phone' => $phone, 'reason' => null];
}
// 4) first active
return ['phone' => $phones->first(), 'reason' => null];
}
}
+41 -5
View File
@@ -79,16 +79,52 @@ public function sendFromTemplate(SmsTemplate $template, string $to, array $varia
return $log;
}
protected function renderContent(string $content, array $vars): string
public function renderContent(string $content, array $vars): string
{
// Simple token replacement: {token}
return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars) {
$key = $m[1];
// Support {token} and {nested.keys} using dot-notation lookup
$resolver = function (array $arr, string $path) {
if (array_key_exists($path, $arr)) {
return $arr[$path];
}
$segments = explode('.', $path);
$cur = $arr;
foreach ($segments as $seg) {
if (is_array($cur) && array_key_exists($seg, $cur)) {
$cur = $cur[$seg];
} else {
return null;
}
}
return array_key_exists($key, $vars) ? (string) $vars[$key] : $m[0];
return $cur;
};
return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars, $resolver) {
$key = $m[1];
$val = $resolver($vars, $key);
return $val !== null ? (string) $val : $m[0];
}, $content);
}
/**
* Format a number to EU style: thousands separated by '.', decimals by ','.
*/
public function formatAmountEu(mixed $value, int $decimals = 2): string
{
if ($value === null || $value === '') {
return number_format(0, $decimals, ',', '.');
}
$str = (string) $value;
// Normalize possible EU-style input like "1.234,56" to standard for float casting
if (str_contains($str, ',')) {
$str = str_replace(['.', ','], ['', '.'], $str);
}
$num = (float) $str;
return number_format($num, $decimals, ',', '.');
}
/**
* Get current credit balance from provider.
*/