where('channel', $channel) ->when(! $actor->isMasterAdmin(), 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() ? (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 && $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('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, '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 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']) ->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' => $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()]); } 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(); } }