Files
services_core/app/Services/Notification/NotificationManager.php
T
2026-08-01 11:42:42 +07:00

314 lines
13 KiB
PHP

<?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) {
$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' => $recipientOccurrenceKey,
]);
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();
$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()]);
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',
]);
}
}