diff --git a/app/Http/Controllers/Admin/AppNotificationController.php b/app/Http/Controllers/Admin/AppNotificationController.php index d8b6968..2d764e9 100644 --- a/app/Http/Controllers/Admin/AppNotificationController.php +++ b/app/Http/Controllers/Admin/AppNotificationController.php @@ -6,7 +6,12 @@ use App\DB\Te\TableEngine; use App\Http\Controllers\Controller; use App\Models\AppNotification; use App\Models\AppNotificationRead; +use App\Models\AppNotificationRecipient; +use App\Models\Customer; +use App\Services\AppNotificationAudienceService; +use App\Services\PriceStreamService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class AppNotificationController extends Controller { @@ -15,31 +20,134 @@ class AppNotificationController extends Controller $query = AppNotification::query()->withCount('reads'); TableEngine::create($query, $request->all()) - ->initSearch(['title', 'body', 'severity']) + ->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); - $user = auth()->user(); + $audience = app(AppNotificationAudienceService::class); - $row = AppNotification::create([ - ...$data, - 'is_active' => $data['is_active'] ?? true, - 'created_by' => $user?->id, - ]); + $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'] ?? [])))); - return response()->json($row, 201); + $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 = $this->validatedPayload($request, true); + // مخاطب بعد از ارسال ثابت می‌ماند — فقط محتوا/وضعیت + $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); @@ -58,12 +166,14 @@ class AppNotificationController extends Controller public function destroy(int $id) { - AppNotification::findOrFail($id)->delete(); + $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); @@ -93,15 +203,90 @@ class AppNotificationController extends Controller ]); } - private function validatedPayload(Request $request, bool $partial = false): array + /** گیرندگان هدف (اسنپ‌شات ارسال) */ + public function recipients(int $id) { - $rules = [ - 'title' => ($partial ? 'sometimes|' : '') . 'required|string|max:191', - 'body' => ($partial ? 'sometimes|' : '') . 'required|string|max:5000', - 'severity' => ($partial ? 'sometimes|' : '') . 'required|in:info,success,warning,danger', + $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(), ]; - return $request->validate($rules); + $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); + } } } diff --git a/app/Http/Controllers/Customer/AppNotificationController.php b/app/Http/Controllers/Customer/AppNotificationController.php index 7b68584..1842f15 100644 --- a/app/Http/Controllers/Customer/AppNotificationController.php +++ b/app/Http/Controllers/Customer/AppNotificationController.php @@ -5,7 +5,9 @@ namespace App\Http\Controllers\Customer; use App\Http\Controllers\Controller; use App\Models\AppNotification; use App\Models\AppNotificationRead; +use App\Services\AppNotificationAudienceService; use App\Services\ConfigService; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -18,13 +20,29 @@ class AppNotificationController extends Controller } } + private function visibleQuery(int $customerId): Builder + { + return AppNotification::query() + ->where('is_active', true) + ->where(function (Builder $q) use ($customerId) { + $q->where('audience_type', AppNotificationAudienceService::TYPE_ALL) + ->orWhere(function (Builder $inner) use ($customerId) { + $inner->whereIn('audience_type', [ + AppNotificationAudienceService::TYPE_SEGMENT, + AppNotificationAudienceService::TYPE_CUSTOMERS, + ])->whereHas('recipients', function (Builder $r) use ($customerId) { + $r->where('customer_id', $customerId); + }); + }); + }); + } + public function index() { $this->assertFeatureEnabled(); $customerId = (int) auth()->id(); - $rows = AppNotification::query() - ->where('is_active', true) + $rows = $this->visibleQuery($customerId) ->orderByDesc('id') ->limit(100) ->get(['id', 'title', 'body', 'severity', 'created_at']); @@ -55,13 +73,11 @@ class AppNotificationController extends Controller $this->assertFeatureEnabled(); $customerId = (int) auth()->id(); - $total = AppNotification::query()->where('is_active', true)->count(); + $visibleIds = $this->visibleQuery($customerId)->select('id'); + $total = (clone $visibleIds)->count(); $read = AppNotificationRead::query() ->where('customer_id', $customerId) - ->whereIn( - 'notification_id', - AppNotification::query()->where('is_active', true)->select('id') - ) + ->whereIn('notification_id', $visibleIds) ->count(); return response()->json([ @@ -74,10 +90,7 @@ class AppNotificationController extends Controller $this->assertFeatureEnabled(); $customerId = (int) auth()->id(); - $exists = AppNotification::query() - ->where('id', $id) - ->where('is_active', true) - ->exists(); + $exists = $this->visibleQuery($customerId)->where('id', $id)->exists(); if (! $exists) { throw new NotFoundHttpException('اعلان یافت نشد.'); } @@ -100,10 +113,7 @@ class AppNotificationController extends Controller $this->assertFeatureEnabled(); $customerId = (int) auth()->id(); - $ids = AppNotification::query() - ->where('is_active', true) - ->pluck('id'); - + $ids = $this->visibleQuery($customerId)->pluck('id'); $now = now(); foreach ($ids as $notificationId) { AppNotificationRead::query()->updateOrCreate( diff --git a/app/Models/AppNotification.php b/app/Models/AppNotification.php index dfd2756..189315a 100644 --- a/app/Models/AppNotification.php +++ b/app/Models/AppNotification.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -18,14 +19,30 @@ class AppNotification extends Model 'severity', 'is_active', 'created_by', + 'audience_type', + 'segment_keys', + 'audience_label', + 'recipients_count', ]; protected $casts = [ 'is_active' => 'boolean', + 'segment_keys' => 'array', + 'recipients_count' => 'integer', ]; public function reads(): HasMany { return $this->hasMany(AppNotificationRead::class, 'notification_id'); } + + public function recipients(): HasMany + { + return $this->hasMany(AppNotificationRecipient::class, 'notification_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } } diff --git a/app/Models/AppNotificationRecipient.php b/app/Models/AppNotificationRecipient.php new file mode 100644 index 0000000..a476211 --- /dev/null +++ b/app/Models/AppNotificationRecipient.php @@ -0,0 +1,26 @@ +belongsTo(AppNotification::class, 'notification_id'); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } +} diff --git a/app/Services/AppNotificationAudienceService.php b/app/Services/AppNotificationAudienceService.php new file mode 100644 index 0000000..459e658 --- /dev/null +++ b/app/Services/AppNotificationAudienceService.php @@ -0,0 +1,163 @@ + */ + public function segmentOptions(): array + { + return [ + [ + 'key' => 'all_household', + 'name' => 'همه کاربران خانگی', + 'description' => 'همه مشتریان نوع خانگی (آرشیو نشده)', + ], + [ + 'key' => 'no_login', + 'name' => 'هرگز وارد اپ نشده‌اند', + 'description' => 'آخرین بازدید خالی است', + ], + [ + 'key' => 'inactive_7d', + 'name' => '۷ روز اخیر نیامده‌اند', + 'description' => 'آخرین بازدید بیش از ۷ روز پیش یا هرگز', + ], + [ + 'key' => 'inactive_30d', + 'name' => '۳۰ روز اخیر نیامده‌اند', + 'description' => 'آخرین بازدید بیش از ۳۰ روز پیش یا هرگز', + ], + [ + 'key' => 'no_valid', + 'name' => 'احراز هویت نشده', + 'description' => 'is_legal = 0', + ], + [ + 'key' => 'active_wallet', + 'name' => 'کاربران فعال (دارای کیف)', + 'description' => 'حداقل یک ردیف در wallets دارند', + ], + ]; + } + + public function segmentLabel(string $key): string + { + foreach ($this->segmentOptions() as $opt) { + if ($opt['key'] === $key) { + return $opt['name']; + } + } + + return $key; + } + + /** + * @param array $segmentKeys + * @return Collection + */ + public function resolveSegmentCustomers(array $segmentKeys): Collection + { + $byId = []; + foreach ($segmentKeys as $key) { + foreach ($this->customersForSegment((string) $key) as $row) { + $id = (int) ($row['id'] ?? 0); + if ($id > 0) { + $byId[$id] = $row; + } + } + } + + return collect(array_values($byId)); + } + + /** + * @param array $customerIds + * @return Collection + */ + public function resolveCustomersByIds(array $customerIds): Collection + { + $ids = array_values(array_unique(array_filter(array_map('intval', $customerIds)))); + if ($ids === []) { + return collect(); + } + + return Customer::query() + ->whereIn('id', $ids) + ->where('is_archive', false) + ->get(['id', 'name', 'mobile']) + ->map(fn (Customer $c) => [ + 'id' => (int) $c->id, + 'name' => $c->name, + 'mobile' => $c->mobile, + ]); + } + + /** + * @param array $segmentKeys + */ + public function buildAudienceLabel(string $audienceType, array $segmentKeys = [], int $recipientsCount = 0): string + { + return match ($audienceType) { + self::TYPE_ALL => 'همه کاربران', + self::TYPE_SEGMENT => collect($segmentKeys) + ->map(fn ($k) => $this->segmentLabel((string) $k)) + ->filter() + ->implode(' + ') ?: 'گروهی', + self::TYPE_CUSTOMERS => $recipientsCount <= 1 + ? 'تکی' + : ('تکی / چندنفره (' . $recipientsCount . ' نفر)'), + default => $audienceType, + }; + } + + /** + * @return array + */ + private function customersForSegment(string $key): array + { + $base = Customer::query() + ->where('type', 2) + ->where('is_archive', false); + + $query = match ($key) { + 'all_household' => $base, + 'no_login' => (clone $base)->whereNull('last_seen_at'), + 'inactive_7d' => (clone $base)->where(function ($q) { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', now()->subDays(7)); + }), + 'inactive_30d' => (clone $base)->where(function ($q) { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', now()->subDays(30)); + }), + 'no_valid' => (clone $base)->where('is_legal', 0), + 'active_wallet' => (clone $base)->whereIn('id', function ($q) { + $q->select('owner')->from('wallets'); + }), + default => null, + }; + + if ($query === null) { + return []; + } + + return $query->get(['id', 'name', 'mobile'])->map(fn (Customer $c) => [ + 'id' => (int) $c->id, + 'name' => $c->name, + 'mobile' => $c->mobile, + ])->all(); + } +} diff --git a/database/migrations/2026_07_24_000002_add_audience_to_app_notifications.php b/database/migrations/2026_07_24_000002_add_audience_to_app_notifications.php new file mode 100644 index 0000000..b698e0f --- /dev/null +++ b/database/migrations/2026_07_24_000002_add_audience_to_app_notifications.php @@ -0,0 +1,56 @@ +string('audience_type', 32)->default('all')->after('is_active'); + } + if (! Schema::hasColumn('app_notifications', 'segment_keys')) { + $table->json('segment_keys')->nullable()->after('audience_type'); + } + if (! Schema::hasColumn('app_notifications', 'audience_label')) { + $table->string('audience_label', 255)->nullable()->after('segment_keys'); + } + if (! Schema::hasColumn('app_notifications', 'recipients_count')) { + $table->unsignedInteger('recipients_count')->default(0)->after('audience_label'); + } + }); + } + + if (! Schema::hasTable('app_notification_recipients')) { + Schema::create('app_notification_recipients', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('notification_id')->index(); + $table->unsignedBigInteger('customer_id')->index(); + $table->timestamps(); + $table->unique(['notification_id', 'customer_id'], 'app_notif_recipients_unique'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('app_notification_recipients'); + + if (Schema::hasTable('app_notifications')) { + Schema::table('app_notifications', function (Blueprint $table) { + foreach (['recipients_count', 'audience_label', 'segment_keys', 'audience_type'] as $col) { + if (Schema::hasColumn('app_notifications', $col)) { + $table->dropColumn($col); + } + } + }); + } + } +}; diff --git a/routes/admin.php b/routes/admin.php index 07748fe..e64c65f 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -50,10 +50,14 @@ Route::middleware('auth:user')->group(function (){ //------------AppNotifications Route::post('app_notifications/table', [AdminAppNotificationController::class, 'table']); + Route::get('app_notifications/audience-options', [AdminAppNotificationController::class, 'audienceOptions']); + Route::get('app_notifications/search-customers', [AdminAppNotificationController::class, 'searchCustomers']); + Route::post('app_notifications/preview-audience', [AdminAppNotificationController::class, 'previewAudience']); Route::post('app_notifications', [AdminAppNotificationController::class, 'store']); Route::put('app_notifications/{id}', [AdminAppNotificationController::class, 'update']); Route::put('app_notifications/{id}/toggle', [AdminAppNotificationController::class, 'toggleActive']); Route::get('app_notifications/{id}/readers', [AdminAppNotificationController::class, 'readers']); + Route::get('app_notifications/{id}/recipients', [AdminAppNotificationController::class, 'recipients']); Route::delete('app_notifications/{id}', [AdminAppNotificationController::class, 'destroy']); //------------ConditionalOrder Route::post('conditional_orders/table', [\App\Http\Controllers\Admin\ConditionalOrderController::class, 'table']);