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', ]); } }