update api register

This commit is contained in:
Wian Drs
2026-08-01 11:42:42 +07:00
parent b0d08211ec
commit 75342dc7b7
55 changed files with 2254 additions and 35 deletions
@@ -4,6 +4,7 @@ namespace App\Services\Notification\Adapters;
use App\Models\NotificationDelivery;
use App\Services\Notification\Contracts\NotificationChannelAdapter;
use App\Services\Notification\WhapiIdService;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
@@ -12,6 +13,11 @@ class WhatsAppUnofficialAdapter implements NotificationChannelAdapter
public function send(NotificationDelivery $delivery): array
{
$config = $delivery->channelConfig;
if ($config?->provider === 'whapi_id') {
$response = app(WhapiIdService::class)->send($config, $delivery->destination, $delivery->rendered_body);
return ['provider_message_id' => data_get($response, 'results.id_message'), 'response' => $response];
}
$credentials = $config?->credentials ?? [];
$settings = $config?->settings ?? [];
$url = $settings['send_url'] ?? null;
@@ -40,12 +40,17 @@ class NotificationManager
}
return DB::transaction(function () use ($event, $recipient, $subject, $payload, $occurrenceKey, $context, $tenantId) {
$recipientOccurrenceKey = Str::limit(
$occurrenceKey.'|'.$recipient->getMorphClass().'|'.$recipient->getKey(),
255,
''
);
$occurrence = NotificationEventOccurrence::firstOrCreate([
'tenant_id' => $tenantId,
'notification_event_id' => $event->id,
'subjectable_type' => $subject->getMorphClass(),
'subjectable_id' => $subject->getKey(),
'occurrence_key' => $occurrenceKey,
'occurrence_key' => $recipientOccurrenceKey,
]);
if (! $occurrence->wasRecentlyCreated) {
return null;
@@ -57,6 +62,20 @@ class NotificationManager
->where('enabled', true)
->orderBy('priority')
->get();
$systemConfigured = NotificationPreference::where('tenant_id', $tenantId)
->where('notification_event_id', $event->id)
->where('channel', 'system')
->exists();
if (! $systemConfigured && in_array('system', $event->available_channels ?? [], true)) {
$preferences->prepend(new NotificationPreference([
'tenant_id' => $tenantId,
'notification_event_id' => $event->id,
'channel' => 'system',
'enabled' => true,
'priority' => 1,
'send_delay_minutes' => 0,
]));
}
if ($preferences->isEmpty()) {
$occurrence->update(['processed_at' => now()]);
@@ -4,6 +4,7 @@ namespace App\Services\Notification;
use App\Jobs\ProcessBroadcast;
use App\Jobs\ProcessNotificationDelivery;
use App\Jobs\ProcessSystemNotificationCampaign;
use App\Models\Customer;
use App\Models\CustomerContact;
use App\Models\NotificationBroadcast;
@@ -14,6 +15,7 @@ use App\Models\NotificationEvent;
use App\Models\NotificationMessage;
use App\Models\NotificationPreference;
use App\Models\NotificationTemplate;
use App\Models\SystemNotificationCampaign;
use App\Models\TopologyLink;
use App\Models\User;
use App\Models\UserNotification;
@@ -32,29 +34,126 @@ class NotificationService
return NotificationChannel::with('tenant:id,tenant_code,tenant_name')
->where('channel', $channel)
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where(
fn ($scope) => $scope->where('tenant_id', $tenantId)->orWhereNull('tenant_id')
))
->orderByDesc('is_default')->orderBy('name')->get()
->each(fn ($item) => $item->setAttribute('credentials_configured', ! empty($item->getRawOriginal('credentials'))));
}
public function systemCampaigns(array $filters, User $actor, ?int $tenantId)
{
return SystemNotificationCampaign::with(['tenant:id,tenant_code,tenant_name', 'creator:id,name'])
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && ! empty($filters['tenant_id']), fn ($q) => $q->where('tenant_id', $filters['tenant_id']))
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
->latest('id')->paginate($filters['per_page'] ?? 10);
}
public function createSystemCampaign(array $data, User $actor, ?int $tenantId): SystemNotificationCampaign
{
$effectiveTenantId = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? null) : $tenantId;
if (! $actor->isMasterAdmin() && ! $effectiveTenantId) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant aktif wajib tersedia.']);
}
if (! $actor->isMasterAdmin() && ($data['target_scope'] ?? null) === 'all_users') {
throw ValidationException::withMessages(['target_scope' => 'Hanya Master Admin yang dapat mengirim ke semua user.']);
}
if (($data['target_scope'] ?? null) === 'selected_users' && empty($data['user_ids'])) {
throw ValidationException::withMessages(['user_ids' => 'Pilih minimal satu user penerima.']);
}
if ($effectiveTenantId && ! empty($data['user_ids'])) {
$validCount = User::whereIn('id', $data['user_ids'])
->whereHas('userTenants', fn ($q) => $q->where('tenant_id', $effectiveTenantId))->count();
if ($validCount !== count(array_unique($data['user_ids']))) {
throw ValidationException::withMessages(['user_ids' => 'Terdapat user di luar tenant aktif.']);
}
}
$campaign = SystemNotificationCampaign::create([
'uuid' => Str::uuid(),
'tenant_id' => $effectiveTenantId,
'created_by' => $actor->id,
'title' => $data['title'],
'body' => $data['body'],
'action_url' => $data['action_url'] ?? null,
'icon' => $data['icon'] ?? 'cil-bell',
'audience' => [
'target_scope' => $data['target_scope'],
'statuses' => $data['statuses'] ?? [],
'access_levels' => $data['access_levels'] ?? [],
'user_ids' => $data['user_ids'] ?? [],
],
]);
ProcessSystemNotificationCampaign::dispatch($campaign->id)->onQueue('system-notifications');
return $campaign;
}
public function systemAudienceOptions(User $actor, ?int $tenantId, ?int $requestedTenantId, ?string $search)
{
$effectiveTenantId = $actor->isMasterAdmin() ? $requestedTenantId : $tenantId;
return User::query()
->when($effectiveTenantId, fn ($q) => $q->whereHas('userTenants', fn ($x) => $x->where('tenant_id', $effectiveTenantId)))
->when($search, fn ($q, $term) => $q->where(fn ($x) => $x
->where('name', 'ILIKE', "%{$term}%")
->orWhere('username', 'ILIKE', "%{$term}%")
->orWhere('email', 'ILIKE', "%{$term}%")))
->with(['userTenants' => fn ($q) => $q->when($effectiveTenantId, fn ($x) => $x->where('tenant_id', $effectiveTenantId))])
->orderBy('name')->limit(50)->get(['id', 'name', 'username', 'email', 'access_level', 'status'])
->map(fn (User $user) => [
...$user->only(['id', 'name', 'username', 'email', 'status']),
'access_level' => $user->access_level->value,
'tenant_role' => $user->userTenants->first()?->role,
]);
}
public function saveChannel(array $data, User $actor, ?int $tenantId, ?NotificationChannel $channel = null): NotificationChannel
{
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $channel?->tenant_id ?? $tenantId) : $tenantId;
if (! $effectiveTenant) {
$effectiveTenant = $actor->isMasterAdmin()
? (array_key_exists('tenant_id', $data) ? $data['tenant_id'] : ($channel ? $channel->tenant_id : $tenantId))
: $tenantId;
$isGlobalWhapi = $actor->isMasterAdmin()
&& ($data['channel'] ?? $channel?->channel) === 'whatsapp_unofficial'
&& ! $effectiveTenant;
if (! $effectiveTenant && ! $isGlobalWhapi) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
}
if ($channel && (int) $channel->tenant_id !== (int) $effectiveTenant) {
if ($channel && $channel->tenant_id !== null && (int) $channel->tenant_id !== (int) $effectiveTenant) {
throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi bukan milik tenant tersebut.']);
}
if ($channel && $channel->tenant_id === null && ! $isGlobalWhapi) {
throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi global hanya dapat digunakan untuk WHAPI Master Admin.']);
}
$credentials = array_filter($data['credentials'] ?? [], fn ($value) => $value !== '' && $value !== null);
if ($channel && $credentials) {
$credentials = [...($channel->credentials ?? []), ...$credentials];
}
if (($data['is_default'] ?? false) === true) {
NotificationChannel::where('tenant_id', $effectiveTenant)->where('channel', $data['channel'])
NotificationChannel::where('channel', $data['channel'])
->when($effectiveTenant, fn ($q) => $q->where('tenant_id', $effectiveTenant), fn ($q) => $q->whereNull('tenant_id'))
->when($channel, fn ($q) => $q->whereKeyNot($channel->id))
->update(['is_default' => false]);
}
if ($data['channel'] === 'whatsapp_unofficial') {
$data['provider'] = 'whapi_id';
$apiKey = $credentials['api_key'] ?? data_get($channel?->credentials, 'api_key');
$baseUrl = data_get($data, 'settings.base_url', data_get($channel?->settings, 'base_url'));
if (! $apiKey || ! $baseUrl) {
throw ValidationException::withMessages([
'credentials' => 'API Key perangkat dan Base URL WHAPI wajib diisi.',
]);
}
if ($isGlobalWhapi && NotificationChannel::whereNull('tenant_id')
->where('channel', 'whatsapp_unofficial')
->when($channel, fn ($q) => $q->whereKeyNot($channel->id))
->exists()) {
throw ValidationException::withMessages([
'channel' => 'Konfigurasi WHAPI global sudah tersedia. Silakan perbarui konfigurasi tersebut.',
]);
}
}
$payload = [
...$data,
'tenant_id' => $effectiveTenant,
@@ -72,6 +171,16 @@ class NotificationService
->setAttribute('credentials_configured', ! empty($channel->getRawOriginal('credentials')));
}
public function deleteChannel(NotificationChannel $channel): void
{
if ($channel->templates()->exists() || $channel->deliveries()->exists()) {
$channel->update(['enabled' => false, 'is_default' => false]);
return;
}
$channel->delete();
}
public function templates(string $channel, User $actor, ?int $tenantId)
{
return NotificationTemplate::with(['event:id,code,name,category,variables', 'channelConfig:id,name'])
@@ -170,7 +279,9 @@ class NotificationService
'system' => 'Penyimpanan notifikasi aplikasi siap.',
'telegram' => data_get(Http::timeout(15)->get('https://api.telegram.org/bot'.$credentials['bot_token'].'/getMe')->throw()->json(), 'result.username', 'Bot Telegram terhubung.'),
'whatsapp_official' => data_get(Http::withToken($credentials['access_token'])->timeout(15)->get(rtrim($settings['base_url'] ?? 'https://graph.facebook.com/v22.0', '/').'/'.$credentials['phone_number_id'])->throw()->json(), 'display_phone_number', 'WhatsApp Official terhubung.'),
'whatsapp_unofficial' => data_get(Http::withHeaders(isset($credentials['api_key']) ? [($settings['api_key_header'] ?? 'X-API-Key') => $credentials['api_key']] : [])->timeout(15)->get($settings['health_url'])->throw()->json(), 'status', 'Gateway terhubung.'),
'whatsapp_unofficial' => $channel->provider === 'whapi_id'
? data_get(app(WhapiIdService::class)->state($channel), 'results.state', 'WHAPI terhubung.')
: data_get(Http::withHeaders(isset($credentials['api_key']) ? [($settings['api_key_header'] ?? 'X-API-Key') => $credentials['api_key']] : [])->timeout(15)->get($settings['health_url'])->throw()->json(), 'status', 'Gateway terhubung.'),
'email' => ! empty($settings['host']) && ! empty($settings['from_address']) ? 'Konfigurasi SMTP lengkap.' : throw new \RuntimeException('Host dan alamat pengirim SMTP wajib diisi.'),
};
$channel->update(['last_health_status' => 'healthy', 'last_health_message' => (string) $message, 'last_health_check_at' => now()]);
@@ -0,0 +1,93 @@
<?php
namespace App\Services\Notification;
use App\Models\NotificationChannel;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class WhapiIdService
{
public function state(NotificationChannel $channel): array
{
return $this->get($channel, '/api/getState');
}
public function start(NotificationChannel $channel): array
{
return $this->get($channel, '/api/serviceStart');
}
public function qr(NotificationChannel $channel): array
{
return $this->get($channel, '/api/getQR');
}
public function send(NotificationChannel $channel, string $phone, string $message): array
{
try {
return $this->client($channel)->asForm()->post('/api/sendMessage', [
...$this->auth($channel), 'phone' => $this->normalizePhone($phone),
'message' => $message, 'forward_type' => 0, 'ephemeral' => 0,
])->throw()->json();
} catch (RequestException|ConnectionException $exception) {
$this->providerError($exception);
}
}
private function client(NotificationChannel $channel): PendingRequest
{
$baseUrl = rtrim((string) data_get($channel->settings, 'base_url'), '/');
if (! $baseUrl || ! preg_match('#^https?://#i', $baseUrl)) {
throw ValidationException::withMessages(['base_url' => 'Base URL WHAPI wajib menggunakan HTTP atau HTTPS.']);
}
$baseUrl = preg_replace('#/api$#i', '', $baseUrl);
return Http::baseUrl($baseUrl)->acceptJson()->timeout(30)->retry(2, 500);
}
private function get(NotificationChannel $channel, string $path): array
{
try {
return $this->client($channel)->get($path, $this->auth($channel))->throw()->json();
} catch (RequestException|ConnectionException $exception) {
$this->providerError($exception);
}
}
private function providerError(RequestException|ConnectionException $exception): never
{
$status = $exception instanceof RequestException ? $exception->response->status() : null;
$providerMessage = $exception instanceof RequestException
? data_get($exception->response->json(), 'results.message')
?? data_get($exception->response->json(), 'message')
: null;
throw ValidationException::withMessages([
'whapi' => $providerMessage
?: ($status ? "WHAPI merespons HTTP {$status}. Periksa Base URL dan endpoint gateway." : 'Server WHAPI tidak dapat dihubungi.'),
]);
}
private function auth(NotificationChannel $channel): array
{
$apiKey = data_get($channel->credentials, 'api_key');
if (! $apiKey) {
throw ValidationException::withMessages(['api_key' => 'API Key perangkat WHAPI belum diatur.']);
}
return ['apiKey' => $apiKey];
}
private function normalizePhone(string $phone): string
{
$phone = preg_replace('/\D+/', '', $phone);
if (str_starts_with($phone, '0')) {
$phone = '62'.substr($phone, 1);
}
return $phone;
}
}