295 lines
12 KiB
PHP
295 lines
12 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) {
|
|
$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',
|
|
]);
|
|
}
|
|
}
|