withCount('reads'); TableEngine::create($query, $request->all()) ->initSearch(['title', 'body', 'severity', 'audience_label', 'audience_type']) ->select(['*']) ->export($query); return $query; } public function audienceOptions() { return response()->json([ 'segments' => app(AppNotificationAudienceService::class)->segmentOptions(), ]); } public function searchCustomers(Request $request) { $q = trim((string) $request->get('q', '')); $rows = Customer::query() ->where('type', 2) ->where('is_archive', false) ->when($q !== '', function ($query) use ($q) { $query->where(function ($inner) use ($q) { $inner->where('name', 'like', '%' . $q . '%') ->orWhere('mobile', 'like', '%' . $q . '%') ->orWhere('id', $q); }); }) ->orderByDesc('id') ->limit(30) ->get(['id', 'name', 'mobile']); return response()->json(['customers' => $rows]); } public function previewAudience(Request $request) { $audience = app(AppNotificationAudienceService::class); $type = (string) $request->input('audience_type', AppNotificationAudienceService::TYPE_ALL); $segmentKeys = array_values(array_filter((array) $request->input('segment_keys', []))); $customerIds = array_values(array_filter(array_map('intval', (array) $request->input('customer_ids', [])))); $customers = match ($type) { AppNotificationAudienceService::TYPE_SEGMENT => $audience->resolveSegmentCustomers($segmentKeys), AppNotificationAudienceService::TYPE_CUSTOMERS => $audience->resolveCustomersByIds($customerIds), default => collect(), }; return response()->json([ 'count' => $type === AppNotificationAudienceService::TYPE_ALL ? null : $customers->count(), 'sample' => $customers->take(20)->values(), 'label' => $audience->buildAudienceLabel($type, $segmentKeys, $customers->count()), ]); } public function store(Request $request) { $data = $this->validatedPayload($request); $audience = app(AppNotificationAudienceService::class); $audienceType = (string) ($data['audience_type'] ?? AppNotificationAudienceService::TYPE_ALL); $segmentKeys = array_values(array_filter((array) ($data['segment_keys'] ?? []))); $customerIds = array_values(array_filter(array_map('intval', (array) ($data['customer_ids'] ?? [])))); $recipients = match ($audienceType) { AppNotificationAudienceService::TYPE_SEGMENT => $audience->resolveSegmentCustomers($segmentKeys), AppNotificationAudienceService::TYPE_CUSTOMERS => $audience->resolveCustomersByIds($customerIds), default => collect(), }; if ($audienceType === AppNotificationAudienceService::TYPE_SEGMENT && $recipients->isEmpty()) { return response()->json(['message' => 'برای گروه انتخاب‌شده مشتری‌ای یافت نشد.'], 422); } if ($audienceType === AppNotificationAudienceService::TYPE_CUSTOMERS && $recipients->isEmpty()) { return response()->json(['message' => 'حداقل یک مشتری انتخاب کنید.'], 422); } $recipientsCount = $audienceType === AppNotificationAudienceService::TYPE_ALL ? 0 : $recipients->count(); $row = DB::transaction(function () use ($data, $audience, $audienceType, $segmentKeys, $recipients, $recipientsCount) { $user = auth()->user(); $row = AppNotification::create([ 'title' => $data['title'], 'body' => $data['body'], 'severity' => $data['severity'], 'is_active' => $data['is_active'] ?? true, 'created_by' => $user?->id, 'audience_type' => $audienceType, 'segment_keys' => $audienceType === AppNotificationAudienceService::TYPE_SEGMENT ? $segmentKeys : null, 'audience_label' => $audience->buildAudienceLabel($audienceType, $segmentKeys, $recipientsCount), 'recipients_count' => $recipientsCount, ]); if ($audienceType !== AppNotificationAudienceService::TYPE_ALL) { $now = now(); $chunks = $recipients->pluck('id')->map(fn ($id) => [ 'notification_id' => $row->id, 'customer_id' => (int) $id, 'created_at' => $now, 'updated_at' => $now, ])->chunk(500); foreach ($chunks as $chunk) { AppNotificationRecipient::query()->insert($chunk->values()->all()); } } return $row; }); $this->broadcastNotification($row); return response()->json($row->fresh(), 201); } public function update(Request $request, int $id) { $row = AppNotification::findOrFail($id); // مخاطب بعد از ارسال ثابت می‌ماند — فقط محتوا/وضعیت $data = $request->validate([ 'title' => 'sometimes|required|string|max:191', 'body' => 'sometimes|required|string|max:5000', 'severity' => 'sometimes|required|in:info,success,warning,danger', 'is_active' => 'sometimes|boolean', ]); $row->update($data); return response()->json(true); } public function toggleActive(int $id) { $row = AppNotification::findOrFail($id); $row->is_active = ! $row->is_active; $row->save(); return response()->json([ 'is_active' => (bool) $row->is_active, ]); } public function destroy(int $id) { $row = AppNotification::findOrFail($id); AppNotificationRecipient::query()->where('notification_id', $id)->delete(); $row->delete(); return response()->json(true); } /** بازدیدکنندگان (خوانده‌اند) */ public function readers(int $id) { AppNotification::findOrFail($id); $rows = AppNotificationRead::query() ->where('notification_id', $id) ->with(['customer:id,name,fname,lname,mobile']) ->orderByDesc('read_at') ->orderByDesc('id') ->get(); $readers = $rows->map(function (AppNotificationRead $read) { $customer = $read->customer; $name = trim((string) ($customer?->name ?: trim(($customer?->fname ?? '') . ' ' . ($customer?->lname ?? '')))); return [ 'customer_id' => $read->customer_id, 'name' => $name !== '' ? $name : '—', 'mobile' => $customer?->mobile, 'read_at' => $read->read_at, ]; })->values(); return response()->json([ 'reads_count' => $readers->count(), 'readers' => $readers, ]); } /** گیرندگان هدف (اسنپ‌شات ارسال) */ public function recipients(int $id) { $row = AppNotification::findOrFail($id); if ($row->audience_type === AppNotificationAudienceService::TYPE_ALL) { return response()->json([ 'audience_type' => $row->audience_type, 'audience_label' => $row->audience_label ?: 'همه کاربران', 'recipients_count' => null, 'recipients' => [], 'note' => 'این اعلان برای همه کاربران فعال ارسال شده است.', ]); } $rows = AppNotificationRecipient::query() ->where('notification_id', $id) ->with(['customer:id,name,fname,lname,mobile']) ->orderByDesc('id') ->limit(500) ->get(); $recipients = $rows->map(function (AppNotificationRecipient $item) { $customer = $item->customer; $name = trim((string) ($customer?->name ?: trim(($customer?->fname ?? '') . ' ' . ($customer?->lname ?? '')))); return [ 'customer_id' => $item->customer_id, 'name' => $name !== '' ? $name : '—', 'mobile' => $customer?->mobile, ]; })->values(); return response()->json([ 'audience_type' => $row->audience_type, 'audience_label' => $row->audience_label, 'recipients_count' => (int) $row->recipients_count, 'recipients' => $recipients, ]); } private function validatedPayload(Request $request): array { return $request->validate([ 'title' => 'required|string|max:191', 'body' => 'required|string|max:5000', 'severity' => 'required|in:info,success,warning,danger', 'is_active' => 'sometimes|boolean', 'audience_type' => 'required|in:all,segment,customers', 'segment_keys' => 'nullable|array', 'segment_keys.*' => 'string', 'customer_ids' => 'nullable|array', 'customer_ids.*' => 'integer', ]); } private function broadcastNotification(AppNotification $row): void { if (! $row->is_active) { return; } $payload = [ 'id' => $row->id, 'title' => $row->title, 'body' => $row->body, 'severity' => $row->severity, 'created_at' => optional($row->created_at)?->toIso8601String(), ]; $stream = app(PriceStreamService::class); if ($row->audience_type === AppNotificationAudienceService::TYPE_ALL) { $stream->publish('app_notification', $payload); return; } $ids = AppNotificationRecipient::query() ->where('notification_id', $row->id) ->pluck('customer_id'); foreach ($ids as $customerId) { $stream->publishForCustomer((int) $customerId, 'app_notification', $payload); } } }