Fix FLEXIAPI-433 Implement CallForwarding

This commit is contained in:
Timothée Jaussoin 2026-02-05 17:42:33 +01:00
parent 126f25de5a
commit 3f0ecc297b
33 changed files with 1388 additions and 596 deletions

View file

@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- **Rockylinux 10 support** Packages are now available in the official repository
- **Artisan cleanup script for statistics** Add an artisan console script to clear statistics after n days `app:clear-statistics {days} {--apply}`
- **Add Voicemail features and related API endpoints** to integrate with `flexisip-voicemail`
- **Add Call Forwarding features and related API endpoints**
### Changed

View file

@ -139,6 +139,20 @@ class Account extends Authenticatable
return $this->hasMany(AccountFile::class)->latest();
}
public function callForwardings()
{
return $this->hasMany(CallForwarding::class)->latest();
}
public function getCallForwardingsDefaultAttribute()
{
$callForwardings = $this->callForwardings->keyBy('type');
$resolved['always'] = $callForwardings['always'] ?? new CallForwarding(['type' => 'always']);
$resolved['away'] = $callForwardings['away'] ?? new CallForwarding(['type' => 'away']);
$resolved['busy'] = $callForwardings['busy'] ?? new CallForwarding(['type' => 'busy']);
return $resolved;
}
public function voicemails()
{
return $this->hasMany(AccountFile::class)

View file

@ -0,0 +1,19 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class CallForwarding extends Model
{
protected $casts = [
'enabled' => 'boolean'
];
protected $fillable = ['enabled', 'account_id', 'type', 'forward_to', 'sip_uri'];
protected $hidden = ['account_id'];
public function account()
{
return $this->belongsTo(Account::class);
}
}

View file

@ -175,21 +175,21 @@ function captchaConfigured(): bool
return env('HCAPTCHA_SECRET', false) != false || env('HCAPTCHA_SITEKEY', false) != false;
}
function resolveUserContacts(Request $request)
function resolveUserContacts(Account $account)
{
$selected = ['id', 'username', 'domain', 'activated', 'dtmf_protocol', 'display_name'];
return Account::withoutGlobalScopes()->whereIn('id', function ($query) use ($request) {
return Account::withoutGlobalScopes()->whereIn('id', function ($query) use ($account) {
$query->select('contact_id')
->from('contacts')
->where('account_id', $request->user()->id)
->where('account_id', $account->id)
->union(
DB::table('contacts_list_contact')
->select('contact_id')
->whereIn('contacts_list_id', function ($query) use ($request) {
->whereIn('contacts_list_id', function ($query) use ($account) {
$query->select('contacts_list_id')
->from('account_contacts_list')
->where('account_id', $request->user()->id);
->where('account_id', $account->id);
})
);
})->select($selected);

View file

@ -28,7 +28,7 @@ class ContactVcardController extends Controller
public function index(Request $request)
{
return response(
resolveUserContacts($request)->get()->map(function ($contact) {
resolveUserContacts($request->user())->get()->map(function ($contact) {
return $contact->toVcard4();
})->implode("\n")
);
@ -36,7 +36,7 @@ class ContactVcardController extends Controller
public function show(Request $request, string $sip)
{
return resolveUserContacts($request)
return resolveUserContacts($request->user())
->sip($sip)
->firstOrFail()
->toVcard4();

View file

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Admin\Account;
use App\Account;
use App\CallForwarding;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class CallForwardingController extends Controller
{
public function update(Request $request, int $accountId)
{
$account = Account::findOrFail($accountId);
$contactsIds = resolveUserContacts($account)->pluck('id')->toArray();
$forwardTo = 'required|in:sip_uri,contact,voicemail';
$request->validate([
'always.forward_to' => $forwardTo,
'always.sip_uri' => 'nullable|starts_with:sip:|required_if:always.forward_to,sip_uri',
'always.contact_id' => ['required_if:always.forward_to,contact', Rule::in($contactsIds)],
'away.forward_to' => $forwardTo,
'away.sip_uri' => 'nullable|starts_with:sip:|required_if:away.forward_to,sip_uri',
'away.contact_id' => ['required_if:away.forward_to,contact', Rule::in($contactsIds)],
'busy.forward_to' => $forwardTo,
'busy.sip_uri' => 'nullable|starts_with:sip:|required_if:busy.forward_to,sip_uri',
'busy.contact_id' => ['required_if:busy.forward_to,contact', Rule::in($contactsIds)],
]);
$account->callForwardings()->update(['enabled' => false]);
if (array_key_exists('enabled', $request->get('always'))) {
$alwaysForwarding = $account->callForwardings()->where('type', 'always')->first() ?? new CallForwarding;
$alwaysForwarding->enabled = true;
$alwaysForwarding->account_id = $account->id;
$alwaysForwarding->type = 'always';
$alwaysForwarding->forward_to = $request->get('always')['forward_to'];
$alwaysForwarding->sip_uri = $request->get('always')['sip_uri'];
$alwaysForwarding->contact_id = $request->get('always')['forward_to'] == 'contact'
? $request->get('always')['contact_id']
: null;
$alwaysForwarding->save();
}
if (array_key_exists('enabled', $request->get('away'))) {
$awayForwarding = $account->callForwardings()->where('type', 'away')->first() ?? new CallForwarding;
$awayForwarding->enabled = true;
$awayForwarding->account_id = $account->id;
$awayForwarding->type = 'away';
$awayForwarding->forward_to = $request->get('away')['forward_to'];
$awayForwarding->sip_uri = $request->get('away')['sip_uri'];
$awayForwarding->contact_id = $request->get('away')['forward_to'] == 'contact'
? $request->get('away')['contact_id']
: null;
$awayForwarding->save();
}
if (array_key_exists('enabled', $request->get('busy'))) {
$busyForwarding = $account->callForwardings()->where('type', 'busy')->first() ?? new CallForwarding;
$busyForwarding->enabled = true;
$busyForwarding->account_id = $account->id;
$busyForwarding->type = 'busy';
$busyForwarding->forward_to = $request->get('busy')['forward_to'];
$busyForwarding->sip_uri = $request->get('busy')['sip_uri'];
$busyForwarding->contact_id = $request->get('busy')['forward_to'] == 'contact'
? $request->get('busy')['contact_id']
: null;
$busyForwarding->save();
}
return redirect()->route('admin.account.telephony.show', $account);
}
}

View file

@ -34,7 +34,7 @@ class ContactController extends Controller
return view('admin.account.contact.index', [
'account' => $account,
'contacts_lists' => ContactsList::whereNotIn('id', function ($query) use ($accountId) {
'contacts_lists' => $account->space->contactsLists()->whereNotIn('id', function ($query) use ($accountId) {
$query->select('contacts_list_id')
->from('account_contacts_list')
->where('account_id', $accountId);

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Admin\Account;
use App\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TelephonyController extends Controller
{
public function show(int $accountId)
{
return view('admin.account.telephony.show', [
'account' => Account::findOrFail($accountId)
]);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api\Account;
use App\Http\Controllers\Controller;
use \App\Http\Controllers\Api\Admin\Account\CallForwardingController as AdminCallForwardingController;
use Illuminate\Http\Request;
class CallForwardingController extends Controller
{
public function index(Request $request)
{
return (new AdminCallForwardingController)->index($request, $request->user()->id);
}
public function store(Request $request)
{
return (new AdminCallForwardingController)->store($request, $request->user()->id);
}
public function update(Request $request, $id)
{
return (new AdminCallForwardingController)->update($request, $request->user()->id, $id);
}
public function show(Request $request, string $id)
{
return (new AdminCallForwardingController)->show($request, $request->user()->id, $id);
}
public function destroy(Request $request, string $id)
{
return (new AdminCallForwardingController)->destroy($request, $request->user()->id, $id);
}
}

View file

@ -26,11 +26,11 @@ class ContactController extends Controller
{
public function index(Request $request)
{
return resolveUserContacts($request)->get();
return resolveUserContacts($request->user())->get();
}
public function show(Request $request, string $sip)
{
return resolveUserContacts($request)->sip($sip)->firstOrFail();
return resolveUserContacts($request->user())->sip($sip)->firstOrFail();
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Api\Admin\Account;
use App\Account;
use App\CallForwarding;
use App\Http\Controllers\Controller;
use App\Rules\CallForwardingEnable;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class CallForwardingController extends Controller
{
public function index(Request $request, int $accountId)
{
return Account::findOrFail($accountId)->callForwardings;
}
public function store(Request $request, int $accountId)
{
$account = Account::findOrFail($accountId);
$request->validate([
'type' => [
'required',
'in:always,away,busy',
Rule::unique('call_forwardings', 'type')->where(fn($query) => $query->where('account_id', $accountId))
],
'forward_to' => 'required|in:sip_uri,contact,voicemail',
'sip_uri' => 'nullable|starts_with:sip:|required_if:forward_to,sip_uri',
'enabled' => ['required', 'boolean', new CallForwardingEnable($request, $account)],
'contact_id' => ['required_if:forward_to,contact', Rule::in(resolveUserContacts($account)->pluck('id')->toArray())],
]);
$callForwarding = new CallForwarding;
$callForwarding->account_id = $account->id;
$callForwarding->type = $request->get('type');
$callForwarding->forward_to = $request->get('forward_to');
$callForwarding->sip_uri = $request->get('sip_uri');
$callForwarding->enabled = $request->get('enabled');
$callForwarding->contact_id = $request->get('contact_id');
$callForwarding->save();
return $callForwarding;
}
public function update(Request $request, int $accountId, string $id)
{
$account = Account::findOrFail($accountId);
$callForwarding = $account->callForwardings()->where('id', $id)->firstOrFail();
$request->validate([
'type' => [
'required',
'in:always,away,busy',
Rule::unique('call_forwardings', 'type')
->where(fn($query) => $query->where('account_id', $accountId))
->ignore($callForwarding->id)
],
'forward_to' => 'required|in:sip_uri',
'sip_uri' => 'required|starts_with:sip',
'enabled' => ['required', 'boolean', new CallForwardingEnable($request, $account)]
]);
$callForwarding->forward_to = $request->get('forward_to');
$callForwarding->sip_uri = $request->get('sip_uri');
$callForwarding->enabled = $request->get('enabled');
$callForwarding->save();
return $callForwarding;
}
public function show(Request $request, int $accountId, string $id)
{
return Account::findOrFail($accountId)->callForwardings()->where('id', $id)->firstOrFail();
}
public function destroy(Request $request, int $accountId, string $id)
{
return Account::findOrFail($accountId)->callForwardings()->where('id', $id)->delete();
}
}

View file

@ -67,7 +67,7 @@ class AccountController extends Controller
public function search(Request $request, string $sip)
{
$account = $request->space->accounts()->sip($sip)->first();
$account = $request->space->accounts()->sip($sip)->with('callForwardings')->first();
if (!$account)
abort(404, 'SIP address not found');
@ -77,7 +77,7 @@ class AccountController extends Controller
public function searchByEmail(Request $request, string $email)
{
$account = $request->space->accounts()->where('email', $email)->first();
$account = $request->space->accounts()->where('email', $email)->with('callForwardings')->first();
if (!$account)
abort(404, 'Email address not found');

View file

@ -81,6 +81,18 @@ class SpaceController extends Controller
return $space->refresh();
}
public function resolve(Request $request, string $sip)
{
$account = $request->space->accounts()->sip($sip)->with('callForwardings')->firstOrFail();
$arrayAccount = $account->toArray();
unset($arrayAccount['space']);
return json_encode([
'type' => 'account',
'payload' => $arrayAccount
]);
}
public function show(string $domain)
{
return Space::where('domain', $domain)->firstOrFail();

View file

@ -0,0 +1,26 @@
<?php
namespace App\Rules;
use App\Account;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Request;
class CallForwardingEnable implements ValidationRule
{
public function __construct(private Request $request, private Account $account)
{
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value == true) {
$filter = $this->request->get('type') == 'always' ? ['away', 'busy'] : ['always'];
if ($this->account->callForwardings()->whereIn('type', $filter)->where('enabled', true)->exists()) {
$fail('type: always and type: always/busy cannot be enabled at the same time');
}
}
}
}

1177
flexiapi/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('call_forwardings', function (Blueprint $table) {
$table->id();
$table->integer('account_id')->unsigned()->nullable();
$table->foreign('account_id')->references('id')
->on('accounts')->onDelete('cascade');
$table->string('type');
$table->string('forward_to');
$table->string('sip_uri')->nullable();
$table->boolean('enabled')->default(false);
$table->integer('contact_id')->unsigned()->nullable();
$table->foreign('contact_id')->references('id')
->on('accounts')->onDelete('cascade');
$table->unique(['account_id', 'type']);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('call_forwardings');
}
};

View file

@ -21,6 +21,7 @@
"Admin": "Administrateur",
"Administration": "Administration",
"Admins": "Administrateurs",
"All the calls": "Tous les appels",
"All the admins will be super admins": "Tous les administrateurs seront super-administrateurs",
"Allow a custom CSS theme": "Autoriser un thème CSS personnalisé",
"Allow client settings to be overwritten by the provisioning ones": "Écraser la configuration client avec celle du déploiement",
@ -30,6 +31,7 @@
"Api Keys": "Clefs d'API",
"App Configuration": "Configuration de l'App",
"App settings": "Paramètres d'application",
"No anwser": "Pas de réponse",
"Assistant": "Assistant",
"Best regards,":"Cordialement,",
"Blocked": "Bloqué",
@ -38,6 +40,7 @@
"By phone": "Par téléphone",
"By": "Par",
"Call Recording": "Enregistrement d'appels",
"Call Forwarding": "Redirection d'appels",
"Calls logs": "Journaux d'appel",
"Cancel": "Annuler",
"Cannot be changed once created.": "Ne peut être changé par la suite.",
@ -63,6 +66,7 @@
"Connection": "Connexion",
"Contacts List": "Liste de Contacts",
"Contacts Lists": "Listes de Contacts",
"Contact": "Contact",
"Contacts": "Contacts",
"Copyright text": "Texte droits d'auteurs",
"Country code": "Code du pays",
@ -154,11 +158,13 @@
"No account yet?": "Pas encore de compte ?",
"No email yet": "Pas d'email pour le moment",
"No limit": "Sans limite",
"No answer": "Pas de réponse",
"No phone yet": "Pas de téléphone pour le moment",
"Number of minutes to expire the key after the last request.": "Nombre de minutes avant l'expiration de la clef après son dernier usage.",
"Open the app": "Ouvrir l'application",
"Other information": "Autres informations",
"Outbound proxy": "Outbound proxy",
"Line occupied": "Ligne occupée",
"Password": "Mot de passe",
"Phone Countries": "Numéros Internationaux",
"Phone number": "Numéro de téléphone",
@ -203,8 +209,7 @@
"Separated by commas": "Séparé par des virgules",
"Settings": "Paramètres",
"Show usernames only": "Afficher uniquement les noms d'utilisateur",
"SIP address":"Adresse SIP",
"Sip Adress": "Adresse SIP",
"SIP Adress": "Adresse SIP",
"SIP Domain": "Domaine SIP",
"Space": "Espace",
"Spaces": "Espaces",
@ -212,6 +217,7 @@
"Subdomain": "Sous-domaine",
"Super Admin": "Super Admin",
"Super Space": "Super Espace",
"Telephony": "Téléphonie",
"Thank you for registering on :space.":"Merci de vous être inscrit sur :space.",
"Thanks for the validation": "Nous vous remercions pour la validation",
"The :attribute should not be a phone number": "Le champ :attribute ne peut pas être un numéro de téléphone",

View file

@ -400,3 +400,16 @@ div.checkbox:hover > input[type="checkbox"] + label {
div.checkbox > input[type="checkbox"]:checked + label {
right: 0.5rem;
}
/* Telephony subselect */
div.select[data-value] ~ div.togglable {
display: none;
}
div.select[data-value=voicemail] ~ div.togglable.voicemail,
div.select[data-value=contact] ~ div.togglable.contact,
div.select[data-value=sip_uri] ~ div.togglable.sip_uri {
display: block;
}

View file

@ -135,3 +135,11 @@ function copyValueTo(from, to, append) {
to.value = value;
}
}
function setCheckboxValue(name, value) {
let checkbox = document.getElementsByName(name)[0];
if (checkbox) {
checkbox.checked = value;
}
}

View file

@ -0,0 +1,49 @@
<h3>
{{ __('Call Forwarding') }}
</h3>
<form id="edit" method="POST" action="{{ route('admin.account.call_forwardings.update', $account->id) }}" accept-charset="UTF-8">
@csrf
@method('put')
@php($callForwardings = $account->callForwardingsDefault)
<section>
<h4>{{ __('All the calls') }}</h4>
<div class="checkbox">
<input id="always[enabled]" type="checkbox" @if ($callForwardings['always']->enabled) checked @endif name="always[enabled]"
onchange="if (this.checked) { setCheckboxValue('away[enabled]', false); setCheckboxValue('busy[enabled]', false); }">
<label for="always[enabled]"></label>
</div>
@include('admin.account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'always'])
</section>
<section>
<h4>{{ __('No answer') }}</h4>
<div class="checkbox">
<input id="away[enabled]" type="checkbox" @if ($callForwardings['away']->enabled) checked @endif name="away[enabled]"
onchange="if (this.checked) { setCheckboxValue('always[enabled]', false); }">
<label for="away[enabled]"></label>
</div>
@include('admin.account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'away'])
</section>
<section>
<h4>{{ __('Line occupied') }}</h4>
<div class="checkbox">
<input id="busy[enabled]" type="checkbox" @if ($callForwardings['busy']->enabled) checked @endif name="busy[enabled]"
onchange="if (this.checked) { setCheckboxValue('always[enabled]', false); }">
<label for="busy[enabled]"></label>
</div>
@include('admin.account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'busy'])
</section>
<div class="large">
<input class="btn small oppose" type="submit" value="{{ __('Update') }}">
</div>
</form>

View file

@ -0,0 +1,23 @@
<div class="select" data-value="{{ $callForwardings[$type]->forward_to }}">
<select name="{{ $type }}[forward_to]" onchange="this.parentNode.dataset.value = this.value">
<option @if ($callForwardings[$type]->forward_to == 'voicemail') selected @endif value="voicemail">{{ __('Voicemails') }}</option>
<option @if ($callForwardings[$type]->forward_to == null || $callForwardings[$type]->forward_to == 'sip_uri') selected @endif value="sip_uri">{{ __('SIP Adress') }}</option>
<option @if ($callForwardings[$type]->forward_to == 'contact') selected @endif value="contact">{{ __('Contact') }}</option>
</select>
<label for="{{ $type }}[forward_to]">{{ __('Destination') }}</label>
</div>
<div class="togglable sip_uri">
<input placeholder="sip:username@server.com" list="contacts" name="{{ $type }}[sip_uri]" type="text" id="busy[sip_uri]" value="{{ $callForwardings[$type]->sip_uri }}">
<label for="sip">{{ __('SIP Adress') }}</label>
@include('parts.errors', ['name' => $type . '.sip_uri'])
</div>
<div class="togglable voicemail"></div>
<div class="select togglable contact">
<select name="{{ $type }}[contact_id]">
@foreach (resolveUserContacts($account)->get() as $contact)
<option @if ($callForwardings[$type]->contact_id == $contact->id) selected @endif value="{{ $contact->id }}">{{ $contact->identifier }}</option>
@endforeach
</select>
<label for="contact">{{ __('Contact') }}</label>
@include('parts.errors', ['name' => $type . '.contact'])
</div>

View file

@ -49,7 +49,7 @@
@foreach ($account->contactsLists as $contactsList)
<tr>
<td>
<a href="{{ route('admin.spaces.contacts_lists.edit', [$space, $contactsList->id]) }}">{{ $contactsList->title }}</a>
<a href="{{ route('admin.spaces.contacts_lists.edit', [$account->space, $contactsList->id]) }}">{{ $contactsList->title }}</a>
<small>{{ $contactsList->contacts_count }} {{ __('Contacts') }}</small>
</td>
<td class="actions">

View file

@ -1,6 +1,7 @@
@include('parts.tabs', [
'items' => [
route('admin.account.show', $account) => __('Information'),
route('admin.account.telephony.show', $account) => __('Telephony'),
route('admin.account.contact.index', $account) => __('Contacts'),
route('admin.account.statistics.show_call_logs', $account) => __('Calls logs'),
route('admin.account.statistics.show', $account) => __('Statistics'),

View file

@ -179,7 +179,7 @@
</div>
@endif
<div class="card">
<div class="card large">
<h3>
{{ __('Devices') }}
</h3>
@ -211,53 +211,6 @@
</table>
</div>
<div class="card">
<h3>
{{ __('Voicemails') }}
</h3>
<table>
<thead>
<tr>
<th>{{ __('Created') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@if ($account->uploadedVoicemails->isEmpty())
<tr class="empty">
<td colspan="2">{{ __('Empty') }}</td>
</tr>
@endif
@foreach ($account->uploadedVoicemails as $voicemail)
<tr>
<td>
{{ $voicemail->created_at }}
@if ($voicemail->url)
<a style="margin-left: 1rem;" href="{{ $voicemail->download_url }}" download>
<i class="ph ph-download"></i>
</a>
@endif
@if ($voicemail->sip_from)
<br/>
<small>{{ $voicemail->sip_from }}</small>
@endif
</td>
<td>
@if ($voicemail->url)
<audio class="oppose" controls src="{{ $voicemail->url }}"></audio>
<a type="button"
class="oppose btn tertiary"
href="{{ route('admin.account.file.delete', [$account, $voicemail->id]) }}">
<i class="ph ph-trash"></i>
</a>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="card large">
<a class="btn small oppose" href="{{ route('admin.account.dictionary.create', $account) }}">
<i class="ph ph-plus"></i>

View file

@ -0,0 +1,65 @@
@extends('layouts.main')
@section('breadcrumb')
@include('admin.parts.breadcrumb.accounts.show', ['account' => $account])
@endsection
@section('content')
<header>
<h1><i class="ph ph-phone"></i> {{ $account->identifier }}</h1>
</header>
@include('admin.account.parts.tabs')
<div class="grid">
<div class="card">
<h3>
{{ __('Voicemails') }}
</h3>
<table>
<thead>
<tr>
<th>{{ __('Created') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@if ($account->uploadedVoicemails->isEmpty())
<tr class="empty">
<td colspan="2">{{ __('Empty') }}</td>
</tr>
@endif
@foreach ($account->uploadedVoicemails as $voicemail)
<tr>
<td>
{{ $voicemail->created_at }}
@if ($voicemail->url)
<a style="margin-left: 1rem;" href="{{ $voicemail->download_url }}" download>
<i class="ph ph-download"></i>
</a>
@endif
@if ($voicemail->sip_from)
<br/>
<small>{{ $voicemail->sip_from }}</small>
@endif
</td>
<td>
@if ($voicemail->url)
<audio class="oppose" controls src="{{ $voicemail->url }}"></audio>
<a type="button"
class="oppose btn tertiary"
href="{{ route('admin.account.file.delete', [$account, $voicemail->id]) }}">
<i class="ph ph-trash"></i>
</a>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="card">
@include('admin.account.call_forwardings.edit', ['account' => $account])
</div>
</div>
@endsection

View file

@ -0,0 +1,46 @@
## Account Call Forwardings
### `GET /accounts/{id/me}/call_forwardings`
<span class="badge badge-info">User</span>
<span class="badge badge-warning">Admin</span>
Return the user Call Forwardings.
### `GET /accounts/{id/me}/call_forwardings/{call_forwarding_id}`
<span class="badge badge-info">User</span>
<span class="badge badge-warning">Admin</span>
Return a Call Forwarding configuration.
### `POST /accounts/{id/me}/call_forwardings`
<span class="badge badge-info">User</span>
<span class="badge badge-warning">Admin</span>
Create a new Call Forwarding configuration.
JSON parameters:
* `type` **required**, must be `always`, `away` or `busy`, one of each declaration maximum per account
* `forward_to` **required**, must be `sip_uri`, `voicemail` or `contact`
* `sip_uri` **required if `forward_to` is set to `sip_uri`**, must be a SIP URI, must be set when `forward_to` set to `sip_uri`
* `contact_id` **required if `forward_to` is set to `contact`**, must be a valid `contact_id` of the contact
* `enabled` **required**, boolean. If `type: always` is enabled `away` and `busy` must be disabled. If `type: away or busy` are enabled `always` must be disabled.
### `PUT /accounts/{id/me}/call_forwardings/{call_forwarding_id}`
<span class="badge badge-info">User</span>
<span class="badge badge-warning">Admin</span>
Create a new Call Forwarding configuration.
JSON parameters:
* `forward_to` **required**, must be `sip_uri`, `voicemail` or `contact`
* `sip_uri` **required if `forward_to` is set to `sip_uri`**, must be a SIP URI, must be set when `forward_to` set to `sip_uri`
* `contact_id` **required if `forward_to` is set to `contact`**, must be a valid `contact_id` of the contact
* `enabled` **required**, boolean. If `type: always` is enabled `away` and `busy` must be disabled. If `type: away or busy` are enabled `always` must be disabled.
### `DELETE /accounts/{id/me}/call_forwardings/{call_forwarding_id}`
<span class="badge badge-info">User</span>
<span class="badge badge-warning">Admin</span>
Remove a Call Forwarding configuration.

View file

@ -9,6 +9,19 @@
Returns `pong`
## SIP URI Resolving
### `GET /resolve/{sip}`
<span class="badge badge-warning">Admin</span>
Resolve a specific `SIP URI` in the space.
All the resolved object can be reached using direct API endpoints. This endpoint is only there as a shortcut and should be used sparingly.
Will return a JSON message with:
* `type` resolved API object `type`, can be `acccount`
* `payload` that contains the resolved object
@include('api.documentation.spaces')
@include('api.documentation.spaces.carddav')
@ -25,6 +38,8 @@ Returns `pong`
@include('api.documentation.accounts.contacts_lists')
@include('api.documentation.accounts.call_forwarding')
@include('api.documentation.accounts.contacts')
@include('api.documentation.accounts.dictionary')

View file

@ -1,5 +1,11 @@
@extends('errors::minimal')
@section('title', __('Service Unavailable'))
@section('code', '503')
@if (app()->isDownForMaintenance())
@section('title', __('We will be back soon!'))
@section('message', 'Sorry for the inconvenience but we are performing some maintenance at the moment.')
@else
@section('title', __('Service Unavailable'))
@section('message', $exception->getMessage())
@endif

View file

@ -1,4 +1,3 @@
@if (isset($errors) && isset($name) && count($errors->get($name)) > 0)
@foreach ($errors->get($name) as $error)
<small class="error">

View file

@ -20,6 +20,7 @@
use App\Http\Controllers\Api\Account\AccountController;
use App\Http\Controllers\Api\Account\ApiKeyController;
use App\Http\Controllers\Api\Account\AuthTokenController;
use App\Http\Controllers\Api\Account\CallForwardingController;
use App\Http\Controllers\Api\Account\ContactController;
use App\Http\Controllers\Api\Account\CreationRequestToken;
use App\Http\Controllers\Api\Account\CreationTokenController;
@ -33,6 +34,7 @@ use App\Http\Controllers\Api\Account\RecoveryTokenController;
use App\Http\Controllers\Api\Account\VcardsStorageController;
use App\Http\Controllers\Api\Account\VoicemailController;
use App\Http\Controllers\Api\Admin\Account\ActionController;
use App\Http\Controllers\Api\Admin\Account\CallForwardingController as AdminCallForwardingController;
use App\Http\Controllers\Api\Admin\Account\CardDavCredentialsController;
use App\Http\Controllers\Api\Admin\Account\ContactController as AdminContactController;
use App\Http\Controllers\Api\Admin\Account\CreationTokenController as AdminCreationTokenController;
@ -111,6 +113,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::apiResource('vcards-storage', VcardsStorageController::class);
Route::apiResource('voicemails', VoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]);
Route::apiResource('call_forwardings', CallForwardingController::class);
});
Route::group(['middleware' => ['auth.admin']], function () {
@ -134,6 +137,8 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::post('phone_countries/{code}/deactivate', [AdminPhoneCountryController::class, 'deactivate']);
});
Route::get('resolve/{sip}', [SpaceController::class, 'resolve']);
// Account creation token
Route::post('account_creation_tokens', [AdminCreationTokenController::class, 'create']);
@ -181,6 +186,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::apiResource('account_types', TypeController::class);
Route::apiResource('accounts/{id}/vcards-storage', AdminVcardsStorageController::class);
Route::apiResource('accounts/{id}/voicemails', AdminVoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]);
Route::apiResource('accounts/{id}/call_forwardings', AdminCallForwardingController::class);
Route::apiResource('contacts_lists', ContactsListController::class);
Route::prefix('contacts_lists')->controller(ContactsListController::class)->group(function () {

View file

@ -36,6 +36,7 @@ use App\Http\Controllers\Account\VcardsStorageController;
use App\Http\Controllers\Admin\Account\AccountTypeController;
use App\Http\Controllers\Admin\Account\ActionController;
use App\Http\Controllers\Admin\Account\ActivityController;
use App\Http\Controllers\Admin\Account\CallForwardingController;
use App\Http\Controllers\Admin\Account\CardDavCredentialsController;
use App\Http\Controllers\Admin\Account\ContactController;
use App\Http\Controllers\Admin\Account\DeviceController as AdminAccountDeviceController;
@ -43,6 +44,7 @@ use App\Http\Controllers\Admin\Account\DictionaryController;
use App\Http\Controllers\Admin\Account\FileController as AdminFileController;
use App\Http\Controllers\Admin\Account\ImportController;
use App\Http\Controllers\Admin\Account\StatisticsController as AdminAccountStatisticsController;
use App\Http\Controllers\Admin\Account\TelephonyController;
use App\Http\Controllers\Admin\Account\TypeController;
use App\Http\Controllers\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Admin\ApiKeyController as AdminApiKeyController;
@ -312,6 +314,14 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () {
Route::delete('/', 'destroy')->name('destroy');
});
Route::name('telephony.')->prefix('{account}/telephony')->controller(TelephonyController::class)->group(function () {
Route::get('/', 'show')->name('show');
});
Route::name('call_forwardings.')->prefix('{account}/call_forwardings')->controller(CallForwardingController::class)->group(function () {
Route::put('/', 'update')->name('update');
});
Route::name('device.')->prefix('{account}/devices')->controller(AdminAccountDeviceController::class)->group(function () {
Route::get('{device_id}/delete', 'delete')->name('delete');
Route::delete('/', 'destroy')->name('destroy');

View file

@ -0,0 +1,162 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2026 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Tests\Feature;
use App\Account;
use Tests\TestCase;
use function PHPUnit\Framework\assertJson;
class ApiAccountCallForwardingTest extends TestCase
{
protected $route = '/api/accounts/me/call_forwardings';
protected $method = 'POST';
public function testResolving()
{
$account = Account::factory()->create();
$account->generateUserApiKey();
$uri = 'sip:uri';
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'always',
'forward_to' => 'sip_uri',
'sip_uri' => $uri,
'enabled' => true
])
->assertStatus(201);
$admin = Account::factory()->admin()->create();
$admin->generateUserApiKey();
$this->keyAuthenticated($admin)
->get('/api/resolve/' . $account->identifier)
->assertStatus(200)
->assertJsonFragment(['type' => 'account']);
}
public function testCrud()
{
$account = Account::factory()->create();
$account->generateUserApiKey();
$admin = Account::factory()->admin()->create();
$admin->generateUserApiKey();
$uri = 'sip:uri';
// Contacts
$contactAccount = Account::factory()->create();
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'always',
'forward_to' => 'contact',
'enabled' => true
])
->assertJsonValidationErrors(['contact_id']);
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'always',
'forward_to' => 'contact',
'contact_id' => $contactAccount->id,
'enabled' => true
])
->assertJsonValidationErrors(['contact_id']);
$this->keyAuthenticated($admin)
->json($this->method, '/api/accounts/' . $account->id . '/contacts/' . $contactAccount->id)
->assertStatus(200);
$response = $this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'always',
'forward_to' => 'contact',
'contact_id' => $contactAccount->id,
'enabled' => true
])
->assertStatus(201);
// SIP URI
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'always',
'forward_to' => 'sip_uri',
'sip_uri' => null,
'enabled' => true
])
->assertJsonValidationErrors(['type']);
$this->keyAuthenticated($account)
->json('DELETE', $this->route . '/' . $response['id'])
->assertStatus(200);
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'always',
'forward_to' => 'sip_uri',
'sip_uri' => null,
'enabled' => true
])
->assertJsonValidationErrors(['sip_uri']);
$response = $this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'always',
'forward_to' => 'sip_uri',
'sip_uri' => $uri,
'enabled' => true
])
->assertStatus(201);
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'away',
'forward_to' => 'sip_uri',
'sip_uri' => $uri,
'enabled' => true
])
->assertJsonValidationErrors(['enabled']);
$this->keyAuthenticated($account)
->json('PUT', $this->route . '/' . $response->json()['id'], [
'type' => 'always',
'forward_to' => 'sip_uri',
'sip_uri' => $uri,
'enabled' => false
])
->assertStatus(200);
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'type' => 'away',
'forward_to' => 'sip_uri',
'sip_uri' => $uri,
'enabled' => true
])
->assertStatus(201);
$this->assertCount(2, $this->keyAuthenticated($account)
->json('GET', $this->route)->json());
}
}