Giant Update
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EmailAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$config = $delivery->channelConfig;
|
||||
$credentials = $config?->credentials ?? [];
|
||||
$settings = $config?->settings ?? [];
|
||||
if (! $delivery->destination || empty($settings['host'])) {
|
||||
throw ValidationException::withMessages(['channel' => 'Alamat penerima atau konfigurasi SMTP belum lengkap.']);
|
||||
}
|
||||
$original = Config::get('mail.mailers.smtp');
|
||||
$originalFrom = Config::get('mail.from');
|
||||
try {
|
||||
Config::set('mail.mailers.smtp', [
|
||||
'transport' => 'smtp',
|
||||
'host' => $settings['host'],
|
||||
'port' => $settings['port'] ?? 587,
|
||||
'encryption' => $settings['encryption'] ?? 'tls',
|
||||
'username' => $credentials['username'] ?? null,
|
||||
'password' => $credentials['password'] ?? null,
|
||||
'timeout' => 30,
|
||||
]);
|
||||
Config::set('mail.from', [
|
||||
'address' => $settings['from_address'],
|
||||
'name' => $settings['from_name'] ?? null,
|
||||
]);
|
||||
Mail::purge('smtp');
|
||||
Mail::raw($delivery->rendered_body, function ($mail) use ($delivery) {
|
||||
$mail->to($delivery->destination)->subject($delivery->rendered_subject ?: 'Notifikasi');
|
||||
});
|
||||
} finally {
|
||||
Config::set('mail.mailers.smtp', $original);
|
||||
Config::set('mail.from', $originalFrom);
|
||||
Mail::purge('smtp');
|
||||
}
|
||||
|
||||
return ['provider_message_id' => (string) Str::uuid(), 'response' => ['accepted' => true]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SystemAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$message = $delivery->message;
|
||||
$user = $message->recipient instanceof User
|
||||
? $message->recipient
|
||||
: ($message->recipient?->user ?? null);
|
||||
if (! $user) {
|
||||
throw ValidationException::withMessages(['recipient' => 'Penerima tidak memiliki akun aplikasi.']);
|
||||
}
|
||||
|
||||
$notification = UserNotification::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'tenant_id' => $message->tenant_id,
|
||||
'user_id' => $user->id,
|
||||
'notification_message_id' => $message->id,
|
||||
'title' => $delivery->rendered_subject ?: 'Notifikasi',
|
||||
'body' => $delivery->rendered_body,
|
||||
'action_url' => data_get($message->context, 'action_url'),
|
||||
'icon' => data_get($message->context, 'icon', 'cil-bell'),
|
||||
]);
|
||||
$settings = NotificationChannel::where('tenant_id', $message->tenant_id)
|
||||
->where('channel', 'system')->where('enabled', true)->where('is_default', true)
|
||||
->first()?->settings ?? [];
|
||||
$maximum = max(20, (int) ($settings['maximum_notifications_per_user'] ?? 500));
|
||||
UserNotification::where('user_id', $user->id)
|
||||
->whereNotIn('id', UserNotification::where('user_id', $user->id)->latest('id')->limit($maximum)->pluck('id'))
|
||||
->delete();
|
||||
|
||||
return ['provider_message_id' => $notification->uuid, 'response' => ['stored' => true]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TelegramAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$token = data_get($delivery->channelConfig?->credentials, 'bot_token');
|
||||
if (! $token) {
|
||||
throw ValidationException::withMessages(['channel' => 'Bot token Telegram belum diatur.']);
|
||||
}
|
||||
$response = Http::timeout(30)->post("https://api.telegram.org/bot{$token}/sendMessage", [
|
||||
'chat_id' => $delivery->destination,
|
||||
'text' => $delivery->rendered_body,
|
||||
'parse_mode' => data_get($delivery->channelConfig?->settings, 'parse_mode'),
|
||||
])->throw()->json();
|
||||
|
||||
return ['provider_message_id' => (string) data_get($response, 'result.message_id'), 'response' => $response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class WhatsAppOfficialAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$config = $delivery->channelConfig;
|
||||
$credentials = $config?->credentials ?? [];
|
||||
$settings = $config?->settings ?? [];
|
||||
$phoneId = $credentials['phone_number_id'] ?? null;
|
||||
$token = $credentials['access_token'] ?? null;
|
||||
$templateName = data_get($delivery->provider_response, 'request_context.provider_template_name');
|
||||
if (! $phoneId || ! $token || ! $templateName) {
|
||||
throw ValidationException::withMessages(['channel' => 'Phone Number ID, access token, dan template WhatsApp Official wajib tersedia.']);
|
||||
}
|
||||
$baseUrl = rtrim($settings['base_url'] ?? 'https://graph.facebook.com/v22.0', '/');
|
||||
$parameters = collect(data_get($delivery->provider_response, 'request_context.template_parameters', []))
|
||||
->map(fn ($value) => ['type' => 'text', 'text' => (string) $value])->values()->all();
|
||||
$response = Http::withToken($token)->timeout(30)->post("{$baseUrl}/{$phoneId}/messages", [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'to' => $delivery->destination,
|
||||
'type' => 'template',
|
||||
'template' => [
|
||||
'name' => $templateName,
|
||||
'language' => ['code' => data_get($delivery->provider_response, 'request_context.language', 'id')],
|
||||
'components' => $parameters ? [['type' => 'body', 'parameters' => $parameters]] : [],
|
||||
],
|
||||
])->throw()->json();
|
||||
|
||||
return ['provider_message_id' => data_get($response, 'messages.0.id'), 'response' => $response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class WhatsAppUnofficialAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$config = $delivery->channelConfig;
|
||||
$credentials = $config?->credentials ?? [];
|
||||
$settings = $config?->settings ?? [];
|
||||
$url = $settings['send_url'] ?? null;
|
||||
if (! $url) {
|
||||
throw ValidationException::withMessages(['channel' => 'URL pengiriman gateway unofficial belum diatur.']);
|
||||
}
|
||||
$headers = [];
|
||||
if ($apiKey = $credentials['api_key'] ?? null) {
|
||||
$headers[$settings['api_key_header'] ?? 'X-API-Key'] = $apiKey;
|
||||
}
|
||||
$payload = [
|
||||
$settings['destination_field'] ?? 'number' => $delivery->destination,
|
||||
$settings['message_field'] ?? 'message' => $delivery->rendered_body,
|
||||
'instance_id' => $credentials['instance_id'] ?? null,
|
||||
];
|
||||
$response = Http::withHeaders($headers)->timeout(30)->post($url, $payload)->throw()->json();
|
||||
|
||||
return ['provider_message_id' => data_get($response, $settings['message_id_path'] ?? 'id'), 'response' => $response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Contracts;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
|
||||
interface NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification;
|
||||
|
||||
use App\Jobs\ProcessNotificationDelivery;
|
||||
use App\Models\Customer;
|
||||
use App\Models\NotificationBroadcastRecipient;
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Models\NotificationEvent;
|
||||
use App\Models\NotificationEventOccurrence;
|
||||
use App\Models\NotificationMessage;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\User;
|
||||
use App\Services\Notification\Adapters\EmailAdapter;
|
||||
use App\Services\Notification\Adapters\SystemAdapter;
|
||||
use App\Services\Notification\Adapters\TelegramAdapter;
|
||||
use App\Services\Notification\Adapters\WhatsAppOfficialAdapter;
|
||||
use App\Services\Notification\Adapters\WhatsAppUnofficialAdapter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NotificationManager
|
||||
{
|
||||
public function emit(
|
||||
string $eventCode,
|
||||
Model $recipient,
|
||||
Model $subject,
|
||||
array $payload,
|
||||
string $occurrenceKey,
|
||||
array $context = [],
|
||||
): ?NotificationMessage {
|
||||
$event = NotificationEvent::where('code', $eventCode)->where('enabled', true)->first();
|
||||
$tenantId = (int) ($recipient->tenant_id ?? $subject->tenant_id);
|
||||
if (! $event || ! $tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($event, $recipient, $subject, $payload, $occurrenceKey, $context, $tenantId) {
|
||||
$occurrence = NotificationEventOccurrence::firstOrCreate([
|
||||
'tenant_id' => $tenantId,
|
||||
'notification_event_id' => $event->id,
|
||||
'subjectable_type' => $subject->getMorphClass(),
|
||||
'subjectable_id' => $subject->getKey(),
|
||||
'occurrence_key' => $occurrenceKey,
|
||||
]);
|
||||
if (! $occurrence->wasRecentlyCreated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$preferences = NotificationPreference::with('channelConfig')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('notification_event_id', $event->id)
|
||||
->where('enabled', true)
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
if ($preferences->isEmpty()) {
|
||||
$occurrence->update(['processed_at' => now()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = NotificationMessage::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'tenant_id' => $tenantId,
|
||||
'notification_event_id' => $event->id,
|
||||
'recipient_type' => $recipient->getMorphClass(),
|
||||
'recipient_id' => $recipient->getKey(),
|
||||
'subjectable_type' => $subject->getMorphClass(),
|
||||
'subjectable_id' => $subject->getKey(),
|
||||
'payload' => $payload,
|
||||
'context' => $context,
|
||||
'priority' => 5,
|
||||
'status' => 'queued',
|
||||
]);
|
||||
|
||||
foreach ($preferences as $preference) {
|
||||
$template = $this->template($tenantId, $event->id, $preference->channel);
|
||||
$channelConfig = $preference->channelConfig
|
||||
?: NotificationChannel::where('tenant_id', $tenantId)->where('channel', $preference->channel)->where('enabled', true)->where('is_default', true)->first();
|
||||
$destination = $this->destination($recipient, $preference->channel);
|
||||
if (! $template || ($preference->channel !== 'system' && ! $channelConfig) || ! $destination) {
|
||||
NotificationDelivery::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'notification_message_id' => $message->id,
|
||||
'notification_channel_id' => $channelConfig?->id,
|
||||
'channel' => $preference->channel,
|
||||
'destination' => $destination,
|
||||
'rendered_subject' => $template?->subject,
|
||||
'rendered_body' => $template?->body ?? '',
|
||||
'status' => 'skipped',
|
||||
'error_message' => 'Template, channel, atau tujuan penerima belum tersedia.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$delivery = NotificationDelivery::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'notification_message_id' => $message->id,
|
||||
'notification_channel_id' => $channelConfig?->id,
|
||||
'channel' => $preference->channel,
|
||||
'destination' => $destination,
|
||||
'rendered_subject' => $this->render($template->subject, $payload),
|
||||
'rendered_body' => $this->render($template->body, $payload),
|
||||
'status' => 'queued',
|
||||
'queued_at' => now(),
|
||||
'provider_response' => [
|
||||
'request_context' => [
|
||||
'provider_template_name' => $template->provider_template_name,
|
||||
'language' => $template->language,
|
||||
'template_parameters' => collect($template->variables ?? [])->map(fn ($key) => data_get($payload, $key))->values()->all(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
$scheduledAt = $this->scheduledAt($preference);
|
||||
$message->update([
|
||||
'notification_template_id' => $message->notification_template_id ?: $template->id,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
]);
|
||||
ProcessNotificationDelivery::dispatch($delivery->id)
|
||||
->delay($scheduledAt)
|
||||
->onQueue('notifications-'.$this->queueName($preference->channel))
|
||||
->afterCommit();
|
||||
}
|
||||
$occurrence->update(['processed_at' => now()]);
|
||||
|
||||
return $message->load('deliveries');
|
||||
});
|
||||
}
|
||||
|
||||
public function deliver(NotificationDelivery $delivery): void
|
||||
{
|
||||
$delivery->load(['message.recipient', 'channelConfig']);
|
||||
$delivery->update(['status' => 'processing', 'attempt_count' => $delivery->attempt_count + 1]);
|
||||
try {
|
||||
$result = $this->adapter($delivery->channel)->send($delivery);
|
||||
$delivery->update([
|
||||
'status' => $delivery->channel === 'system' ? 'delivered' : 'sent',
|
||||
'provider_message_id' => $result['provider_message_id'] ?? null,
|
||||
'provider_response' => $result['response'] ?? null,
|
||||
'sent_at' => now(),
|
||||
'delivered_at' => $delivery->channel === 'system' ? now() : null,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
$delivery->update([
|
||||
'status' => 'failed',
|
||||
'failed_at' => now(),
|
||||
'next_retry_at' => now()->addMinutes(min(60, 2 ** $delivery->attempt_count)),
|
||||
'error_code' => (string) $exception->getCode(),
|
||||
'error_message' => Str::limit($exception->getMessage(), 2000),
|
||||
]);
|
||||
throw $exception;
|
||||
} finally {
|
||||
$this->refreshMessageStatus($delivery->message);
|
||||
$this->refreshBroadcast($delivery->message);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(?string $template, array $payload): ?string
|
||||
{
|
||||
if ($template === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{\s*([a-zA-Z0-9_.]+)\s*}}/',
|
||||
function ($matches) use ($payload) {
|
||||
$value = data_get($payload, $matches[1]);
|
||||
if ($value === null && $matches[1] === 'tenant.name') {
|
||||
$value = data_get($payload, 'tenant.tenant_name');
|
||||
}
|
||||
|
||||
return (string) ($value ?? '');
|
||||
},
|
||||
$template,
|
||||
);
|
||||
}
|
||||
|
||||
public function destination(Model $recipient, string $channel, bool $broadcast = false): ?string
|
||||
{
|
||||
if ($channel === 'system') {
|
||||
return (string) ($recipient instanceof User ? $recipient->id : $recipient->user_id);
|
||||
}
|
||||
if (! $recipient instanceof Customer) {
|
||||
return $channel === 'email' ? $recipient->email : null;
|
||||
}
|
||||
$type = match ($channel) {
|
||||
'whatsapp_official', 'whatsapp_unofficial' => 'whatsapp',
|
||||
'email' => 'email',
|
||||
'telegram' => 'telegram',
|
||||
};
|
||||
$contact = $recipient->contacts()
|
||||
->where('type', $type)
|
||||
->whereNull('opted_out_at')
|
||||
->when($broadcast, fn ($q) => $q->where('can_receive_broadcast', true))
|
||||
->orderByDesc('is_primary')
|
||||
->first();
|
||||
if ($contact) {
|
||||
return $contact->value;
|
||||
}
|
||||
|
||||
return match ($channel) {
|
||||
'whatsapp_official', 'whatsapp_unofficial' => $broadcast ? null : $recipient->whatsapp_number,
|
||||
'email' => $broadcast ? null : $recipient->email,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function template(int $tenantId, int $eventId, string $channel): ?NotificationTemplate
|
||||
{
|
||||
return NotificationTemplate::where('notification_event_id', $eventId)
|
||||
->where('channel', $channel)
|
||||
->where('enabled', true)
|
||||
->when($channel === 'whatsapp_official', fn ($q) => $q->where('approval_status', 'approved')->whereNotNull('provider_template_name'))
|
||||
->where(fn ($q) => $q->where('tenant_id', $tenantId)->orWhereNull('tenant_id'))
|
||||
->orderByRaw('tenant_id IS NULL')
|
||||
->orderByDesc('version')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function adapter(string $channel)
|
||||
{
|
||||
return match ($channel) {
|
||||
'system' => app(SystemAdapter::class),
|
||||
'whatsapp_official' => app(WhatsAppOfficialAdapter::class),
|
||||
'whatsapp_unofficial' => app(WhatsAppUnofficialAdapter::class),
|
||||
'email' => app(EmailAdapter::class),
|
||||
'telegram' => app(TelegramAdapter::class),
|
||||
};
|
||||
}
|
||||
|
||||
private function queueName(string $channel): string
|
||||
{
|
||||
return str_starts_with($channel, 'whatsapp') ? 'whatsapp' : $channel;
|
||||
}
|
||||
|
||||
private function scheduledAt(NotificationPreference $preference): Carbon
|
||||
{
|
||||
$scheduled = now()->addMinutes($preference->send_delay_minutes);
|
||||
if (! $preference->quiet_hours_start || ! $preference->quiet_hours_end) {
|
||||
return $scheduled;
|
||||
}
|
||||
$start = $scheduled->copy()->setTimeFromTimeString($preference->quiet_hours_start);
|
||||
$end = $scheduled->copy()->setTimeFromTimeString($preference->quiet_hours_end);
|
||||
if ($end->lte($start)) {
|
||||
$end->addDay();
|
||||
if ($scheduled->lt($start)) {
|
||||
$start->subDay();
|
||||
}
|
||||
}
|
||||
|
||||
return $scheduled->between($start, $end) ? $end : $scheduled;
|
||||
}
|
||||
|
||||
private function refreshMessageStatus(NotificationMessage $message): void
|
||||
{
|
||||
$statuses = $message->deliveries()->pluck('status');
|
||||
$status = $statuses->contains(fn ($value) => in_array($value, ['queued', 'processing', 'pending'], true))
|
||||
? 'processing'
|
||||
: ($statuses->contains(fn ($value) => in_array($value, ['sent', 'delivered', 'read'], true)) ? 'sent' : 'failed');
|
||||
$message->update(['status' => $status]);
|
||||
}
|
||||
|
||||
private function refreshBroadcast(NotificationMessage $message): void
|
||||
{
|
||||
if (! $message->notification_broadcast_id) {
|
||||
return;
|
||||
}
|
||||
$recipient = NotificationBroadcastRecipient::where('notification_message_id', $message->id)->first();
|
||||
$messageStatus = $message->fresh()->status;
|
||||
if ($recipient) {
|
||||
$recipient->update([
|
||||
'status' => $messageStatus === 'sent' ? 'sent' : ($messageStatus === 'failed' ? 'failed' : 'queued'),
|
||||
]);
|
||||
}
|
||||
$broadcast = $message->broadcast;
|
||||
if (! $broadcast) {
|
||||
return;
|
||||
}
|
||||
$pending = $broadcast->recipients()->whereIn('status', ['pending', 'queued'])->count();
|
||||
$broadcast->update([
|
||||
'processed_recipients' => $broadcast->recipients()->count(),
|
||||
'success_count' => $broadcast->recipients()->where('status', 'sent')->count(),
|
||||
'failed_count' => $broadcast->recipients()->whereIn('status', ['failed', 'skipped'])->count(),
|
||||
'status' => $pending === 0 ? 'completed' : 'processing',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification;
|
||||
|
||||
use App\Jobs\ProcessBroadcast;
|
||||
use App\Jobs\ProcessNotificationDelivery;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerContact;
|
||||
use App\Models\NotificationBroadcast;
|
||||
use App\Models\NotificationBroadcastRecipient;
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Models\NotificationEvent;
|
||||
use App\Models\NotificationMessage;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\TopologyLink;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
public function __construct(protected NotificationManager $manager) {}
|
||||
|
||||
public function channels(string $channel, User $actor, ?int $tenantId)
|
||||
{
|
||||
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))
|
||||
->orderByDesc('is_default')->orderBy('name')->get()
|
||||
->each(fn ($item) => $item->setAttribute('credentials_configured', ! empty($item->getRawOriginal('credentials'))));
|
||||
}
|
||||
|
||||
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) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
if ($channel && (int) $channel->tenant_id !== (int) $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi bukan milik tenant tersebut.']);
|
||||
}
|
||||
$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'])
|
||||
->when($channel, fn ($q) => $q->whereKeyNot($channel->id))
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
$payload = [
|
||||
...$data,
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'provider' => $data['provider'] ?? $data['channel'],
|
||||
'settings' => $data['settings'] ?? [],
|
||||
];
|
||||
if ($credentials) {
|
||||
$payload['credentials'] = $credentials;
|
||||
} else {
|
||||
unset($payload['credentials']);
|
||||
}
|
||||
$channel ? $channel->update($payload) : $channel = NotificationChannel::create($payload);
|
||||
|
||||
return $channel->fresh('tenant:id,tenant_code,tenant_name')
|
||||
->setAttribute('credentials_configured', ! empty($channel->getRawOriginal('credentials')));
|
||||
}
|
||||
|
||||
public function templates(string $channel, User $actor, ?int $tenantId)
|
||||
{
|
||||
return NotificationTemplate::with(['event:id,code,name,category,variables', 'channelConfig:id,name'])
|
||||
->where('channel', $channel)
|
||||
->where(function ($q) use ($actor, $tenantId) {
|
||||
$q->whereNull('tenant_id')
|
||||
->when(! $actor->isMasterAdmin() || $tenantId, fn ($x) => $x->orWhere('tenant_id', $tenantId));
|
||||
})
|
||||
->orderBy('notification_event_id')->orderByDesc('tenant_id')->orderByDesc('version')->get();
|
||||
}
|
||||
|
||||
public function saveTemplate(array $data, User $actor, ?int $tenantId, ?NotificationTemplate $template = null): NotificationTemplate
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $template?->tenant_id ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih untuk membuat override template.']);
|
||||
}
|
||||
if ($template?->tenant_id === null) {
|
||||
$template = null;
|
||||
}
|
||||
$variables = $this->extractVariables(($data['subject'] ?? '').' '.($data['body'] ?? ''));
|
||||
$payload = [...$data, 'tenant_id' => $effectiveTenant, 'variables' => $variables];
|
||||
if (! $template) {
|
||||
$payload['version'] = ((int) NotificationTemplate::where('tenant_id', $effectiveTenant)
|
||||
->where('notification_event_id', $data['notification_event_id'])
|
||||
->where('channel', $data['channel'])->max('version')) + 1;
|
||||
$template = NotificationTemplate::create($payload);
|
||||
} else {
|
||||
$template->update($payload);
|
||||
}
|
||||
|
||||
return $template->fresh(['event:id,code,name,category,variables', 'channelConfig:id,name']);
|
||||
}
|
||||
|
||||
public function preferences(User $actor, ?int $tenantId): array
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? $tenantId : $tenantId;
|
||||
|
||||
return [
|
||||
'events' => NotificationEvent::where('enabled', true)->orderBy('category')->orderBy('name')->get(),
|
||||
'preferences' => $effectiveTenant
|
||||
? NotificationPreference::with(['event:id,code,name', 'channelConfig:id,name'])->where('tenant_id', $effectiveTenant)->get()
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
public function savePreferences(array $rows, User $actor, ?int $tenantId): void
|
||||
{
|
||||
DB::transaction(function () use ($rows, $actor, $tenantId) {
|
||||
foreach ($rows as $row) {
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($row['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
if (! empty($row['notification_channel_id']) && ! NotificationChannel::whereKey($row['notification_channel_id'])
|
||||
->where('tenant_id', $effectiveTenant)->where('channel', $row['channel'])->exists()) {
|
||||
throw ValidationException::withMessages(['notification_channel_id' => 'Konfigurasi channel bukan milik tenant atau channel tersebut.']);
|
||||
}
|
||||
NotificationPreference::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'notification_event_id' => $row['notification_event_id'],
|
||||
'channel' => $row['channel'],
|
||||
],
|
||||
[
|
||||
'notification_channel_id' => $row['notification_channel_id'] ?? null,
|
||||
'enabled' => $row['enabled'] ?? false,
|
||||
'priority' => $row['priority'] ?? 1,
|
||||
'send_delay_minutes' => $row['send_delay_minutes'] ?? 0,
|
||||
'quiet_hours_start' => $row['quiet_hours_start'] ?? null,
|
||||
'quiet_hours_end' => $row['quiet_hours_end'] ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function deliveries(string $channel, array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
return NotificationDelivery::with(['message:id,tenant_id,uuid,notification_event_id,recipient_type,recipient_id,created_at', 'message.event:id,code,name', 'channelConfig:id,name'])
|
||||
->where('channel', $channel)
|
||||
->whereHas('message', fn ($q) => $q
|
||||
->when(! $actor->isMasterAdmin(), fn ($x) => $x->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($x) => $x->where('tenant_id', $tenantId)))
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where(fn ($x) => $x->where('destination', 'ILIKE', "%{$filters['search']}%")->orWhere('rendered_body', 'ILIKE', "%{$filters['search']}%")))
|
||||
->orderByDesc('id')->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function testChannel(NotificationChannel $channel): NotificationChannel
|
||||
{
|
||||
try {
|
||||
$credentials = $channel->credentials ?? [];
|
||||
$settings = $channel->settings ?? [];
|
||||
$message = match ($channel->channel) {
|
||||
'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.'),
|
||||
'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()]);
|
||||
} catch (\Throwable $exception) {
|
||||
$channel->update(['last_health_status' => 'unhealthy', 'last_health_message' => Str::limit($exception->getMessage(), 1000), 'last_health_check_at' => now()]);
|
||||
}
|
||||
|
||||
return $channel->fresh();
|
||||
}
|
||||
|
||||
public function broadcasts(array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
return NotificationBroadcast::with(['tenant:id,tenant_code,tenant_name', 'template:id,name', 'channelConfig:id,name'])
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where('name', 'ILIKE', "%{$filters['search']}%"))
|
||||
->orderByDesc('id')->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function previewBroadcast(array $data, User $actor, ?int $tenantId): array
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
$query = $this->recipientQuery($effectiveTenant, $data['filters'] ?? []);
|
||||
$estimated = (clone $query)->count();
|
||||
$sample = $query->limit(10)->get(['id', 'customer_code', 'name', 'email', 'whatsapp_number']);
|
||||
|
||||
return ['estimated_recipients' => $estimated, 'sample' => $sample];
|
||||
}
|
||||
|
||||
public function createBroadcast(array $data, User $actor, ?int $tenantId): NotificationBroadcast
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
$channelConfig = NotificationChannel::whereKey($data['notification_channel_id'] ?? null)
|
||||
->where('tenant_id', $effectiveTenant)->where('channel', $data['channel'])->where('enabled', true)->first();
|
||||
if ($data['channel'] !== 'system' && ! $channelConfig) {
|
||||
throw ValidationException::withMessages(['notification_channel_id' => 'Konfigurasi channel aktif wajib dipilih.']);
|
||||
}
|
||||
$template = null;
|
||||
if (! empty($data['notification_template_id'])) {
|
||||
$template = NotificationTemplate::whereKey($data['notification_template_id'])
|
||||
->where('channel', $data['channel'])
|
||||
->where('enabled', true)
|
||||
->where(fn ($q) => $q->whereNull('tenant_id')->orWhere('tenant_id', $effectiveTenant))
|
||||
->first();
|
||||
if (! $template) {
|
||||
throw ValidationException::withMessages(['notification_template_id' => 'Template tidak tersedia untuk tenant dan channel ini.']);
|
||||
}
|
||||
}
|
||||
if ($data['channel'] === 'whatsapp_official'
|
||||
&& (! $template || $template->approval_status !== 'approved' || ! $template->provider_template_name)) {
|
||||
throw ValidationException::withMessages(['notification_template_id' => 'WhatsApp Official wajib memakai template provider berstatus approved.']);
|
||||
}
|
||||
$estimated = $this->recipientQuery($effectiveTenant, $data['filters'] ?? [])->count();
|
||||
$filters = $data['filters'] ?? [];
|
||||
unset($data['filters']);
|
||||
$requiresApproval = $estimated >= 1000;
|
||||
$broadcast = NotificationBroadcast::create([
|
||||
...$data,
|
||||
'uuid' => Str::uuid(),
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'filter_snapshot' => $filters,
|
||||
'estimated_recipients' => $estimated,
|
||||
'status' => $requiresApproval
|
||||
? 'pending_approval'
|
||||
: (! empty($data['scheduled_at']) && now()->lt($data['scheduled_at']) ? 'scheduled' : 'queued'),
|
||||
'created_by' => $actor->id,
|
||||
]);
|
||||
if (! $requiresApproval) {
|
||||
$this->dispatchBroadcast($broadcast);
|
||||
}
|
||||
|
||||
return $broadcast->load(['tenant:id,tenant_code,tenant_name', 'template:id,name', 'channelConfig:id,name']);
|
||||
}
|
||||
|
||||
public function approveBroadcast(NotificationBroadcast $broadcast, User $actor): NotificationBroadcast
|
||||
{
|
||||
if ($broadcast->status !== 'pending_approval') {
|
||||
throw ValidationException::withMessages(['broadcast' => 'Siaran ini tidak sedang menunggu persetujuan.']);
|
||||
}
|
||||
$broadcast->update([
|
||||
'approved_by' => $actor->id,
|
||||
'status' => $broadcast->scheduled_at?->isFuture() ? 'scheduled' : 'queued',
|
||||
]);
|
||||
$this->dispatchBroadcast($broadcast);
|
||||
|
||||
return $broadcast->fresh(['tenant:id,tenant_code,tenant_name', 'template:id,name', 'channelConfig:id,name']);
|
||||
}
|
||||
|
||||
public function processBroadcast(NotificationBroadcast $broadcast): void
|
||||
{
|
||||
if (in_array($broadcast->status, ['processing', 'completed', 'cancelled'], true)) {
|
||||
return;
|
||||
}
|
||||
$broadcast->update(['status' => 'processing']);
|
||||
$template = $broadcast->template;
|
||||
$channelConfig = $broadcast->channelConfig;
|
||||
$this->recipientQuery($broadcast->tenant_id, $broadcast->filter_snapshot)
|
||||
->with('contacts')
|
||||
->chunkById(200, function ($customers) use ($broadcast, $template, $channelConfig) {
|
||||
foreach ($customers as $customer) {
|
||||
$destination = $this->manager->destination($customer, $broadcast->channel, true);
|
||||
$recipient = NotificationBroadcastRecipient::firstOrCreate(
|
||||
['notification_broadcast_id' => $broadcast->id, 'customer_id' => $customer->id],
|
||||
['destination' => $destination, 'status' => $destination ? 'pending' : 'skipped', 'reason' => $destination ? null : 'Kontak broadcast tidak tersedia.'],
|
||||
);
|
||||
if (! $destination || $recipient->notification_message_id) {
|
||||
continue;
|
||||
}
|
||||
$payload = ['customer' => $customer->toArray(), 'tenant' => $broadcast->tenant->toArray()];
|
||||
$body = $this->manager->render($template?->body ?? $broadcast->body, $payload);
|
||||
$subject = $this->manager->render($template?->subject ?? $broadcast->subject, $payload);
|
||||
$message = NotificationMessage::create([
|
||||
'uuid' => Str::uuid(), 'tenant_id' => $broadcast->tenant_id,
|
||||
'notification_template_id' => $template?->id, 'notification_broadcast_id' => $broadcast->id,
|
||||
'recipient_type' => $customer->getMorphClass(), 'recipient_id' => $customer->id,
|
||||
'subject' => $subject, 'payload' => $payload, 'status' => 'queued', 'scheduled_at' => now(),
|
||||
'context' => ['provider_template_name' => $template?->provider_template_name, 'language' => $template?->language],
|
||||
]);
|
||||
$delivery = NotificationDelivery::create([
|
||||
'uuid' => Str::uuid(), 'notification_message_id' => $message->id,
|
||||
'notification_channel_id' => $channelConfig?->id, 'channel' => $broadcast->channel,
|
||||
'destination' => $destination, 'rendered_subject' => $subject, 'rendered_body' => $body,
|
||||
'status' => 'queued', 'queued_at' => now(),
|
||||
'provider_response' => ['request_context' => [
|
||||
'provider_template_name' => $template?->provider_template_name,
|
||||
'language' => $template?->language,
|
||||
'template_parameters' => collect($template?->variables ?? [])->map(fn ($key) => data_get($payload, $key))->values()->all(),
|
||||
]],
|
||||
]);
|
||||
$recipient->update(['notification_message_id' => $message->id, 'status' => 'queued']);
|
||||
$queue = str_starts_with($broadcast->channel, 'whatsapp') ? 'whatsapp' : $broadcast->channel;
|
||||
ProcessNotificationDelivery::dispatch($delivery->id)->onQueue('notifications-'.$queue);
|
||||
}
|
||||
});
|
||||
$pending = $broadcast->recipients()->whereIn('status', ['pending', 'queued'])->count();
|
||||
$broadcast->update([
|
||||
'status' => $pending === 0 ? 'completed' : 'processing',
|
||||
'processed_recipients' => $broadcast->recipients()->count(),
|
||||
'success_count' => $broadcast->recipients()->where('status', 'sent')->count(),
|
||||
'failed_count' => $broadcast->recipients()->whereIn('status', ['failed', 'skipped'])->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function userNotifications(User $user, array $filters)
|
||||
{
|
||||
return UserNotification::where('user_id', $user->id)
|
||||
->when(($filters['unread'] ?? false), fn ($q) => $q->whereNull('read_at'))
|
||||
->orderByDesc('id')->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
public function saveContact(Customer $customer, array $data, ?CustomerContact $contact = null): CustomerContact
|
||||
{
|
||||
if ($contact && (int) $contact->customer_id !== (int) $customer->id) {
|
||||
throw ValidationException::withMessages(['contact' => 'Kontak bukan milik customer tersebut.']);
|
||||
}
|
||||
if (($data['is_primary'] ?? false) === true) {
|
||||
$customer->contacts()->where('type', $data['type'])->when($contact, fn ($q) => $q->whereKeyNot($contact->id))
|
||||
->update(['is_primary' => false]);
|
||||
}
|
||||
$data = [...$data, 'tenant_id' => $customer->tenant_id];
|
||||
if (($data['is_verified'] ?? false) && ! $contact?->verified_at) {
|
||||
$data['verified_at'] = now();
|
||||
}
|
||||
$contact ? $contact->update($data) : $contact = $customer->contacts()->create($data);
|
||||
|
||||
return $contact->fresh();
|
||||
}
|
||||
|
||||
private function recipientQuery(int $tenantId, array $filters): Builder
|
||||
{
|
||||
$query = Customer::query()->where('tenant_id', $tenantId)->whereNull('deleted_at');
|
||||
$query->when(! empty($filters['customer_status']), fn ($q) => $q->whereIn('status', (array) $filters['customer_status']))
|
||||
->when(! empty($filters['source']), fn ($q) => $q->where('source', $filters['source']))
|
||||
->when(! empty($filters['package_profile_id']), fn ($q) => $q->where('package_profile_id', $filters['package_profile_id']))
|
||||
->when(! empty($filters['billing_profile_id']), fn ($q) => $q->where('billing_profile_id', $filters['billing_profile_id']))
|
||||
->when(! empty($filters['provinsi_id']), fn ($q) => $q->where('provinsi_id', $filters['provinsi_id']))
|
||||
->when(! empty($filters['kabupaten_id']), fn ($q) => $q->where('kabupaten_id', $filters['kabupaten_id']))
|
||||
->when(! empty($filters['kecamatan_id']), fn ($q) => $q->where('kecamatan_id', $filters['kecamatan_id']))
|
||||
->when(! empty($filters['desa_id']), fn ($q) => $q->where('desa_id', $filters['desa_id']));
|
||||
if (! empty($filters['topology_node_id'])) {
|
||||
$nodeIds = $this->downstreamNodeIds((int) $filters['topology_node_id'], $tenantId);
|
||||
$query->whereHas('activeTopologyConnection', fn ($q) => $q->whereIn('odp_node_id', $nodeIds));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function downstreamNodeIds(int $rootId, int $tenantId): array
|
||||
{
|
||||
$ids = [$rootId];
|
||||
$frontier = [$rootId];
|
||||
while ($frontier && count($ids) < 10000) {
|
||||
$frontier = TopologyLink::where('tenant_id', $tenantId)->whereIn('source_node_id', $frontier)
|
||||
->pluck('target_node_id')->diff($ids)->values()->all();
|
||||
$ids = [...$ids, ...$frontier];
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function extractVariables(string $value): array
|
||||
{
|
||||
preg_match_all('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', $value, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
|
||||
private function dispatchBroadcast(NotificationBroadcast $broadcast): void
|
||||
{
|
||||
ProcessBroadcast::dispatch($broadcast->id)
|
||||
->delay($broadcast->scheduled_at)
|
||||
->onQueue('notifications-broadcast')
|
||||
->afterCommit();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user