fix notif 3

This commit is contained in:
2026-07-24 22:37:48 +03:30
parent 107cf5b291
commit 52ff3a088d
7 changed files with 493 additions and 32 deletions
@@ -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);
}
}
}
@@ -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(
+17
View File
@@ -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');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AppNotificationRecipient extends Model
{
protected $table = 'app_notification_recipients';
protected $fillable = [
'notification_id',
'customer_id',
];
public function notification(): BelongsTo
{
return $this->belongsTo(AppNotification::class, 'notification_id');
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class, 'customer_id');
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Services;
use App\Models\Customer;
use Illuminate\Support\Collection;
/**
* مخاطب اعلان داخل اپ — هم‌سبک سگمنت‌های پیامک + غیرفعال‌های اخیر.
*/
class AppNotificationAudienceService
{
public const TYPE_ALL = 'all';
public const TYPE_SEGMENT = 'segment';
public const TYPE_CUSTOMERS = 'customers';
/** @return array<int, array{key: string, name: string, description: string}> */
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<int, string> $segmentKeys
* @return Collection<int, array{id: int, name: ?string, mobile: ?string}>
*/
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<int, int> $customerIds
* @return Collection<int, array{id: int, name: ?string, mobile: ?string}>
*/
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<int, string> $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<int, array{id: int, name: ?string, mobile: ?string}>
*/
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();
}
}
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* مخاطب‌گذاری اعلان: همه / گروه / تکی + جدول گیرندگان (اسنپ‌شات زمان ارسال).
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('app_notifications')) {
Schema::table('app_notifications', function (Blueprint $table) {
if (! Schema::hasColumn('app_notifications', 'audience_type')) {
$table->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);
}
}
});
}
}
};
+4
View File
@@ -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']);