SMS service

This commit is contained in:
Simon Pocrnjič
2025-10-24 21:39:10 +02:00
parent 3a2eed7dda
commit 930ac83604
52 changed files with 3830 additions and 36 deletions
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SmsLog;
use App\Models\SmsProfile;
use App\Models\SmsTemplate;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SmsLogController extends Controller
{
public function index(Request $request)
{
$query = SmsLog::query()->with(['profile:id,name', 'template:id,name,slug']);
// Filters
$status = $request->string('status')->toString();
$profileId = $request->integer('profile_id');
$templateId = $request->integer('template_id');
$search = trim((string) $request->input('search', ''));
$from = $request->date('from');
$to = $request->date('to');
if ($status !== '') {
$query->where('status', $status);
}
if ($profileId) {
$query->where('profile_id', $profileId);
}
if ($templateId) {
$query->where('template_id', $templateId);
}
if ($search !== '') {
$query->where(function ($q) use ($search): void {
$q->where('to_number', 'ILIKE', "%$search%")
->orWhere('sender', 'ILIKE', "%$search%")
->orWhere('provider_message_id', 'ILIKE', "%$search%")
->orWhere('message', 'ILIKE', "%$search%");
});
}
if ($from) {
$query->whereDate('created_at', '>=', $from);
}
if ($to) {
$query->whereDate('created_at', '<=', $to);
}
$logs = $query->orderByDesc('id')->paginate(20)->withQueryString();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json($logs);
}
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$templates = SmsTemplate::query()->orderBy('name')->get(['id', 'name', 'slug']);
return Inertia::render('Admin/SmsLogs/Index', [
'logs' => $logs,
'profiles' => $profiles,
'templates' => $templates,
'filters' => [
'status' => $status ?: null,
'profile_id' => $profileId ?: null,
'template_id' => $templateId ?: null,
'search' => $search ?: null,
'from' => $from ? $from->format('Y-m-d') : null,
'to' => $to ? $to->format('Y-m-d') : null,
],
]);
}
public function show(SmsLog $smsLog)
{
$smsLog->load(['profile:id,name', 'template:id,name,slug']);
return Inertia::render('Admin/SmsLogs/Show', [
'log' => $smsLog,
]);
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSmsProfileRequest;
use App\Http\Requests\TestSendSmsRequest;
use App\Jobs\SendSmsJob;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use App\Services\Sms\SmsService;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
class SmsProfileController extends Controller
{
public function index(Request $request)
{
$profiles = SmsProfile::query()->with(['senders:id,profile_id,sname,active'])->orderBy('name')->get([
'id', 'uuid', 'name', 'active', 'api_username', 'default_sender_id', 'settings', 'created_at', 'updated_at',
]);
// Inertia requests must receive an Inertia response
if ($request->headers->has('X-Inertia')) {
return Inertia::render('Admin/SmsProfiles/Index', [
'initialProfiles' => $profiles,
]);
}
// JSON/AJAX API
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['profiles' => $profiles]);
}
// Default to Inertia page for normal browser navigation
return Inertia::render('Admin/SmsProfiles/Index', [
'initialProfiles' => $profiles,
]);
}
public function store(StoreSmsProfileRequest $request)
{
$data = $request->validated();
$profile = new SmsProfile;
$profile->uuid = (string) Str::uuid();
$profile->name = $data['name'];
$profile->active = (bool) ($data['active'] ?? true);
$profile->api_username = $data['api_username'];
// write-only attribute setter will encrypt and store to encrypted_api_password
$profile->api_password = $data['api_password'];
$profile->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['profile' => $profile], 201);
}
return back()->with('success', 'SMS profil je ustvarjen.');
}
public function testSend(SmsProfile $profile, TestSendSmsRequest $request, SmsService $sms)
{
$data = $request->validated();
$sender = null;
if (! empty($data['sender_id'])) {
$sender = SmsSender::query()->where('id', $data['sender_id'])->where('profile_id', $profile->id)->firstOrFail();
}
// Queue the SMS send (admin test send - no activity created)
SendSmsJob::dispatch(
profileId: $profile->id,
to: (string) $data['to'],
content: (string) $data['message'],
senderId: $sender?->id,
countryCode: $data['country_code'] ?? null,
deliveryReport: (bool) ($data['delivery_report'] ?? false),
clientReference: null,
);
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['queued' => true]);
}
return back()->with('success', 'Testni SMS je bil dodan v čakalno vrsto.');
}
public function balance(SmsProfile $smsProfile, SmsService $sms)
{
try {
$balance = (string) $sms->getCreditBalance($smsProfile);
return response()->json(['balance' => $balance]);
} catch (\Throwable $e) {
// Return a graceful payload so UI doesn't break; also include message for optional UI/tooling
return response()->json([
'balance' => '—',
'error' => 'Unable to fetch balance: '.$e->getMessage(),
]);
}
}
public function price(SmsProfile $smsProfile, SmsService $sms)
{
$quotes = $sms->getPriceQuotes($smsProfile);
return response()->json(['quotes' => $quotes]);
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSmsSenderRequest;
use App\Http\Requests\UpdateSmsSenderRequest;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use Illuminate\Http\Request;
use Inertia\Inertia;
class SmsSenderController extends Controller
{
public function index(Request $request)
{
$senders = SmsSender::query()
->with(['profile:id,name'])
->orderBy('id', 'desc')
->get(['id', 'profile_id', 'sname', 'phone_number', 'description', 'active', 'created_at']);
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
return Inertia::render('Admin/SmsSenders/Index', [
'initialSenders' => $senders,
'profiles' => $profiles,
]);
}
public function store(StoreSmsSenderRequest $request)
{
$data = $request->validated();
$sender = SmsSender::create([
'profile_id' => $data['profile_id'],
'sname' => $data['sname'],
'phone_number' => $data['phone_number'] ?? null,
'description' => $data['description'] ?? null,
'active' => (bool) ($data['active'] ?? true),
]);
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['sender' => $sender], 201);
}
return back()->with('success', 'Pošiljatelj je ustvarjen.');
}
public function update(UpdateSmsSenderRequest $request, SmsSender $smsSender)
{
$data = $request->validated();
$smsSender->forceFill([
'profile_id' => $data['profile_id'],
'sname' => $data['sname'],
'phone_number' => $data['phone_number'] ?? null,
'description' => $data['description'] ?? null,
'active' => (bool) ($data['active'] ?? $smsSender->active),
])->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['sender' => $smsSender]);
}
return back()->with('success', 'Pošiljatelj je posodobljen.');
}
public function toggle(Request $request, SmsSender $smsSender)
{
$smsSender->active = ! $smsSender->active;
$smsSender->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['sender' => $smsSender]);
}
return back()->with('success', 'Stanje pošiljatelja je posodobljeno.');
}
public function destroy(Request $request, SmsSender $smsSender)
{
$smsSender->delete();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['deleted' => true]);
}
return back()->with('success', 'Pošiljatelj je izbrisan.');
}
}
@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSmsTemplateRequest;
use App\Http\Requests\TestSendSmsTemplateRequest;
use App\Http\Requests\UpdateSmsTemplateRequest;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use App\Models\SmsTemplate;
use App\Services\Sms\SmsService;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
class SmsTemplateController extends Controller
{
public function index(Request $request)
{
$templates = SmsTemplate::query()
->with(['defaultProfile:id,name', 'defaultSender:id,sname'])
->orderBy('name')
->get(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'created_at']);
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json([
'templates' => $templates,
'profiles' => $profiles,
'senders' => $senders,
]);
}
return Inertia::render('Admin/SmsTemplates/Index', [
'initialTemplates' => $templates,
'profiles' => $profiles,
'senders' => $senders,
]);
}
public function create()
{
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/SmsTemplates/Edit', [
'template' => null,
'profiles' => $profiles,
'senders' => $senders,
'actions' => $actions,
]);
}
public function edit(SmsTemplate $smsTemplate)
{
$profiles = SmsProfile::query()->orderBy('name')->get(['id', 'name']);
$senders = SmsSender::query()->orderBy('sname')->get(['id', 'profile_id', 'sname', 'active']);
$actions = \App\Models\Action::query()
->with(['decisions:id,name'])
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/SmsTemplates/Edit', [
'template' => $smsTemplate->only(['id', 'uuid', 'name', 'slug', 'content', 'variables_json', 'is_active', 'default_profile_id', 'default_sender_id', 'allow_custom_body', 'action_id', 'decision_id']),
'profiles' => $profiles,
'senders' => $senders,
'actions' => $actions,
]);
}
public function store(StoreSmsTemplateRequest $request)
{
$data = $request->validated();
$tpl = new SmsTemplate;
$tpl->uuid = (string) Str::uuid();
$tpl->name = $data['name'];
$tpl->slug = $data['slug'];
$tpl->content = $data['content'] ?? '';
$tpl->variables_json = $data['variables_json'] ?? null;
$tpl->is_active = (bool) ($data['is_active'] ?? true);
$tpl->default_profile_id = $data['default_profile_id'] ?? null;
$tpl->default_sender_id = $data['default_sender_id'] ?? null;
$tpl->allow_custom_body = (bool) ($data['allow_custom_body'] ?? false);
$tpl->action_id = $data['action_id'] ?? null;
$tpl->decision_id = $data['decision_id'] ?? null;
$tpl->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['template' => $tpl], 201);
}
return redirect()->route('admin.sms-templates.edit', $tpl);
}
public function update(UpdateSmsTemplateRequest $request, SmsTemplate $smsTemplate)
{
$data = $request->validated();
$smsTemplate->forceFill([
'name' => $data['name'],
'slug' => $data['slug'],
'content' => $data['content'] ?? '',
'variables_json' => $data['variables_json'] ?? null,
'is_active' => (bool) ($data['is_active'] ?? $smsTemplate->is_active),
'default_profile_id' => $data['default_profile_id'] ?? null,
'default_sender_id' => $data['default_sender_id'] ?? null,
'allow_custom_body' => (bool) ($data['allow_custom_body'] ?? $smsTemplate->allow_custom_body),
'action_id' => $data['action_id'] ?? null,
'decision_id' => $data['decision_id'] ?? null,
])->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['template' => $smsTemplate]);
}
return back()->with('success', 'SMS predloga je posodobljena.');
}
public function toggle(Request $request, SmsTemplate $smsTemplate)
{
$smsTemplate->is_active = ! $smsTemplate->is_active;
$smsTemplate->save();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['template' => $smsTemplate]);
}
return back()->with('success', 'Stanje predloge je posodobljeno.');
}
public function destroy(Request $request, SmsTemplate $smsTemplate)
{
$smsTemplate->delete();
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['deleted' => true]);
}
return back()->with('success', 'Predloga je izbrisana.');
}
public function sendTest(TestSendSmsTemplateRequest $request, SmsTemplate $smsTemplate, SmsService $sms)
{
$data = $request->validated();
$profile = null;
if (! empty($data['profile_id'])) {
$profile = SmsProfile::query()->findOrFail($data['profile_id']);
}
$sender = null;
if (! empty($data['sender_id'])) {
$sender = SmsSender::query()->findOrFail($data['sender_id']);
}
$variables = (array) ($data['variables'] ?? []);
if (! empty($data['custom_content']) && $smsTemplate->allow_custom_body) {
// Use custom content when allowed
if (! $profile) {
$profile = $smsTemplate->defaultProfile;
}
if (! $profile) {
throw new \InvalidArgumentException('SMS profile is required to send a message.');
}
$log = $sms->sendRaw(
profile: $profile,
to: $data['to'],
content: (string) $data['custom_content'],
sender: $sender,
countryCode: $data['country_code'] ?? null,
deliveryReport: (bool) ($data['delivery_report'] ?? false),
);
$log->template_id = $smsTemplate->id;
$log->save();
} else {
$log = $sms->sendFromTemplate(
template: $smsTemplate,
to: $data['to'],
variables: $variables,
profile: $profile,
sender: $sender,
countryCode: $data['country_code'] ?? null,
deliveryReport: (bool) ($data['delivery_report'] ?? false),
);
}
if ($request->wantsJson() || $request->expectsJson()) {
return response()->json(['log' => $log]);
}
return back()->with('success', 'Testni SMS je bil poslan.');
}
}
@@ -1343,6 +1343,22 @@ function ($p) {
'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']),
'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']),
'current_segment' => $currentSegment,
// SMS helpers for per-case sending UI
'sms_profiles' => \App\Models\SmsProfile::query()
->select(['id', 'name', 'default_sender_id'])
->where('active', true)
->orderBy('name')
->get(),
'sms_senders' => \App\Models\SmsSender::query()
->select(['id', 'profile_id'])
->addSelect(\DB::raw('sname as name'))
->addSelect(\DB::raw('phone_number as phone'))
->orderBy('sname')
->get(),
'sms_templates' => \App\Models\SmsTemplate::query()
->select(['id', 'name', 'content', 'allow_custom_body'])
->orderBy('name')
->get(),
]);
}
@@ -1665,4 +1681,124 @@ public function emergencyCreatePerson(ClientCase $clientCase, Request $request)
'person_uuid' => $newPerson?->uuid,
]);
}
/**
* Send an SMS to a specific phone that belongs to the client case person.
*/
public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $phone_id)
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:1000'],
'delivery_report' => ['sometimes', 'boolean'],
'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'],
]);
// Ensure the phone belongs to the person of this case
/** @var \App\Models\Person\PersonPhone|null $phone */
$phone = \App\Models\Person\PersonPhone::query()
->where('id', $phone_id)
->where('person_id', $clientCase->person_id)
->first();
if (! $phone) {
abort(404);
}
// Resolve explicit profile/sender if provided; otherwise fallback to first active profile and its default sender
/** @var \App\Models\SmsProfile|null $profile */
$profile = null;
/** @var \App\Models\SmsSender|null $sender */
$sender = null;
if (! empty($validated['sender_id']) && empty($validated['profile_id'])) {
// Infer profile from sender if not explicitly provided
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if ($sender) {
$profile = \App\Models\SmsProfile::query()->find($sender->profile_id);
}
}
if (! empty($validated['profile_id'])) {
$profile = \App\Models\SmsProfile::query()->where('id', $validated['profile_id'])->first();
if (! $profile) {
return back()->with('error', 'Izbran SMS profil ne obstaja.');
}
if (property_exists($profile, 'active') && ! $profile->active) {
return back()->with('error', 'Izbran SMS profil ni aktiven.');
}
}
if (! empty($validated['sender_id'])) {
$sender = \App\Models\SmsSender::query()->find($validated['sender_id']);
if (! $sender) {
return back()->with('error', 'Izbran pošiljatelj ne obstaja.');
}
if ($profile && (int) $sender->profile_id !== (int) $profile->id) {
return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.');
}
}
if (! $profile) {
$profile = \App\Models\SmsProfile::query()
->where('active', true)
->orderByRaw('CASE WHEN default_sender_id IS NULL THEN 1 ELSE 0 END')
->orderBy('id')
->first();
}
if (! $profile) {
return back()->with('warning', 'Ni aktivnega SMS profila.');
}
if (! $sender && ! empty($profile->default_sender_id)) {
$sender = \App\Models\SmsSender::query()->find($profile->default_sender_id);
}
try {
/** @var \App\Services\Sms\SmsService $sms */
$sms = app(\App\Services\Sms\SmsService::class);
// Check available credits before enqueueing (fail-closed)
try {
$raw = (string) $sms->getCreditBalance($profile);
$num = null;
if ($raw !== '') {
$normalized = str_replace(',', '.', trim($raw));
if (preg_match('/-?\d+(?:\.\d+)?/', $normalized, $m)) {
$num = (float) ($m[0] ?? null);
}
}
if (! is_null($num) && $num <= 0.0) {
return back()->with('error', 'No credits left.');
}
} catch (\Throwable $e) {
\Log::warning('SMS credit balance check failed', [
'error' => $e->getMessage(),
'profile_id' => $profile->id,
]);
return back()->with('error', 'Unable to verify SMS credits.');
}
// Queue the SMS send; activity will be created in the job on success if a template is provided
\App\Jobs\SendSmsJob::dispatch(
profileId: $profile->id,
to: (string) $phone->nu,
content: (string) $validated['message'],
senderId: $sender?->id,
countryCode: $phone->country_code ?: null,
deliveryReport: (bool) ($validated['delivery_report'] ?? false),
clientReference: null,
templateId: $validated['template_id'] ?? null,
clientCaseId: $clientCase->id,
userId: optional($request->user())->id,
);
return back()->with('success', 'SMS je bil dodan v čakalno vrsto.');
} catch (\Throwable $e) {
\Log::warning('SMS enqueue failed', [
'error' => $e->getMessage(),
'case_id' => $clientCase->id,
'phone_id' => $phone_id,
]);
return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.');
}
}
}
+50 -1
View File
@@ -8,6 +8,9 @@
use App\Models\Document; // assuming model name Import
use App\Models\FieldJob; // if this model exists
use App\Models\Import;
use App\Models\SmsLog;
use App\Models\SmsProfile;
use App\Services\Sms\SmsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
@@ -15,7 +18,7 @@
class DashboardController extends Controller
{
public function __invoke(): Response
public function __invoke(SmsService $sms): Response
{
$today = now()->startOfDay();
$yesterday = now()->subDay()->startOfDay();
@@ -223,6 +226,52 @@ public function __invoke(): Response
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
'importsInProgress' => fn () => $importsInProgress,
'activeTemplates' => fn () => $activeTemplates,
'smsStats' => function () use ($sms, $today) {
// Aggregate counts per profile for today
$counts = SmsLog::query()
->whereDate('created_at', $today)
->selectRaw('profile_id, status, COUNT(*) as c')
->groupBy('profile_id', 'status')
->get()
->groupBy('profile_id')
->map(function ($rows) {
$map = [
'queued' => 0,
'sent' => 0,
'delivered' => 0,
'failed' => 0,
];
foreach ($rows as $r) {
$map[$r->status] = (int) $r->c;
}
$map['total'] = array_sum($map);
return $map;
});
// Important: include credential fields so provider calls have proper credentials
$profiles = SmsProfile::query()
->orderBy('name')
->get(['id', 'name', 'active', 'api_username', 'encrypted_api_password']);
return $profiles->map(function (SmsProfile $p) use ($sms, $counts) {
// Provider balance may fail; guard and present a placeholder.
try {
$balance = $sms->getCreditBalance($p);
} catch (\Throwable $e) {
$balance = '—';
}
$c = $counts->get($p->id) ?? ['queued' => 0, 'sent' => 0, 'delivered' => 0, 'failed' => 0, 'total' => 0];
return [
'id' => $p->id,
'name' => $p->name,
'active' => (bool) $p->active,
'balance' => $balance,
'today' => $c,
];
})->values();
},
]);
}
}
+36 -24
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use Illuminate\Http\Request;
@@ -21,9 +22,13 @@ public function unread(Request $request)
$perPage = max(1, min(100, (int) $request->integer('perPage', 15)));
$search = trim((string) $request->input('search', ''));
$clientUuid = trim((string) $request->input('client', ''));
$clientCaseId = null;
$clientId = null;
$clientCaseIdsForFilter = collect();
if ($clientUuid !== '') {
$clientCaseId = ClientCase::query()->where('uuid', $clientUuid)->value('id');
$clientId = Client::query()->where('uuid', $clientUuid)->value('id');
if ($clientId) {
$clientCaseIdsForFilter = ClientCase::query()->where('client_id', $clientId)->pluck('id');
}
}
$query = Activity::query()
@@ -36,13 +41,13 @@ public function unread(Request $request)
->where('anr.user_id', $user->id)
->whereColumn('anr.due_date', 'activities.due_date');
})
->when($clientCaseId, function ($q) use ($clientCaseId) {
// Match activities for the client case directly OR via contracts belonging to the case
$q->where(function ($qq) use ($clientCaseId) {
$qq->where('activities.client_case_id', $clientCaseId)
->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) {
// Filter by clients: activities directly on any of the client's cases OR via contracts under those cases
$q->where(function ($qq) use ($clientCaseIdsForFilter) {
$qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter)
->orWhereIn('activities.contract_id', Contract::query()
->select('id')
->where('client_case_id', $clientCaseId)
->whereIn('client_case_id', $clientCaseIdsForFilter)
);
});
})
@@ -65,7 +70,7 @@ public function unread(Request $request)
$qq->select(['client_cases.id', 'client_cases.uuid', 'client_cases.client_id'])
->with([
'client' => function ($qqq) {
$qqq->select(['clients.id', 'clients.person_id'])
$qqq->select(['clients.id', 'clients.uuid', 'clients.person_id'])
->with([
'person' => function ($qqqq) {
$qqqq->select(['person.id', 'person.full_name']);
@@ -86,7 +91,7 @@ public function unread(Request $request)
$qq->select(['person.id', 'person.full_name']);
},
'client' => function ($qq) {
$qq->select(['clients.id', 'clients.person_id'])
$qq->select(['clients.id', 'clients.uuid', 'clients.person_id'])
->with([
'person' => function ($qqq) {
$qqq->select(['person.id', 'person.full_name']);
@@ -102,7 +107,7 @@ public function unread(Request $request)
// Use a custom page parameter name to match the frontend DataTableServer
$activities = $query->paginate($perPage, ['*'], 'unread-page')->withQueryString();
// Build a distinct clients list for the filter (client_case UUID + person.full_name)
// Build a distinct clients list for the filter (client UUID + client.person.full_name)
// Collect client_case_ids from both direct activities and via contracts
$baseForClients = Activity::query()
->select(['contract_id', 'client_case_id'])
@@ -114,10 +119,10 @@ public function unread(Request $request)
->where('anr.user_id', $user->id)
->whereColumn('anr.due_date', 'activities.due_date');
})
->when($clientCaseId, function ($q) use ($clientCaseId) {
$q->where(function ($qq) use ($clientCaseId) {
$qq->where('activities.client_case_id', $clientCaseId)
->orWhereIn('activities.contract_id', Contract::query()->select('id')->where('client_case_id', $clientCaseId));
->when($clientCaseIdsForFilter->isNotEmpty(), function ($q) use ($clientCaseIdsForFilter) {
$q->where(function ($qq) use ($clientCaseIdsForFilter) {
$qq->whereIn('activities.client_case_id', $clientCaseIdsForFilter)
->orWhereIn('activities.contract_id', Contract::query()->select('id')->whereIn('client_case_id', $clientCaseIdsForFilter));
});
})
->get();
@@ -133,16 +138,23 @@ public function unread(Request $request)
->unique()
->values();
$clients = ClientCase::query()
->whereIn('id', $caseIds)
->with(['person:id,full_name'])
->get(['id', 'uuid', 'person_id'])
->map(fn ($cc) => [
'value' => $cc->uuid,
'label' => optional($cc->person)->full_name ?: '(neznana stranka)',
])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values();
// Map caseIds -> clientIds, then load clients and present as value(label)
$clientIds = $caseIds->isNotEmpty()
? ClientCase::query()->whereIn('id', $caseIds)->pluck('client_id')->filter()->unique()->values()
: collect();
$clients = $clientIds->isNotEmpty()
? Client::query()
->whereIn('id', $clientIds)
->with(['person:id,full_name'])
->get(['id', 'uuid', 'person_id'])
->map(fn ($c) => [
'value' => $c->uuid,
'label' => optional($c->person)->full_name ?: '(neznana stranka)',
])
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
->values()
: collect();
return Inertia::render('Notifications/Unread', [
'activities' => $activities,
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\SmsLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class SmsWebhookController extends Controller
{
/**
* Handle smsapi.si delivery reports (GET) and replies (POST).
* This endpoint accepts both methods as the provider may use either.
*/
public function __invoke(Request $request)
{
// Delivery report via GET: id (int) and status (string)
if ($request->query->has('id')) {
$providerId = (string) ((int) $request->query('id'));
$status = trim(strip_tags((string) $request->query('status', '')));
$log = SmsLog::query()->where('provider_message_id', $providerId)->first();
if ($log) {
$meta = (array) $log->meta;
$meta['delivery_report'] = [
'raw_status' => $status,
'received_at' => now()->toIso8601String(),
];
// Naive mapping: mark delivered for common success statuses
$normalized = strtoupper($status);
if (in_array($normalized, ['DELIVERED', 'DELIVRD', 'OK'], true)) {
$log->status = 'delivered';
$log->delivered_at = now();
} elseif (in_array($normalized, ['FAILED', 'UNDELIV', 'UNDELIVERED', 'ERROR'], true)) {
$log->status = 'failed';
$log->failed_at = now();
$log->error_code = $normalized;
}
$log->meta = $meta;
$log->save();
} else {
Log::warning('sms.webhook.delivery.unknown_id', ['provider_id' => $providerId, 'status' => $status]);
}
return response()->json(['ok' => true]);
}
// Reply via POST: smsId, m (message), from, to, time
if ($request->isMethod('post') && $request->post('smsId')) {
$providerId = (string) ((int) $request->post('smsId'));
$msg = trim(strip_tags((string) $request->post('m', '')));
$fromNumber = (string) $request->post('from', '');
$toNumber = (string) $request->post('to', '');
$timestamp = (int) $request->post('time', time());
$log = SmsLog::query()->where('provider_message_id', $providerId)->first();
if ($log) {
$meta = (array) $log->meta;
$replies = isset($meta['replies']) && is_array($meta['replies']) ? $meta['replies'] : [];
$replies[] = [
'message' => $msg,
'from' => $fromNumber,
'to' => $toNumber,
'time' => date('c', $timestamp),
];
$meta['replies'] = $replies;
$log->meta = $meta;
$log->save();
} else {
Log::warning('sms.webhook.reply.unknown_id', [
'provider_id' => $providerId,
'from' => $fromNumber,
'to' => $toNumber,
]);
}
return response()->json(['ok' => true]);
}
// Unknown payload
return response()->json(['ok' => false, 'reason' => 'unsupported payload'], 400);
}
}
@@ -139,7 +139,7 @@ public function share(Request $request): array
? \App\Models\Client::query()
->whereIn('clients.id', $clientIds)
->with(['person:id,full_name'])
->get(['clients.id', 'clients.person_id'])
->get(['clients.id', 'clients.uuid', 'clients.person_id'])
->keyBy('id')
: collect();
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreSmsProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:190'],
'active' => ['sometimes', 'boolean'],
'api_username' => ['required', 'string', 'max:190'],
'api_password' => ['required', 'string', 'max:500'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSmsSenderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
$pid = (int) $this->input('profile_id');
return [
'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'],
'sname' => [
'nullable', 'string', 'max:20',
Rule::unique('sms_senders', 'sname')->where(fn ($q) => $q->where('profile_id', $pid)),
],
'phone_number' => ['nullable', 'string', 'max:30'],
'description' => ['nullable', 'string', 'max:190'],
'active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSmsTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:190'],
'slug' => ['required', 'string', 'max:190', 'alpha_dash', 'unique:sms_templates,slug'],
// Content is required unless template allows custom body
'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'],
'variables_json' => ['nullable', 'array'],
'is_active' => ['sometimes', 'boolean'],
'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'allow_custom_body' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TestSendSmsRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'to' => ['required', 'string', 'max:30'], // E.164-ish; we can refine later
'message' => ['required', 'string', 'max:1000'],
'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'delivery_report' => ['sometimes', 'boolean'],
'country_code' => ['nullable', 'string', 'max:5'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TestSendSmsTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
return [
'to' => ['required', 'string', 'max:30'],
'variables' => ['nullable', 'array'],
'profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'delivery_report' => ['sometimes', 'boolean'],
'country_code' => ['nullable', 'string', 'max:5'],
'custom_content' => ['nullable', 'string', 'max:1000'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateSmsSenderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
$pid = (int) $this->input('profile_id');
$id = (int) ($this->route('smsSender')?->id ?? 0);
return [
'profile_id' => ['required', 'integer', 'exists:sms_profiles,id'],
'sname' => [
'nullable', 'string', 'max:20',
Rule::unique('sms_senders', 'sname')
->ignore($id)
->where(fn ($q) => $q->where('profile_id', $pid)),
],
'phone_number' => ['nullable', 'string', 'max:30'],
'description' => ['nullable', 'string', 'max:190'],
'active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateSmsTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('manage-settings') ?? false;
}
public function rules(): array
{
$id = (int) ($this->route('smsTemplate')?->id ?? 0);
return [
'name' => ['required', 'string', 'max:190'],
'slug' => ['required', 'string', 'max:190', 'alpha_dash', Rule::unique('sms_templates', 'slug')->ignore($id)],
// Content is required unless template allows custom body
'content' => [Rule::requiredIf(fn () => ! (bool) $this->input('allow_custom_body')), 'nullable', 'string', 'max:1000'],
'variables_json' => ['nullable', 'array'],
'is_active' => ['sometimes', 'boolean'],
'default_profile_id' => ['nullable', 'integer', 'exists:sms_profiles,id'],
'default_sender_id' => ['nullable', 'integer', 'exists:sms_senders,id'],
'allow_custom_body' => ['sometimes', 'boolean'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
];
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Jobs;
use App\Models\ClientCase;
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;
class SendSmsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $profileId,
public string $to,
public string $content,
public ?int $senderId = null,
public ?string $countryCode = null,
public bool $deliveryReport = false,
public ?string $clientReference = null,
// For optional activity creation (case UI only)
public ?int $templateId = null,
public ?int $clientCaseId = null,
public ?int $userId = null,
) {}
/**
* Execute the job.
*/
public function handle(SmsService $sms): void
{
// Resolve models
/** @var SmsProfile|null $profile */
$profile = SmsProfile::find($this->profileId);
if (! $profile) {
return; // nothing to do
}
/** @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,
);
// If invoked from the case UI with a selected template, create an Activity
if ($this->templateId && $this->clientCaseId && $log) {
try {
/** @var SmsTemplate|null $template */
$template = SmsTemplate::find($this->templateId);
/** @var ClientCase|null $case */
$case = ClientCase::find($this->clientCaseId);
if ($template && $case) {
$note = '';
if ($log->status === 'sent') {
$note = sprintf('Št: %s | Telo: %s', (string) $this->to, (string) $this->content);
} elseif ($log->status === 'failed') {
$note = sprintf(
'Št: %s | Telo: %s | Napaka: %s',
(string) $this->to,
(string) $this->content,
'SMS ni bil poslan!'
);
}
$case->activities()->create([
'note' => $note,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'user_id' => $this->userId,
]);
}
} catch (\Throwable $e) {
\Log::warning('SendSmsJob activity creation failed', [
'error' => $e->getMessage(),
'client_case_id' => $this->clientCaseId,
'template_id' => $this->templateId,
]);
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SmsLog extends Model
{
use HasFactory;
protected $table = 'sms_logs';
protected $fillable = [
'uuid',
'profile_id',
'template_id',
'to_number',
'sender',
'message',
'status',
'provider_message_id',
'error_code',
'error_message',
'cost',
'currency',
'meta',
'queued_at',
'sent_at',
'delivered_at',
'failed_at',
];
protected $casts = [
'meta' => 'array',
'queued_at' => 'datetime',
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
'failed_at' => 'datetime',
'cost' => 'decimal:2',
];
public function profile()
{
return $this->belongsTo(SmsProfile::class, 'profile_id');
}
public function template()
{
return $this->belongsTo(SmsTemplate::class, 'template_id');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SmsProfile extends Model
{
use HasFactory;
protected $table = 'sms_profiles';
protected $fillable = [
'uuid',
'name',
'active',
'api_username',
'default_sender_id',
'settings',
];
protected $casts = [
'active' => 'boolean',
'settings' => 'array',
];
protected $hidden = [
'encrypted_api_password',
];
// Write-only password setter
public function setApiPasswordAttribute(string $plain): void
{
$this->attributes['encrypted_api_password'] = app(\App\Services\MailSecretEncrypter::class)->encrypt($plain);
}
public function decryptApiPassword(): ?string
{
if (! isset($this->attributes['encrypted_api_password'])) {
return null;
}
return app(\App\Services\MailSecretEncrypter::class)->decrypt($this->attributes['encrypted_api_password']);
}
public function senders()
{
return $this->hasMany(SmsSender::class, 'profile_id');
}
public function logs()
{
return $this->hasMany(SmsLog::class, 'profile_id');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SmsSender extends Model
{
use HasFactory;
protected $table = 'sms_senders';
protected $fillable = [
'profile_id',
'sname',
'phone_number',
'description',
'active',
];
protected $casts = [
'active' => 'boolean',
];
public function profile()
{
return $this->belongsTo(SmsProfile::class, 'profile_id');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SmsTemplate extends Model
{
use HasFactory;
protected $table = 'sms_templates';
protected $fillable = [
'uuid',
'name',
'slug',
'content',
'variables_json',
'is_active',
'default_profile_id',
'default_sender_id',
'allow_custom_body',
'action_id',
'decision_id',
];
protected $casts = [
'is_active' => 'boolean',
'variables_json' => 'array',
'allow_custom_body' => 'boolean',
];
public function defaultProfile()
{
return $this->belongsTo(SmsProfile::class, 'default_profile_id');
}
public function defaultSender()
{
return $this->belongsTo(SmsSender::class, 'default_sender_id');
}
public function action()
{
return $this->belongsTo(Action::class);
}
public function decision()
{
return $this->belongsTo(Decision::class);
}
}
+8 -3
View File
@@ -2,8 +2,10 @@
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Services\Sms\SmsApiSiClient;
use App\Services\Sms\SmsClient;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
class AppServiceProvider extends ServiceProvider
@@ -13,11 +15,14 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// Inertia shared
Inertia::share([
'laravelVersion' => Application::VERSION,
'phpVersion' => PHP_VERSION
'phpVersion' => PHP_VERSION,
]);
// SMS: bind provider client
$this->app->bind(SmsClient::class, SmsApiSiClient::class);
}
/**
+202
View File
@@ -0,0 +1,202 @@
<?php
namespace App\Services\Sms;
use App\Models\SmsProfile;
use Illuminate\Support\Facades\Http;
class SmsApiSiClient implements SmsClient
{
public function send(SmsProfile $profile, SmsMessage $message): SmsResult
{
$baseUrl = config('services.sms.providers.smsapi_si.base_url');
$endpoint = config('services.sms.providers.smsapi_si.send_endpoint', '/poslji-sms');
$timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10);
$url = rtrim($baseUrl, '/').$endpoint;
$payload = [
'un' => $profile->api_username,
'ps' => $profile->decryptApiPassword() ?? '',
// provider requires phone number in `from`
'from' => isset($message->senderPhone) && $message->senderPhone !== '' ? urlencode($message->senderPhone) : '',
'to' => $message->to,
'm' => $message->content,
];
// Log payload safely (mask password) for debugging; uses context so it will appear on supported channels
$logPayload = $payload;
if (isset($logPayload['ps'])) {
$logPayload['ps'] = '***';
}
\Log::info('sms.send payload', ['payload' => $logPayload]);
/*if (! empty($message->sender)) {
$payload['sname'] = $message->sender;
}*/
// Default country code when not provided
$payload['cc'] = urlencode(! empty($message->countryCode) ? $message->countryCode : '386');
if ($message->deliveryReport) {
$payload['dr'] = 1;
}
$response = Http::asForm()
->timeout($timeout)
->post($url, $payload);
$body = trim((string) $response->body());
$statusCode = $response->status();
// Parse according to provider docs: ID##SMS_PRICE##FROM##TO
// Success: 123##0.03##040123456##040654321
// Failure: -1##ERROR_ID##FROM##TO
$parts = array_values(array_filter(array_map(static fn ($s) => trim((string) $s), explode('##', $body)), static fn ($s) => $s !== ''));
// Known error codes (Slovenian descriptions from docs)
$errorMap = [
1 => 'Napaka v validaciji.',
2 => 'Sporočilo je predolgo ali prazno.',
3 => 'Številka pošiljatelja ni pravilno tvorjena ali pa je prazna.',
4 => 'Številka prejemnika ni pravilno tvorjena ali pa je prazna.',
5 => 'Uporabnik nima dovolj kreditov.',
6 => 'Napaka na serverju.',
7 => 'Številka pošiljatelja ni registrirana.',
8 => 'Referenca uporabnika ni veljavna.',
9 => 'Koda države ni veljavna.',
10 => 'ID pošiljatelja ni potrjen.',
11 => 'Koda države ni podprta',
12 => 'Številka pošiljatelja ni potrjena',
13 => 'Številka oz. država pošiljatelja ne podpira MMS vsebin.',
14 => 'MMS tip oblike (mime type) ni podprta',
15 => 'MMS url je nedosegljiv ali datoteka ne obstaja.',
16 => 'MMS vsebina je predolga',
];
if ($response->successful() && ! empty($parts)) {
// Failure shape
if ($parts[0] === '-1') {
$errorId = isset($parts[1]) ? (int) $parts[1] : null;
$from = $parts[2] ?? null;
$to = $parts[3] ?? null;
return new SmsResult(
status: 'failed',
providerMessageId: null,
cost: null,
currency: null,
meta: [
'status_code' => $statusCode,
'body' => $body,
'parts' => $parts,
'error_id' => $errorId,
'error_message' => $errorId ? ($errorMap[$errorId] ?? 'Neznana napaka') : 'Neznana napaka',
'from' => $from,
'to' => $to,
]
);
}
// Success shape
if (preg_match('/^\d+$/', $parts[0])) {
$providerId = $parts[0];
$price = null;
if (isset($parts[1])) {
$priceStr = str_replace(',', '.', $parts[1]);
$price = is_numeric($priceStr) ? (float) $priceStr : null;
}
$from = $parts[2] ?? null;
$to = $parts[3] ?? null;
return new SmsResult(
status: 'sent',
providerMessageId: $providerId,
cost: $price,
currency: null,
meta: [
'status_code' => $statusCode,
'body' => $body,
'parts' => $parts,
'from' => $from,
'to' => $to,
]
);
}
}
// HTTP error or unexpected body
return new SmsResult(
status: 'failed',
providerMessageId: null,
cost: null,
currency: null,
meta: [
'status_code' => $statusCode,
'body' => $body,
'parts' => $parts,
]
);
}
public function getCreditBalance(SmsProfile $profile): int
{
$baseUrl = config('services.sms.providers.smsapi_si.base_url');
$endpoint = config('services.sms.providers.smsapi_si.credits_endpoint', '/preveri-stanje-kreditov');
$timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10);
$url = rtrim($baseUrl, '/').$endpoint;
$response = Http::asForm()
->timeout($timeout)
->post($url, [
'un' => $profile->api_username,
'ps' => $profile->decryptApiPassword() ?? '',
]);
if (! $response->successful()) {
\Log::warning('SMS credits request failed', [
'status' => $response->status(),
'url' => $url,
'username' => $profile->api_username,
'body' => (string) $response->body(),
]);
throw new \RuntimeException('Credits endpoint returned HTTP '.$response->status());
}
$body = trim((string) $response->body());
if ($body === '') {
return 0;
}
// Per provider docs, response is '##' separated: first token contains the balance
$parts = explode('##', $body);
$first = trim($parts[0] ?? $body);
// Fallback to the raw first token if no number found
return intval($first);
}
public function getPriceQuotes(SmsProfile $profile): array
{
$baseUrl = config('services.sms.providers.smsapi_si.base_url');
$endpoint = config('services.sms.providers.smsapi_si.price_endpoint', '/dobi-ceno');
$timeout = (int) config('services.sms.providers.smsapi_si.timeout', 10);
$url = rtrim($baseUrl, '/').$endpoint;
$response = Http::asForm()
->timeout($timeout)
->post($url, [
'un' => $profile->api_username,
'ps' => $profile->decryptApiPassword() ?? '',
]);
$body = trim((string) $response->body());
if ($body === '') {
return [];
}
// Provider returns '##' separated values; trim and drop empty tokens
$parts = explode('##', $body);
$parts = array_map(static fn ($s) => trim((string) $s), $parts);
$parts = array_values(array_filter($parts, static fn ($s) => $s !== ''));
return $parts;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Sms;
use App\Models\SmsProfile;
interface SmsClient
{
/**
* Sends an SMS message using the given profile credentials.
*/
public function send(SmsProfile $profile, SmsMessage $message): SmsResult;
/**
* Returns current credit balance as an integer (normalized provider value).
*/
public function getCreditBalance(SmsProfile $profile): int;
/**
* Returns price quote(s) as an array of strings (provider can return multiple separated by ##).
*/
public function getPriceQuotes(SmsProfile $profile): array;
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Services\Sms;
class SmsMessage
{
public function __construct(
public string $to,
public string $content,
public ?string $sender = null, // provider sname
public ?string $senderPhone = null, // phone number for 'from'
public ?string $countryCode = null, // cc param, optional
public bool $deliveryReport = false, // dr=1 when true
public ?string $clientReference = null,
) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Sms;
class SmsResult
{
public function __construct(
public string $status, // sent|failed
public ?string $providerMessageId = null,
public ?float $cost = null,
public ?string $currency = null,
public array $meta = [],
) {}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Services\Sms;
use App\Models\SmsLog;
use App\Models\SmsProfile;
use App\Models\SmsSender;
use App\Models\SmsTemplate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class SmsService
{
public function __construct(
protected SmsClient $client,
) {}
/**
* Send a raw text message.
*/
public function sendRaw(SmsProfile $profile, string $to, string $content, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog
{
return DB::transaction(function () use ($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference): SmsLog {
$log = new SmsLog([
'uuid' => (string) Str::uuid(),
'profile_id' => $profile->id,
'to_number' => /*$to*/'',
'sender' => $sender?->sname,
'message' => $content,
'status' => 'queued',
'queued_at' => now(),
]);
$log->save();
$result = $this->client->send($profile, new SmsMessage(
to: $to,
content: $content,
sender: $sender?->sname,
senderPhone: /*$sender?->phone_number*/'',
countryCode: $countryCode,
deliveryReport: $deliveryReport,
clientReference: $clientReference,
));
if ($result->status === 'sent') {
$log->status = 'sent';
$log->sent_at = now();
} else {
$log->status = 'failed';
$log->failed_at = now();
}
$log->provider_message_id = $result->providerMessageId;
$log->cost = $result->cost;
$log->currency = $result->currency;
$log->meta = $result->meta;
$log->save();
return $log;
});
}
/**
* Render an SMS from template and send.
*/
public function sendFromTemplate(SmsTemplate $template, string $to, array $variables = [], ?SmsProfile $profile = null, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog
{
$profile = $profile ?: $template->defaultProfile;
if (! $profile) {
throw new \InvalidArgumentException('SMS profile is required to send a message.');
}
$sender = $sender ?: $template->defaultSender;
$content = $this->renderContent($template->content, $variables);
$log = $this->sendRaw($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference);
$log->template_id = $template->id;
$log->save();
return $log;
}
protected 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];
return array_key_exists($key, $vars) ? (string) $vars[$key] : $m[0];
}, $content);
}
/**
* Get current credit balance from provider.
*/
public function getCreditBalance(SmsProfile $profile): string
{
return $this->client->getCreditBalance($profile);
}
/**
* Get price quote(s) from provider.
* Returns array of strings as provided by the API.
*/
public function getPriceQuotes(SmsProfile $profile): array
{
return $this->client->getPriceQuotes($profile);
}
}