use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\TicketController; use App\Http\Controllers\Api\CoinController; use Illuminate\Support\Facades\Route; Route::post('/auth/social', [AuthController::class, 'social']); Route::post('/social-login', [AuthController::class, 'social']); Route::post('/user/fcm-token', [AuthController::class, 'storeFcmToken']); // Articles feed — supports category, language, country, search, user categories filtering Route::get('/articles', function (\Illuminate\Http\Request $request) { // Cache key based on all query params $cacheKey = 'api_articles_' . md5(json_encode($request->query())); $cached = \Illuminate\Support\Facades\Cache::get($cacheKey); if ($cached) return response()->json($cached); $activeSourceIds = \Illuminate\Support\Facades\Cache::remember("active_source_ids", 120, fn() => \App\Models\Source::where("is_active", true)->pluck("id")->toArray()); $query = \App\Models\Article::where("is_published", true)->where(fn($q) => $q->whereNull("is_duplicate")->orWhere("is_duplicate", false)) ->whereIn("source_id", $activeSourceIds) ->latest('published_at'); // Filter by category (supports comma-separated slugs, e.g. ?category=health or ?category=technology,science,education) if ($cat = $request->query('category')) { $catSlugs = array_filter(array_map('trim', explode(',', $cat))); if (count($catSlugs) === 1) { $catModel = \App\Models\Category::where('slug', $catSlugs[0]) ->orWhere('name', $catSlugs[0]) ->orWhere('name_en', $catSlugs[0]) ->first(); if ($catModel) $query->where('category_id', $catModel->id); } else { $catIds = \App\Models\Category::where(function ($q) use ($catSlugs) { $q->whereIn('slug', $catSlugs)->orWhereIn('name', $catSlugs)->orWhereIn('name_en', $catSlugs); })->pluck('id'); if ($catIds->isNotEmpty()) $query->whereIn('category_id', $catIds); } } // Filter by multiple user categories (comma-separated names) if ($cats = $request->query("categories")) { $catNames = array_filter(explode(',', $cats)); if (!empty($catNames)) { $catIds = \App\Models\Category::where(function ($q) use ($catNames) { $q->whereIn('name', $catNames)->orWhereIn('name_en', $catNames)->orWhereIn('slug', $catNames); })->pluck('id'); if ($catIds->isNotEmpty()) $query->whereIn('category_id', $catIds); } } // Filter by language (source language) $lang = $request->query('language') ?? $request->cookie('khabr_lang'); if ($lang) { $sourceIds = \App\Models\Source::where('language', $lang)->pluck('id'); if ($sourceIds->isNotEmpty()) $query->whereIn('source_id', $sourceIds); } // Filter by country (supports comma-separated, e.g. ?country=jo,eg) if ($country = $request->query('country')) { $countries = array_filter(array_map('trim', explode(',', $country))); if (in_array('intl', $countries)) { // International: articles with origin_country='intl' or from sources with no country $query->where(function ($q) { $q->where('origin_country', 'intl') ->orWhereHas('source', fn($sq) => $sq->whereNull('country')->orWhere('country', '')); }); } elseif (count($countries) === 1) { $sourceIds = \App\Models\Source::where('country', $countries[0])->pluck('id'); if ($sourceIds->isNotEmpty()) $query->whereIn('source_id', $sourceIds); } else { $sourceIds = \App\Models\Source::whereIn('country', $countries)->pluck('id'); if ($sourceIds->isNotEmpty()) $query->whereIn('source_id', $sourceIds); } } // Filter by source IDs if ($sources = $request->query('sources')) { $sourceIds = array_filter(explode(',', $sources)); if (!empty($sourceIds)) $query->whereIn('source_id', $sourceIds); } // Search if ($q = $request->query('q')) { $query->where(function ($qb) use ($q) { $qb->where('title', 'like', "%{$q}%") ->orWhere('excerpt', 'like', "%{$q}%"); }); } // Sort by trending (views) or default (latest) if ($request->query("sort_by") === "trending") { // Trending: score = views + recency bonus (newer articles rank higher even with fewer views) $query->where("published_at", ">=", now()->subHours(48)); $query->reorder()->orderByRaw("(COALESCE(views, 0) + GREATEST(0, 10 - TIMESTAMPDIFF(HOUR, published_at, NOW()))) DESC"); } $perPage = min((int) ($request->query('per_page') ?? 20), 50); // Diversify: for international feed, round-robin from each source $isIntl = $request->query('country') === 'intl'; $isTrendSort = $request->query('sort_by') === 'trending'; if ($isIntl && !$isTrendSort) { $allArticles = (clone $query)->limit(200)->get(); $bySource = $allArticles->groupBy('source_id'); $diversified = collect(); $maxRounds = (int) ceil($perPage / max($bySource->count(), 1)); for ($round = 0; $round < $maxRounds && $diversified->count() < $perPage; $round++) { foreach ($bySource as $sourceArticles) { $vals = $sourceArticles->values(); if (isset($vals[$round]) && $diversified->count() < $perPage) { $diversified->push($vals[$round]); } } } $articles = new \Illuminate\Pagination\LengthAwarePaginator( $diversified->values(), $diversified->count(), $perPage, 1 ); } else { $articles = $query->paginate($perPage); } // Optional: translate titles via AI (if translate_to param) $translateTo = $request->query('translate_to'); $result = [ 'data' => $articles->map(function ($a) { return [ 'id' => $a->id, 'title' => $a->title_ar ?: $a->title, 'title_original' => $a->title_ar ? $a->title : null, 'excerpt' => $a->content_ar ? mb_substr(strip_tags($a->content_ar), 0, 200) : $a->excerpt, 'image' => $a->image ?: null, 'slug' => $a->slug, 'source_name' => $a->source?->name ?? '', 'source_language' => $a->source?->language ?? 'ar', 'category_name' => $a->category?->name ?? '', 'category_id' => $a->category_id, 'published_at' => $a->published_at?->toISOString(), 'original_url' => $a->original_url, 'is_breaking' => (bool) $a->is_breaking, 'views' => $a->views ?? 0, ]; }), 'current_page' => $articles->currentPage(), 'last_page' => $articles->lastPage(), 'total' => $articles->total(), ]; \Illuminate\Support\Facades\Cache::put($cacheKey, $result, 30); return response()->json($result); }); // Sources list for app source picker Route::get('/sources', function () { return \App\Models\Source::where('is_active', true) ->orderBy('country') ->orderBy('name') ->get(['id', 'name', 'name_en', 'logo', 'country', 'category_id']) ->map(function ($s) { $s->category = $s->category_id ? (\App\Models\Category::find($s->category_id)?->name ?? '') : ''; unset($s->category_id); return $s; }); }); // Notifications Route::get('/user/notifications', [NotificationController::class, 'index']); Route::post('/user/notifications/read', [NotificationController::class, 'markRead']); Route::get('/user/notifications/unread-count', [NotificationController::class, 'unreadCount']); // Public Figures & Notification Settings Route::get('/public-figures', [NotificationController::class, 'getPublicFigures']); // Get articles matching public figure keywords Route::get('/figure-news', function (\Illuminate\Http\Request $request) { $figureIds = array_filter(explode(',', $request->query('figures', ''))); if (empty($figureIds)) return response()->json([]); $figures = \App\Models\PublicFigure::whereIn('id', $figureIds)->where('is_active', true)->get(); if ($figures->isEmpty()) return response()->json([]); $officialFigures = $figures->where('official_only', true); $regularFigures = $figures->where('official_only', false); $perPage = (int) $request->query('per_page', 30); $allArticles = collect(); // For official-only figures: tweets (figure_id) + keyword matches from official sources only if ($officialFigures->isNotEmpty()) { $officialIds = $officialFigures->pluck('id')->toArray(); // 1) Tweet-based articles (figure_id match) $tweetArticles = \App\Models\Article::where('is_published', true) ->whereIn('figure_id', $officialIds) ->where('created_at', '>=', now()->subDays(30)) ->latest('published_at') ->limit($perPage) ->get(); $allArticles = $allArticles->merge($tweetArticles); // 2) Keyword matches from official sources only (e.g. Royal Court, Petra) $officialKeywords = []; $officialSourceIds = []; foreach ($officialFigures as $fig) { foreach ($fig->keywords ?? [] as $kw) { $officialKeywords[] = $kw; } foreach ($fig->official_source_ids ?? [] as $sid) { $officialSourceIds[] = $sid; } } $officialSourceIds = array_unique($officialSourceIds); if (!empty($officialKeywords) && !empty($officialSourceIds)) { $oQuery = \App\Models\Article::where('is_published', true) ->where(fn($q) => $q->whereNull('is_duplicate')->orWhere('is_duplicate', false)) ->whereIn('source_id', $officialSourceIds) ->where('created_at', '>=', now()->subDays(14)) ->latest('published_at'); $oQuery->where(function ($q) use ($officialKeywords) { foreach ($officialKeywords as $kw) { if (str_contains($kw, ' ')) { $q->orWhere('title', 'like', "%{$kw}%"); } else { $escaped = preg_quote($kw, '/'); $q->orWhereRaw("title REGEXP ?", ['(^|[[:space:]])' . $escaped . '([[:space:]]|$)']); } } }); $officialArticles = $oQuery->limit($perPage)->get(); $allArticles = $allArticles->merge($officialArticles); } } // For regular figures: keyword matching from all active sources (existing behavior) if ($regularFigures->isNotEmpty()) { $allKeywords = []; foreach ($regularFigures as $fig) { foreach ($fig->keywords ?? [] as $kw) { $allKeywords[] = $kw; } } $activeSourceIds = \Illuminate\Support\Facades\Cache::remember("active_source_ids", 120, fn() => \App\Models\Source::where("is_active", true)->pluck("id")->toArray()); $query = \App\Models\Article::where("is_published", true) ->where(fn($q) => $q->whereNull("is_duplicate")->orWhere("is_duplicate", false)) ->whereIn("source_id", $activeSourceIds) ->where('created_at', '>=', now()->subDays(7)) ->latest('published_at'); if ($cats = $request->query("categories")) { $catSlugs = array_filter(explode(",", $cats)); if (!empty($catSlugs)) { $catIds = \App\Models\Category::where(function($q) use ($catSlugs) { $q->whereIn("slug", $catSlugs)->orWhereIn("name", $catSlugs)->orWhereIn("name_en", $catSlugs); })->pluck("id"); if ($catIds->isNotEmpty()) { $query->whereIn("category_id", $catIds); } } } $query->where(function ($q) use ($allKeywords) { foreach ($allKeywords as $kw) { if (str_contains($kw, ' ')) { $q->orWhere('title', 'like', "%{$kw}%"); } else { $escaped = preg_quote($kw, '/'); $q->orWhereRaw("title REGEXP ?", ['(^|[[:space:]])' . $escaped . '([[:space:]]|$)']); } } }); $keywordArticles = $query->limit($perPage)->get(); $allArticles = $allArticles->merge($keywordArticles); } $articles = $allArticles->unique('id')->sortByDesc('published_at')->take($perPage); return response()->json($articles->values()->map(function ($a) { return [ 'id' => $a->id, 'title' => $a->title, 'excerpt' => $a->excerpt, 'image' => $a->image ?: null, 'slug' => $a->slug, 'source_name' => $a->source?->name ?? '', 'category_name' => $a->category?->name ?? '', 'published_at' => $a->published_at?->toISOString(), 'original_url' => $a->original_url, 'views' => $a->views ?? 0, ]; })); }); Route::post('/user/notification-limit', [NotificationController::class, 'updateNotifLimit']); // Save user's followed figures Route::post('/user/followed-figures', function (\Illuminate\Http\Request $request) { $email = $request->input('email'); $figureIds = $request->input('figure_ids', []); if (!$email) return response()->json(['error' => 'email required'], 400); $user = \App\Models\User::where('email', $email)->first(); if (!$user) return response()->json(['error' => 'user not found'], 404); $user->update(['followed_figures' => $figureIds, 'preferences_updated_at' => now()]); return response()->json(['ok' => true]); }); // User Preferences Route::post('/user/preferences', function (\Illuminate\Http\Request $request) { $email = $request->input('email'); if (!$email) return response()->json(['error' => 'email required'], 400); $user = \App\Models\User::where('email', $email)->first(); if (!$user) return response()->json(['error' => 'user not found'], 404); $updates = []; if ($request->has('categories')) $updates['preferred_categories'] = $request->input('categories'); if ($request->has('languages')) $updates['preferred_languages'] = $request->input('languages'); if ($request->has('profession')) $updates['profession'] = $request->input('profession'); if ($request->has('followed_sources')) $updates['followed_sources'] = $request->input('followed_sources'); if ($request->has('preferred_countries')) $updates['preferred_countries'] = $request->input('preferred_countries'); if ($request->has('notification_keywords')) $updates['notification_keywords'] = $request->input('notification_keywords'); if ($request->has("keyword_scope")) $updates["keyword_scope"] = $request->input("keyword_scope"); if ($request->has('followed_topics')) $updates['followed_topics'] = $request->input('followed_topics'); if ($request->has('notifications_enabled')) $updates['notifications_enabled'] = (bool) $request->input('notifications_enabled'); if ($request->has('breaking_enabled')) $updates['breaking_enabled'] = (bool) $request->input('breaking_enabled'); if ($request->has('name')) $updates['name'] = $request->input('name'); if ($request->has('phone')) $updates['phone'] = $request->input('phone'); if ($request->has('max_notifications_per_hour')) $updates['max_notifications_per_hour'] = (int) $request->input('max_notifications_per_hour'); // Track when preferences changed for notification quiet period if (!empty($updates)) { $updates['preferences_updated_at'] = now(); } $user->update($updates); return response()->json(['ok' => true]); }); // Breaking news preferences Route::post('/user/breaking-preferences', function (\Illuminate\Http\Request $request) { $email = $request->input('email'); if (!$email) return response()->json(['error' => 'email required'], 400); $user = \App\Models\User::where('email', $email)->first(); if (!$user) return response()->json(['error' => 'user not found'], 404); $updates = []; if ($request->has('breaking_enabled')) $updates['breaking_enabled'] = (bool) $request->input('breaking_enabled'); if ($request->has('breaking_categories')) $updates['breaking_categories'] = $request->input('breaking_categories'); $user->update($updates); return response()->json(['ok' => true]); }); // Notification rules (server-driven, so app can be updated via API) Route::get('/user/notification-rules', function (\Illuminate\Http\Request $request) { $email = $request->query('email'); if (!$email) return response()->json([]); $user = \App\Models\User::where('email', $email)->first(); if (!$user) return response()->json([]); $topics = []; $filters = []; // Build topics from user preferences $categories = $user->preferred_categories ?? []; foreach ($categories as $cat) { $topics[] = 'category_' . strtolower(str_replace(' ', '_', $cat)); } $sources = $user->followed_sources ?? []; foreach ($sources as $source) { $topics[] = 'source_' . strtolower(str_replace(' ', '_', $source)); } $figures = $user->followed_figures ?? []; foreach ($figures as $figId) { $topics[] = 'figure_' . $figId; } if ($user->breaking_enabled) { $breakingCats = $user->breaking_categories ?? []; if (empty($breakingCats)) { $topics[] = 'breaking'; } else { foreach ($breakingCats as $catId) { $topics[] = 'breaking_' . $catId; } } } // Admin can override filters via app_settings $blockedCategories = \App\Models\AppSetting::get('blocked_notification_categories'); if ($blockedCategories) { $filters['blocked_categories'] = json_decode($blockedCategories, true) ?: []; } $allowAll = \App\Models\AppSetting::get('notification_allow_all', '0'); if ($allowAll === '1') { $filters['allow_all'] = true; } return response()->json([ 'topics' => $topics, 'filters' => $filters, ]); }); // Games API Route::get('/games', function () { return \App\Models\Game::where('is_active', true) ->orderBy('sort_order') ->orderBy('name') ->get(); }); // User profile Route::get('/user/profile', function (\Illuminate\Http\Request $request) { $email = $request->query('email'); if (!$email) return response()->json(['error' => 'email required'], 400); $user = \App\Models\User::where('email', $email)->first(); if (!$user) return response()->json(['error' => 'user not found'], 404); return response()->json([ 'name' => $user->name, 'email' => $user->email, 'avatar' => $user->avatar, 'phone' => $user->phone, 'profession' => $user->profession, 'preferred_categories' => $user->preferred_categories ?? [], 'followed_sources' => $user->followed_sources ?? [], 'followed_figures' => $user->followed_figures ?? [], 'breaking_enabled' => $user->breaking_enabled ?? false, 'breaking_categories' => $user->breaking_categories ?? [], 'preferred_countries' => $user->preferred_countries ?? [], 'notification_keywords' => $user->notification_keywords ?? [], "keyword_scope" => $user->keyword_scope ?? "country", 'followed_topics' => $user->followed_topics ?? [], ]); }); // Upload avatar Route::post('/user/avatar', function (\Illuminate\Http\Request $request) { $request->validate(['email' => 'required|email', 'avatar' => 'required|image|max:2048']); $user = \App\Models\User::where("email", $request->email)->first() ?: \App\Models\User::whereJsonContains("linked_emails", $request->email)->first(); if (!$user) return response()->json(['error' => 'user not found'], 404); $path = $request->file('avatar')->store('avatars', 'public'); $url = asset('storage/' . $path); $user->update(['avatar' => $url]); return response()->json(['avatar' => $url]); }); // Support Tickets Route::get('/support/tickets', [TicketController::class, 'index']); Route::post('/support/tickets', [TicketController::class, 'store']); Route::get('/support/tickets/{id}', [TicketController::class, 'show']); Route::post('/support/tickets/{id}/reply', [TicketController::class, 'reply']); // Coins & Rewards Route::prefix("coins")->group(function () { Route::get("/balance", [CoinController::class, "balance"]); Route::post("/read", [CoinController::class, "recordRead"]); Route::post("/share", [CoinController::class, "recordShare"]); Route::post("/daily-login", [CoinController::class, "dailyLogin"]); Route::get("/history", [CoinController::class, "history"]); Route::get("/rewards", [CoinController::class, "rewards"]); Route::post("/redeem", [CoinController::class, "redeem"]); }); // Countries with article counts // Track article view Route::post('/articles/{id}/view', function ($id) { $article = \App\Models\Article::find($id); if ($article) { $article->increment('views'); return response()->json(['views' => $article->views]); } return response()->json(['error' => 'not found'], 404); }); Route::get('/countries', function () { $countryNames = [ 'jo' => ['ar' => 'الأردن', 'en' => 'Jordan'], 'ps' => ['ar' => 'فلسطين', 'en' => 'Palestine'], 'eg' => ['ar' => 'مصر', 'en' => 'Egypt'], 'sa' => ['ar' => 'السعودية', 'en' => 'Saudi Arabia'], 'ae' => ['ar' => 'الإمارات', 'en' => 'UAE'], 'kw' => ['ar' => 'الكويت', 'en' => 'Kuwait'], 'bh' => ['ar' => 'البحرين', 'en' => 'Bahrain'], 'qa' => ['ar' => 'قطر', 'en' => 'Qatar'], 'om' => ['ar' => 'عُمان', 'en' => 'Oman'], 'iq' => ['ar' => 'العراق', 'en' => 'Iraq'], 'sy' => ['ar' => 'سوريا', 'en' => 'Syria'], 'lb' => ['ar' => 'لبنان', 'en' => 'Lebanon'], 'ly' => ['ar' => 'ليبيا', 'en' => 'Libya'], 'tn' => ['ar' => 'تونس', 'en' => 'Tunisia'], 'dz' => ['ar' => 'الجزائر', 'en' => 'Algeria'], 'ma' => ['ar' => 'المغرب', 'en' => 'Morocco'], 'sd' => ['ar' => 'السودان', 'en' => 'Sudan'], 'ye' => ['ar' => 'اليمن', 'en' => 'Yemen'], 'us' => ['ar' => 'أمريكا', 'en' => 'USA'], 'gb' => ['ar' => 'بريطانيا', 'en' => 'UK'], 'uk' => ['ar' => 'بريطانيا', 'en' => 'UK'], 'fr' => ['ar' => 'فرنسا', 'en' => 'France'], 'de' => ['ar' => 'ألمانيا', 'en' => 'Germany'], 'tr' => ['ar' => 'تركيا', 'en' => 'Turkey'], 'ru' => ['ar' => 'روسيا', 'en' => 'Russia'], 'cn' => ['ar' => 'الصين', 'en' => 'China'], 'in' => ['ar' => 'الهند', 'en' => 'India'], 'pk' => ['ar' => 'باكستان', 'en' => 'Pakistan'], 'il' => ['ar' => 'إسرائيل', 'en' => 'Israel'], 'mr' => ['ar' => 'موريتانيا', 'en' => 'Mauritania'], 'int' => ['ar' => 'دولي', 'en' => 'International'], 'intl' => ['ar' => 'دولي', 'en' => 'International'], ]; $counts = \App\Models\Article::where('is_published', true) ->selectRaw('origin_country, COUNT(*) as cnt') ->groupBy('origin_country') ->pluck('cnt', 'origin_country'); $result = []; foreach ($counts as $code => $count) { if (!$code) continue; $names = $countryNames[$code] ?? ['ar' => $code, 'en' => $code]; $result[] = [ 'code' => $code, 'name_ar' => $names['ar'], 'name_en' => $names['en'], 'count' => $count, ]; } usort($result, fn($a, $b) => $b['count'] <=> $a['count']); return response()->json($result); }); // Feature Requests Route::post('/feature-request', function (\Illuminate\Http\Request $request) { $validated = $request->validate([ 'email' => 'required|email', 'name' => 'required|string|max:255', 'type' => 'required|in:feature,news_source', 'title' => 'required|string|max:255', 'description' => 'nullable|string|max:2000', ]); $userId = null; $user = \App\Models\User::where('email', $validated['email'])->first(); if ($user) { $userId = $user->id; } \App\Models\FeatureRequest::create([ 'user_id' => $userId, 'user_email' => $validated['email'], 'user_name' => $validated['name'], 'type' => $validated['type'], 'title' => $validated['title'], 'description' => $validated['description'] ?? null, 'status' => 'pending', ]); return response()->json([ 'message' => 'تم إرسال طلبك بنجاح. شكراً لك!', 'message_en' => 'Your request has been submitted successfully. Thank you!', ], 201); }); Route::get('/feature-requests', function (\Illuminate\Http\Request $request) { $email = $request->query('email'); if (!$email) { return response()->json(['error' => 'email required'], 400); } $requests = \App\Models\FeatureRequest::where('user_email', $email) ->latest() ->get(['id', 'type', 'title', 'description', 'status', 'admin_notes', 'created_at']); return response()->json($requests); }); // ========== Delete Account ========== Route::delete('/user/account', function (\Illuminate\Http\Request $request) { $validated = $request->validate([ 'email' => 'required|email', 'password' => 'required|string', ]); $user = \App\Models\User::where('email', $validated['email'])->first(); if (!$user) { return response()->json(['error' => 'User not found'], 404); } if (!\Illuminate\Support\Facades\Hash::check($validated['password'], $user->password)) { return response()->json(['error' => 'Invalid password'], 401); } // Delete related data \App\Models\UserNotification::where('user_id', $user->id)->delete(); \App\Models\NotificationRead::where('user_id', $user->id)->delete(); \App\Models\PushSubscription::where('user_id', $user->id)->delete(); \App\Models\FeatureRequest::where('user_id', $user->id)->delete(); \App\Models\CoinTransaction::where('user_id', $user->id)->delete(); \App\Models\SupportTicket::where('user_id', $user->id)->delete(); \App\Models\ContentReport::where('user_email', $user->email)->delete(); $user->delete(); return response()->json([ 'message' => 'Account deleted successfully', ]); }); // ========== Password Reset ========== Route::post('/auth/forgot-password', function (\Illuminate\Http\Request $request) { $validated = $request->validate([ 'email' => 'required|email', ]); $user = \App\Models\User::where('email', $validated['email'])->first(); if (!$user) { return response()->json(['error' => 'User not found'], 404); } $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); \Illuminate\Support\Facades\Cache::put('password_reset_' . $validated['email'], $code, now()->addMinutes(15)); return response()->json([ 'message' => 'Verification code generated', 'code' => $code, // For testing - remove in production ]); }); Route::post('/auth/reset-password', function (\Illuminate\Http\Request $request) { $validated = $request->validate([ 'email' => 'required|email', 'code' => 'required|string|size:6', 'new_password' => 'required|string|min:6', ]); $user = \App\Models\User::where('email', $validated['email'])->first(); if (!$user) { return response()->json(['error' => 'User not found'], 404); } $cachedCode = \Illuminate\Support\Facades\Cache::get('password_reset_' . $validated['email']); if (!$cachedCode || $cachedCode !== $validated['code']) { return response()->json(['error' => 'Invalid or expired code'], 422); } $user->update(['password' => $validated['new_password']]); \Illuminate\Support\Facades\Cache::forget('password_reset_' . $validated['email']); return response()->json([ 'message' => 'Password reset successfully', ]); }); // ========== Report Content ========== Route::post('/report', function (\Illuminate\Http\Request $request) { $validated = $request->validate([ 'email' => 'required|email', 'article_id' => 'nullable|integer|exists:articles,id', 'comment_id' => 'nullable|integer', 'reason' => 'required|in:inappropriate,misleading,spam,hate_speech,other', 'details' => 'nullable|string|max:2000', ]); \App\Models\ContentReport::create([ 'user_email' => $validated['email'], 'article_id' => $validated['article_id'] ?? null, 'comment_id' => $validated['comment_id'] ?? null, 'reason' => $validated['reason'], 'details' => $validated['details'] ?? null, 'status' => 'pending', ]); return response()->json([ 'message' => 'Report submitted successfully. Thank you!', ], 201); }); // ========== Edit Profile ========== Route::put('/user/profile', function (\Illuminate\Http\Request $request) { $validated = $request->validate([ 'email' => 'required|email', 'name' => 'nullable|string|max:255', 'phone' => 'nullable|string|max:20', 'avatar_url' => 'nullable|url|max:500', ]); $user = \App\Models\User::where('email', $validated['email'])->first(); if (!$user) { return response()->json(['error' => 'User not found'], 404); } $updates = []; if ($request->has('name')) $updates['name'] = $validated['name']; if ($request->has('phone')) $updates['phone'] = $validated['phone']; if ($request->has('avatar_url')) $updates['avatar'] = $validated['avatar_url']; if (!empty($updates)) { $user->update($updates); } return response()->json([ 'message' => 'Profile updated successfully', 'user' => [ 'name' => $user->name, 'email' => $user->email, 'phone' => $user->phone, 'avatar' => $user->avatar, 'phone' => $user->phone, ], ]); }); // Notification Topics (preset keyword topics) Route::get('/notification-topics', function () { $topics = \App\Models\NotificationTopic::where('is_active', true) ->select('id', 'name_ar', 'name_en', 'slug', 'keywords', 'icon') ->orderBy('name_ar') ->get(); return response()->json($topics); }); // Update approval/rejection via email link Route::get('/updates/{id}/approve', function ($id, \Illuminate\Http\Request $request) { $update = \App\Models\AppUpdate::findOrFail($id); if ($update->approval_token !== $request->query('token')) { return response('رابط غير صالح', 403); } if ($update->status === 'applied') { return response("
ℹ️

تم تطبيق هذا التحديث مسبقاً

يمكنك إغلاق هذه الصفحة

"); } $update->update(['status' => 'approved', 'approved_at' => now()]); return response("

تمت الموافقة

سيتم تنفيذ التحديث في أقرب وقت

يمكنك إغلاق هذه الصفحة

"); }); Route::get('/updates/{id}/reject', function ($id, \Illuminate\Http\Request $request) { $update = \App\Models\AppUpdate::findOrFail($id); if ($update->approval_token !== $request->query('token')) { return response('رابط غير صالح', 403); } $update->update(['status' => 'rejected']); return response("

تم الرفض

لن يتم تنفيذ هذا التحديث

يمكنك إغلاق هذه الصفحة

"); }); // ========== Discounts ========== Route::get("/discounts", function () { $discounts = \App\Models\Discount::where("is_active", true) ->orderBy("sort_order") ->orderBy("company") ->get(); $grouped = $discounts->groupBy("company")->map(function ($items, $company) { return [ "company" => $company, "discounts" => $items->map(function ($d) { return [ "id" => $d->id, "title_ar" => $d->title_ar, "title_en" => $d->title_en, "description_ar" => $d->description_ar, "description_en" => $d->description_en, "discount_value" => $d->discount_value, "category" => $d->category, "image" => $d->image, "link" => $d->link, "expires_at" => $d->expires_at?->toDateString(), ]; })->values(), ]; })->values(); return response()->json($grouped); }); // ========== Important Numbers ========== Route::get("/important-numbers", function () { return response()->json([ [ "category_ar" => "الطوارئ", "category_en" => "Emergency", "numbers" => [ ["name_ar" => "الدفاع المدني والإسعاف", "name_en" => "Civil Defense & Ambulance", "number" => "199"], ["name_ar" => "الشرطة / الأمن العام", "name_en" => "Police / Public Security", "number" => "911"], ["name_ar" => "الإطفاء", "name_en" => "Fire Department", "number" => "199"], ["name_ar" => "حوادث السير", "name_en" => "Traffic Accidents", "number" => "190"], ["name_ar" => "خط نجدة الطفل والأسرة", "name_en" => "Family & Child Protection Hotline", "number" => "110"], ], ], [ "category_ar" => "الحكومة", "category_en" => "Government", "numbers" => [ ["name_ar" => "خدمة الحكومة الإلكترونية", "name_en" => "eGovernment Service", "number" => "065008080"], ["name_ar" => "ديوان الشكاوى (رئاسة الوزراء)", "name_en" => "Complaints Bureau (PM Office)", "number" => "064641211"], ["name_ar" => "مطار الملكة علياء الدولي", "name_en" => "Queen Alia International Airport", "number" => "064451234"], ["name_ar" => "أمانة عمّان الكبرى", "name_en" => "Greater Amman Municipality", "number" => "064642311"], ["name_ar" => "دائرة الأحوال المدنية", "name_en" => "Civil Status & Passports", "number" => "064008080"], ["name_ar" => "المؤسسة العامة للضمان الاجتماعي", "name_en" => "Social Security Corporation", "number" => "080022025"], ], ], [ "category_ar" => "الاتصالات", "category_en" => "Telecom", "numbers" => [ ["name_ar" => "أورانج الأردن", "name_en" => "Orange Jordan", "number" => "1777"], ["name_ar" => "زين الأردن", "name_en" => "Zain Jordan", "number" => "1234"], ["name_ar" => "أمنية", "name_en" => "Umniah", "number" => "1666"], ["name_ar" => "هيئة تنظيم الاتصالات (TRC)", "name_en" => "Telecom Regulatory Commission", "number" => "117"], ], ], [ "category_ar" => "الصحة", "category_en" => "Health", "numbers" => [ ["name_ar" => "وزارة الصحة", "name_en" => "Ministry of Health", "number" => "065004545"], ["name_ar" => "المستشفى الإسلامي", "name_en" => "Islamic Hospital", "number" => "065680680"], ["name_ar" => "مستشفى الأردن", "name_en" => "Jordan Hospital", "number" => "065607607"], ["name_ar" => "مستشفى الخالدي", "name_en" => "Khalidi Hospital", "number" => "064644281"], ], ], [ "category_ar" => "خدمات عامة", "category_en" => "Utilities & Services", "numbers" => [ ["name_ar" => "شركة الكهرباء الأردنية", "name_en" => "Jordan Electric Power", "number" => "1212"], ["name_ar" => "مياه الأردن (مياهنا)", "name_en" => "Miyahuna Water Company", "number" => "065007777"], ["name_ar" => "البريد الأردني", "name_en" => "Jordan Post", "number" => "064651411"], ["name_ar" => "خدمة الاستعلامات", "name_en" => "Directory Assistance", "number" => "1212"], ["name_ar" => "خط مساندة المرأة", "name_en" => "Women Support Hotline", "number" => "110"], ], ], [ "category_ar" => "البنوك", "category_en" => "Banks", "numbers" => [ ["name_ar" => "البنك المركزي الأردني", "name_en" => "Central Bank of Jordan", "number" => "064630301"], ["name_ar" => "البنك العربي", "name_en" => "Arab Bank", "number" => "065600000"], ["name_ar" => "بنك الإسكان", "name_en" => "Housing Bank", "number" => "065200400"], ], ], ]); }); // Full-text article search Route::get('/search', function (\Illuminate\Http\Request $request) { $q = $request->query('q', ''); if (mb_strlen($q) < 2) return response()->json([]); $page = max(1, (int) $request->query('page', 1)); $perPage = min(50, max(10, (int) $request->query('per_page', 20))); $articles = \App\Models\Article::where('is_published', true) ->where(fn($qb) => $qb->whereNull('is_duplicate')->orWhere('is_duplicate', false)) ->whereHas('source', fn($s) => $s->where('is_active', true)) ->where(function($qb) use ($q) { $qb->where('title', 'like', "%{$q}%") ->orWhere('excerpt', 'like', "%{$q}%") ->orWhere('content', 'like', "%{$q}%"); }) ->with('source:id,name,logo,country,language', 'category:id,name,slug,name_en') ->latest('published_at') ->paginate($perPage, ['*'], 'page', $page); return response()->json($articles->map(function($a) { return [ 'id' => $a->id, 'title' => $a->title, 'slug' => $a->slug, 'excerpt' => $a->excerpt, 'image' => $a->image, 'source_name' => $a->source?->name, 'source_logo' => $a->source?->logo, 'source_language' => $a->source?->language ?? 'ar', 'source_country' => $a->source?->country, 'category' => $a->category?->name, 'category_slug' => $a->category?->slug, 'published_at' => $a->published_at?->toIso8601String(), 'views' => $a->views ?? 0, 'is_breaking' => $a->is_breaking ?? false, ]; })); }); // Insurance Route::get("/insurance/status", function () { return response()->json([ "enabled" => (bool) \App\Models\AppSetting::get("insurance_enabled", false), ]); }); Route::post("/insurance/apply", function (\Illuminate\Http\Request $r) { $r->validate([ "email" => "required|email", "full_name" => "required|string|max:255", "adults" => "required|integer|min:1|max:10", "children" => "required|integer|min:0|max:10", "plan" => "required|in:silver,gold,platinum", "total_cost" => "required|numeric", ]); $user = \App\Models\User::where("email", $r->email)->first(); $app = \App\Models\InsuranceApplication::create([ "user_id" => $user?->id, "full_name" => $r->full_name, "adults" => $r->adults, "children" => $r->children, "plan" => $r->plan, "total_cost" => $r->total_cost, "status" => "pending", ]); return response()->json(["ok" => true, "id" => $app->id]); }); Route::post("/insurance/upload-id", function (\Illuminate\Http\Request $r) { $r->validate([ "application_id" => "required|integer", "side" => "required|in:front,back", "image" => "required|image|max:5120", ]); $app = \App\Models\InsuranceApplication::findOrFail($r->application_id); $path = $r->file("image")->store("insurance", "public"); $field = $r->side === "front" ? "id_front" : "id_back"; $app->update([$field => $path]); return response()->json(["ok" => true, "path" => $path]); }); // ===== Sports Teams & Players ===== Route::get("/sports/teams", function (\Illuminate\Http\Request $r) { $q = \App\Models\SportsTeam::where("is_active", true); if ($r->league) $q->where("league", $r->league); if ($r->type) $q->where("type", $r->type); $teams = $q->orderBy("sort_order")->with(["players" => function ($q) { $q->where("is_active", true)->orderBy("sort_order"); }])->get(); return response()->json($teams); }); Route::get("/sports/teams/{id}", function ($id) { $team = \App\Models\SportsTeam::where("is_active", true) ->with(["players" => function ($q) { $q->where("is_active", true)->orderBy("sort_order"); }]) ->findOrFail($id); return response()->json($team); }); // ===== Football Live Data ===== Route::get("/sports/leagues", function () { $svc = new \App\Services\FootballService(); return response()->json($svc->getLeagues()); }); Route::get("/sports/dashboard", function (\Illuminate\Http\Request $r) { $league = $r->input("league", "PL"); $svc = new \App\Services\FootballService(); return response()->json($svc->getDashboard($league)); }); Route::get("/sports/matches", function (\Illuminate\Http\Request $r) { $league = $r->input("league", "PL"); $status = $r->input("status", "all"); $limit = min((int)$r->input("limit", 30), 50); $svc = new \App\Services\FootballService(); return response()->json(["matches" => $svc->getMatches($league, $status, $limit), "league" => $league]); }); Route::get("/sports/standings", function (\Illuminate\Http\Request $r) { $league = $r->input("league", "PL"); $svc = new \App\Services\FootballService(); return response()->json(["standings" => $svc->getStandings($league), "league" => $league]); }); // ===== Car Showroom ===== Route::get("/cars/brands", function () { $brands = \App\Models\CarBrand::where("is_active", true) ->orderBy("sort_order") ->withCount(["models" => function ($q) { $q->where("is_active", true); }]) ->get(); return response()->json($brands); }); Route::get("/cars/brands/{id}", function ($id) { $brand = \App\Models\CarBrand::where("is_active", true) ->with(["models" => function ($q) { $q->where("is_active", true)->orderBy("sort_order"); }]) ->findOrFail($id); return response()->json($brand); }); Route::get("/cars/models", function (\Illuminate\Http\Request $r) { $q = \App\Models\CarModel::where("is_active", true); if ($r->brand_id) $q->where("brand_id", $r->brand_id); if ($r->category) $q->where("category", $r->category); $models = $q->orderBy("sort_order")->with("brand")->get(); return response()->json($models); }); Route::get("/cars/models/{id}", function ($id) { $model = \App\Models\CarModel::where("is_active", true) ->with("brand") ->findOrFail($id); return response()->json($model); }); // Obituaries API Route::get('/obituaries', function (\Illuminate\Http\Request $request) { $query = \App\Models\Obituary::where('is_published', true) ->latest('death_date'); if ($city = $request->query('city')) { $query->where('city', 'like', "%{$city}%"); } $page = (int) $request->query('page', 1); $perPage = 20; $total = $query->count(); $items = $query->skip(($page - 1) * $perPage)->take($perPage)->get(); return response()->json([ 'data' => $items, 'total' => $total, 'page' => $page, 'last_page' => ceil($total / $perPage), ]); }); // Fact Check API Route::post('/fact-check', [\App\Http\Controllers\Api\FactCheckController::class, 'check']); Route::post("/fact-check/report", [\App\Http\Controllers\Api\FactCheckController::class, "report"]); // App intro audio setting Route::get('/app/intro', function (\Illuminate\Http\Request $request) { $lang = $request->query('lang', 'ar'); $files = [ 'en' => 'khabr_intro_en.mp3', 'tr' => 'khabr_intro_tr.mp3', 'fr' => 'khabr_intro_en.mp3', 'ar' => 'khabar_intro_ar.mp3', ]; $file = $files[$lang] ?? $files['ar']; return response()->json([ 'audio_url' => url('/audio/' . $file), 'file' => $file, ]); }); // ─── DOCTORS ────────────────────────────────────────────────────── Route::get("/doctors", function (\Illuminate\Http\Request $request) { $query = \App\Models\Doctor::where("is_active", true); if ($s = $request->query("specialty")) { $query->where(fn($q) => $q->where("specialty", $s)->orWhere("specialty_en", $s)); } if ($q = $request->query("q")) { $query->where(function ($qb) use ($q) { $qb->where("name", "like", "%{$q}%") ->orWhere("name_en", "like", "%{$q}%") ->orWhere("specialty", "like", "%{$q}%") ->orWhere("specialty_en", "like", "%{$q}%") ->orWhere("clinic_name", "like", "%{$q}%"); }); } $query->orderByDesc("is_featured")->orderBy("sort_order"); $perPage = min((int) ($request->query("per_page") ?? 20), 50); $paginated = $query->paginate($perPage); return response()->json([ "data" => $paginated->items(), "total" => $paginated->total(), "page" => $paginated->currentPage(), "last_page" => $paginated->lastPage(), ]); }); Route::get("/specialties", function () { $specialties = \App\Models\Doctor::where("is_active", true) ->select("specialty", "specialty_en") ->distinct() ->orderBy("specialty") ->get() ->map(fn($d) => ["id" => $d->specialty, "name" => $d->specialty, "name_en" => $d->specialty_en]); return response()->json(["data" => $specialties]); }); Route::post("/doctors/book", function (\Illuminate\Http\Request $request) { $data = $request->validate([ "doctor_id" => "required|exists:doctors,id", "name" => "required|string", "phone" => "required|string", "date" => "nullable|string", "time_preference" => "nullable|string", "notes" => "nullable|string", ]); $appointment = \App\Models\Appointment::create($data); return response()->json(["success" => true, "id" => $appointment->id]); }); // ─── TTS (OpenAI) ───────────────────────────────────────────────── Route::post("/ai-tts", function (\Illuminate\Http\Request $request) { $text = $request->input("text", ""); $lang = $request->input("lang", "ar"); if (empty(trim($text))) { return response()->json(["error" => "No text provided"], 400); } // Limit text to 4000 chars for cost control $text = mb_substr($text, 0, 4000); // Hash for caching $hash = md5($text . $lang); $cachePath = storage_path("app/public/tts"); if (!is_dir($cachePath)) mkdir($cachePath, 0755, true); $audioFile = "{$cachePath}/{$hash}.mp3"; // Return cached if exists if (file_exists($audioFile)) { return response()->json(["audio_url" => url("/storage/tts/{$hash}.mp3")]); } $apiKey = env("OPENAI_API_KEY"); if (empty($apiKey)) { return response()->json(["error" => "TTS not configured"], 500); } // Pick voice based on language $voice = match($lang) { "ar" => "onyx", // deep male voice good for Arabic "fr" => "nova", // female voice good for French "tr" => "alloy", // neutral voice default => "nova", }; try { $response = \Illuminate\Support\Facades\Http::withHeaders([ "Authorization" => "Bearer {$apiKey}", ])->timeout(60)->withBody(json_encode([ "model" => "tts-1", "input" => $text, "voice" => $voice, "response_format" => "mp3", ]), "application/json")->post("https://api.openai.com/v1/audio/speech"); if ($response->successful()) { file_put_contents($audioFile, $response->body()); return response()->json(["audio_url" => url("/storage/tts/{$hash}.mp3")]); } \Illuminate\Support\Facades\Log::error("OpenAI TTS failed: " . $response->status()); return response()->json(["error" => "TTS generation failed"], 500); } catch (\Exception $e) { \Illuminate\Support\Facades\Log::error("TTS error: " . $e->getMessage()); return response()->json(["error" => "TTS error"], 500); } }); // ─── Instagram Content / Social Media Feeds ─────────────────────────────── Route::get("/social-feeds", function (\Illuminate\Http\Request $request) { $feeds = \Illuminate\Support\Facades\Cache::remember("social_feeds_list", 300, function () { return \DB::table("social_feeds")->where("is_active", true)->orderBy("sort_order")->get(); }); return response()->json(["data" => $feeds]); }); Route::get("/social-feeds/{id}/posts", function ($id) { $posts = \Illuminate\Support\Facades\Cache::remember("social_feed_posts_{$id}", 300, function () use ($id) { return \DB::table("social_feed_posts")->where("feed_id", $id)->orderByDesc("posted_at")->limit(50)->get(); }); return response()->json(["data" => $posts]); }); // Card settings for home screen Route::get('/card-settings', function () { return response()->json([ 'card_prayer' => \App\Models\AppSetting::get('card_prayer', '1') === '1', 'card_currency' => \App\Models\AppSetting::get('card_currency', '1') === '1', 'card_history' => \App\Models\AppSetting::get('card_history', '1') === '1', 'card_horoscope' => \App\Models\AppSetting::get('card_horoscope', '1') === '1', 'card_polls' => \App\Models\AppSetting::get('card_polls', '1') === '1', 'card_comments' => \App\Models\AppSetting::get('card_comments', '1') === '1', 'card_doctors' => \App\Models\AppSetting::get('card_doctors', '1') === '1', 'screen_tv' => \App\Models\AppSetting::get('screen_tv', '1') === '1', 'screen_music' => \App\Models\AppSetting::get('screen_music', '1') === '1', 'screen_video_creator' => \App\Models\AppSetting::get('screen_video_creator', '1') === '1', ]); }); // Breaking news articles Route::get('/articles/breaking', function (\Illuminate\Http\Request $request) { $articles = \App\Models\Article::where('is_published', true) ->where('is_breaking', true) ->latest('published_at') ->limit(20) ->get() ->map(function ($a) { return [ 'id' => $a->id, 'title' => $a->title, 'slug' => $a->slug, 'excerpt' => \Illuminate\Support\Str::limit(strip_tags($a->content), 200), 'image' => $a->image, 'original_url' => $a->original_url, 'category_id' => $a->category_id, 'category_name' => $a->category?->name, 'source_name' => $a->source?->name, 'source_language' => $a->source?->language ?? 'ar', 'is_breaking' => true, 'views' => $a->views ?? 0, 'published_at' => $a->published_at, ]; }); return response()->json(['data' => $articles]); }); // App crash & analytics logging Route::post('/app-logs', function (\Illuminate\Http\Request $request) { $device = $request->input('device', []); $events = $request->input('events', []); foreach ($events as $evt) { \DB::table('app_logs')->insert([ 'type' => $evt['type'] ?? 'unknown', 'platform' => $device['platform'] ?? null, 'os_version' => $device['os_version'] ?? null, 'device' => $device['device'] ?? null, 'app_version' => $device['app_version'] ?? null, 'build' => $device['build'] ?? null, 'user_email' => $device['user_email'] ?? null, 'screen' => $evt['screen'] ?? null, 'action' => $evt['action'] ?? null, 'message' => $evt['message'] ?? null, 'stack' => substr($evt['stack'] ?? '', 0, 2000), 'context' => $evt['context'] ?? null, 'details' => $evt['details'] ?? null, 'event' => $evt['event'] ?? null, 'client_timestamp' => isset($evt['timestamp']) ? date('Y-m-d H:i:s', strtotime($evt['timestamp'])) : null, 'created_at' => now(), 'updated_at' => now(), ]); } return response()->json(['ok' => true]); }); // ============ Country-Based News System ============ // Get available countries with source counts Route::get('/countries', function () { $countries = [ 'JO' => ['name_ar' => 'الأردن', 'name_en' => 'Jordan', 'flag' => '🇯🇴'], 'EG' => ['name_ar' => 'مصر', 'name_en' => 'Egypt', 'flag' => '🇪🇬'], 'SA' => ['name_ar' => 'السعودية', 'name_en' => 'Saudi Arabia', 'flag' => '🇸🇦'], 'AE' => ['name_ar' => 'الإمارات', 'name_en' => 'UAE', 'flag' => '🇦🇪'], 'IQ' => ['name_ar' => 'العراق', 'name_en' => 'Iraq', 'flag' => '🇮🇶'], 'PS' => ['name_ar' => 'فلسطين', 'name_en' => 'Palestine', 'flag' => '🇵🇸'], 'LB' => ['name_ar' => 'لبنان', 'name_en' => 'Lebanon', 'flag' => '🇱🇧'], 'SY' => ['name_ar' => 'سوريا', 'name_en' => 'Syria', 'flag' => '🇸🇾'], 'MA' => ['name_ar' => 'المغرب', 'name_en' => 'Morocco', 'flag' => '🇲🇦'], 'DZ' => ['name_ar' => 'الجزائر', 'name_en' => 'Algeria', 'flag' => '🇩🇿'], 'TN' => ['name_ar' => 'تونس', 'name_en' => 'Tunisia', 'flag' => '🇹🇳'], 'LY' => ['name_ar' => 'ليبيا', 'name_en' => 'Libya', 'flag' => '🇱🇾'], 'YE' => ['name_ar' => 'اليمن', 'name_en' => 'Yemen', 'flag' => '🇾🇪'], 'KW' => ['name_ar' => 'الكويت', 'name_en' => 'Kuwait', 'flag' => '🇰🇼'], 'BH' => ['name_ar' => 'البحرين', 'name_en' => 'Bahrain', 'flag' => '🇧🇭'], 'QA' => ['name_ar' => 'قطر', 'name_en' => 'Qatar', 'flag' => '🇶🇦'], 'OM' => ['name_ar' => 'عمان', 'name_en' => 'Oman', 'flag' => '🇴🇲'], 'MR' => ['name_ar' => 'موريتانيا', 'name_en' => 'Mauritania', 'flag' => '🇲🇷'], 'TR' => ['name_ar' => 'تركيا', 'name_en' => 'Turkey', 'flag' => '🇹🇷'], 'IR' => ['name_ar' => 'إيران', 'name_en' => 'Iran', 'flag' => '🇮🇷'], 'GB' => ['name_ar' => 'بريطانيا', 'name_en' => 'United Kingdom', 'flag' => '🇬🇧'], 'US' => ['name_ar' => 'أمريكا', 'name_en' => 'United States', 'flag' => '🇺🇸'], 'FR' => ['name_ar' => 'فرنسا', 'name_en' => 'France', 'flag' => '🇫🇷'], 'DE' => ['name_ar' => 'ألمانيا', 'name_en' => 'Germany', 'flag' => '🇩🇪'], 'RU' => ['name_ar' => 'روسيا', 'name_en' => 'Russia', 'flag' => '🇷🇺'], 'CN' => ['name_ar' => 'الصين', 'name_en' => 'China', 'flag' => '🇨🇳'], 'IN' => ['name_ar' => 'الهند', 'name_en' => 'India', 'flag' => '🇮🇳'], 'PK' => ['name_ar' => 'باكستان', 'name_en' => 'Pakistan', 'flag' => '🇵🇰'], ]; $counts = \DB::table('sources') ->where('is_active', true) ->select('country', \DB::raw('count(*) as source_count')) ->groupBy('country') ->pluck('source_count', 'country'); $result = []; foreach ($countries as $code => $info) { $cnt = $counts[$code] ?? 0; if ($cnt > 0) { $result[] = array_merge($info, ['code' => $code, 'source_count' => $cnt]); } } // Add international section $intl_count = ($counts['INTL'] ?? 0) + ($counts['INT'] ?? 0); if ($intl_count > 0) { $result[] = [ 'code' => 'INTL', 'name_ar' => 'الأخبار الدولية', 'name_en' => 'International News', 'flag' => '🌍', 'source_count' => $intl_count, ]; } return response()->json($result); }); // Get sources for a specific country, grouped by source_type Route::get('/countries/{code}/sources', function ($code) { $query = \DB::table('sources')->where('is_active', true); if ($code === 'INTL') { $query->whereIn('country', ['INTL', 'INT']); } else { $query->where('country', $code); } $sources = $query->select('id', 'name', 'name_en', 'logo', 'source_type', 'category_id') ->orderBy('source_type') ->orderBy('name') ->get(); $source_type_labels = [ 'government' => ['ar' => 'مصادر حكومية', 'en' => 'Government Sources'], 'university' => ['ar' => 'جامعات ومؤسسات تعليمية', 'en' => 'Universities & Educational'], 'local_media' => ['ar' => 'إعلام محلي', 'en' => 'Local Media'], 'news_agency' => ['ar' => 'وكالات أنباء', 'en' => 'News Agencies'], 'international_media' => ['ar' => 'إعلام دولي', 'en' => 'International Media'], 'economic' => ['ar' => 'مؤسسات اقتصادية', 'en' => 'Economic Institutions'], 'cultural' => ['ar' => 'مؤسسات ثقافية', 'en' => 'Cultural Institutions'], 'health' => ['ar' => 'مصادر صحية', 'en' => 'Health Sources'], ]; $grouped = []; foreach ($sources as $s) { $type = $s->source_type ?? 'local_media'; if (!isset($grouped[$type])) { $labels = $source_type_labels[$type] ?? ['ar' => $type, 'en' => $type]; $grouped[$type] = [ 'type' => $type, 'label_ar' => $labels['ar'], 'label_en' => $labels['en'], 'sources' => [], ]; } $grouped[$type]['sources'][] = [ 'id' => $s->id, 'name' => $s->name, 'name_en' => $s->name_en, 'logo' => $s->logo, ]; } return response()->json(array_values($grouped)); }); // Get articles filtered by country, source_type, and/or category Route::get('/countries/{code}/articles', function (\Illuminate\Http\Request $request, $code) { $sourceType = $request->query('source_type'); $category = $request->query('category'); $page = $request->query('page', 1); $perPage = $request->query('per_page', 20); // Get source IDs for this country $sourceQuery = \DB::table('sources')->where('is_active', true); if ($code === 'INTL') { $sourceQuery->whereIn('country', ['INTL', 'INT']); } else { $sourceQuery->where('country', $code); } if ($sourceType) { $sourceQuery->where('source_type', $sourceType); } $sourceIds = $sourceQuery->pluck('id'); if ($sourceIds->isEmpty()) { return response()->json(['data' => [], 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0]]); } $query = \DB::table('articles') ->whereIn('source_id', $sourceIds) ->where('is_published', true); if ($category) { $catId = \DB::table('categories')->where('slug', $category)->value('id'); if ($catId) { $query->where('category_id', $catId); } } $total = $query->count(); $articles = $query->orderByDesc('published_at') ->offset(($page - 1) * $perPage) ->limit($perPage) ->select('id', 'title', 'content', 'image', 'source_id', 'category_id', 'published_at', 'original_url as url') ->get(); // Attach source names $sourceNames = \DB::table('sources')->whereIn('id', $articles->pluck('source_id')->unique()) ->pluck('name', 'id'); $articles = $articles->map(function ($a) use ($sourceNames) { $a->source_name = $sourceNames[$a->source_id] ?? ''; return $a; }); return response()->json([ 'data' => $articles, 'meta' => [ 'current_page' => (int)$page, 'last_page' => (int)ceil($total / $perPage), 'total' => $total, 'per_page' => (int)$perPage, ] ]); }); // Get source types available for a country Route::get('/countries/{code}/source-types', function ($code) { $query = \DB::table('sources')->where('is_active', true); if ($code === 'INTL') { $query->whereIn('country', ['INTL', 'INT']); } else { $query->where('country', $code); } $types = $query->select('source_type', \DB::raw('count(*) as cnt')) ->groupBy('source_type') ->get(); $labels = [ 'government' => ['ar' => 'حكومي', 'en' => 'Government'], 'university' => ['ar' => 'جامعات', 'en' => 'Universities'], 'local_media' => ['ar' => 'إعلام محلي', 'en' => 'Local Media'], 'news_agency' => ['ar' => 'وكالات أنباء', 'en' => 'News Agencies'], 'international_media' => ['ar' => 'إعلام دولي', 'en' => 'International Media'], 'economic' => ['ar' => 'مؤسسات اقتصادية', 'en' => 'Economic'], 'cultural' => ['ar' => 'مؤسسات ثقافية', 'en' => 'Cultural'], 'health' => ['ar' => 'صحية', 'en' => 'Health'], ]; $result = []; foreach ($types as $t) { $l = $labels[$t->source_type] ?? ['ar' => $t->source_type, 'en' => $t->source_type]; $result[] = [ 'type' => $t->source_type, 'label_ar' => $l['ar'], 'label_en' => $l['en'], 'count' => $t->cnt, ]; } return response()->json($result); }); // Save user country preference Route::post('/user/country', function (\Illuminate\Http\Request $request) { $email = $request->input('email'); $country = $request->input('country'); if (!$email || !$country) { return response()->json(['error' => 'email and country required'], 400); } \DB::table('users')->where('email', $email)->update([ 'preferred_country' => $country, 'updated_at' => now(), ]); return response()->json(['ok' => true]); }); // Suggest a new source Route::post('/suggest-source', function (\Illuminate\Http\Request $request) { \DB::table('source_suggestions')->insert([ 'name' => $request->input('name'), 'url' => $request->input('url'), 'country' => $request->input('country'), 'source_type' => $request->input('source_type'), 'suggested_by' => $request->input('email'), 'status' => 'pending', 'created_at' => now(), 'updated_at' => now(), ]); return response()->json(['ok' => true, 'message' => 'Source suggestion submitted']); }); // ============ KHABR FOOD POS API ============ // POS Login Route::post('/pos/login', function (\Illuminate\Http\Request $request) { $username = $request->input('username'); $password = $request->input('password'); $user = \DB::table('food_pos_users') ->where('username', $username) ->where('password', $password) ->where('is_active', true) ->first(); if (!$user) { return response()->json(['error' => 'Invalid credentials'], 401); } $token = bin2hex(random_bytes(40)); \DB::table('food_pos_users')->where('id', $user->id)->update(['api_token' => $token]); $restaurant = \DB::table('food_restaurants')->find($user->restaurant_id); return response()->json([ 'token' => $token, 'user' => [ 'id' => $user->id, 'name' => $user->name, 'role' => $user->role, ], 'restaurant' => [ 'id' => $restaurant->id, 'name' => $restaurant->name, 'name_ar' => $restaurant->name_ar, 'logo' => $restaurant->logo, ], ]); }); // POS Middleware helper if (!function_exists("posAuth")) { function posAuth($request) { $token = str_replace('Bearer ', '', $request->header('Authorization', '')); $user = \DB::table('food_pos_users')->where('api_token', $token)->where('is_active', true)->first(); return $user; } } // Get orders Route::get('/pos/orders', function (\Illuminate\Http\Request $request) { $user = posAuth($request); if (!$user) return response()->json(['error' => 'Unauthorized'], 401); $status = $request->query('status', 'pending'); $orders = \DB::table('food_orders') ->where('restaurant_id', $user->restaurant_id) ->where('status', $status) ->orderBy('created_at', 'desc') ->get() ->map(function ($o) { $o->items = json_decode($o->items, true); return $o; }); return response()->json(['orders' => $orders]); }); // Update order status Route::put('/pos/orders/{id}/status', function (\Illuminate\Http\Request $request, $id) { $user = posAuth($request); if (!$user) return response()->json(['error' => 'Unauthorized'], 401); $status = $request->input('status'); $validStatuses = ['pending', 'preparing', 'ready', 'completed', 'cancelled']; if (!in_array($status, $validStatuses)) { return response()->json(['error' => 'Invalid status'], 400); } \DB::table('food_orders') ->where('id', $id) ->where('restaurant_id', $user->restaurant_id) ->update(['status' => $status, 'updated_at' => now()]); return response()->json(['ok' => true]); }); // Place order (from website/app) Route::post('/pos/orders', function (\Illuminate\Http\Request $request) { $restaurantId = $request->input('restaurant_id', 1); $items = $request->input('items', []); $subtotal = collect($items)->sum(fn($i) => $i['price'] * $i['quantity']); $deliveryFee = $request->input('delivery_fee', 0); $id = \DB::table('food_orders')->insertGetId([ 'restaurant_id' => $restaurantId, 'customer_name' => $request->input('customer_name'), 'phone' => $request->input('phone'), 'address' => $request->input('address'), 'lat' => $request->input('lat'), 'lng' => $request->input('lng'), 'items' => json_encode($items), 'subtotal' => $subtotal, 'delivery_fee' => $deliveryFee, 'total' => $subtotal + $deliveryFee, 'notes' => $request->input('notes'), 'status' => 'pending', 'payment_method' => $request->input('payment_method', 'cash'), 'created_at' => now(), 'updated_at' => now(), ]); return response()->json(['ok' => true, 'order_id' => $id]); }); // Get restaurant menu (public) Route::get('/food/menu/{slug}', function ($slug) { $restaurant = \DB::table('food_restaurants')->where('slug', $slug)->where('is_active', true)->first(); if (!$restaurant) return response()->json(['error' => 'Not found'], 404); $categories = \DB::table('food_categories') ->where('restaurant_id', $restaurant->id) ->where('is_active', true) ->orderBy('sort_order') ->get(); $items = \DB::table('food_items') ->where('restaurant_id', $restaurant->id) ->where('is_active', true) ->orderBy('sort_order') ->get(); $menu = $categories->map(function ($cat) use ($items) { $cat->items = $items->where('category_id', $cat->id)->values(); return $cat; }); $restaurant->cuisines = json_decode($restaurant->cuisines); return response()->json([ 'restaurant' => $restaurant, 'menu' => $menu, ]); }); // Cancel order with notification to customer Route::put('/pos/orders/{id}/cancel', function (\Illuminate\Http\Request $request, $id) { $user = posAuth($request); if (!$user) return response()->json(['error' => 'Unauthorized'], 401); $order = \DB::table('food_orders') ->where('id', $id) ->where('restaurant_id', $user->restaurant_id) ->first(); if (!$order) return response()->json(['error' => 'Order not found'], 404); $reason = $request->input('reason', 'تم إلغاء الطلب من قبل المطعم'); \DB::table('food_orders')->where('id', $id)->update([ 'status' => 'cancelled', 'notes' => $order->notes ? $order->notes . "\n[إلغاء]: " . $reason : "[إلغاء]: " . $reason, 'updated_at' => now(), ]); // Send SMS to customer if phone exists $phone = $order->phone; if ($phone) { $restaurant = \DB::table('food_restaurants')->find($user->restaurant_id); $restaurantName = $restaurant ? $restaurant->name_ar : ''; $message = "عزيزي العميل، نعتذر تم إلغاء طلبك رقم #{$id} من {$restaurantName}. السبب: {$reason} - نظام خبر"; // Log the cancellation notification \DB::table('food_orders')->where('id', $id)->update([ 'notes' => \DB::raw("CONCAT(COALESCE(notes,''), '\n[SMS to {$phone}]: {$message}')"), ]); // TODO: Integrate actual SMS gateway here \Log::info("Order #{$id} cancelled. SMS to {$phone}: {$message}"); } return response()->json(['ok' => true, 'message' => 'Order cancelled and customer notified']); }); // Jersey Orders Route::post("/jersey-order", function (\Illuminate\Http\Request $request) { $v = $request->validate([ "name" => "required|string|max:255", "email" => "required|email|max:255", "phone" => "required|string|max:20", "city" => "required|string|max:100", "address" => "required|string|max:500", "product_name" => "required|string|max:255", "product_type" => "required|string|max:50", "size" => "required|string|max:20", "quantity" => "required|integer|min:1|max:10", "price" => "required|numeric|min:1", "total" => "required|numeric|min:1", "payment_method" => "required|in:cash,apple_pay", "notes" => "nullable|string|max:1000", ]); $orderNum = "NSH-" . strtoupper(substr(uniqid(), -6)) . "-" . now()->format("dmy"); \Illuminate\Support\Facades\DB::table("jersey_orders")->insert([ "order_number" => $orderNum, "name" => $v["name"], "email" => $v["email"], "phone" => $v["phone"], "address" => $v["address"], "city" => $v["city"], "product_name" => $v["product_name"], "product_type" => $v["product_type"], "size" => $v["size"], "quantity" => $v["quantity"], "price" => $v["price"], "total" => $v["total"], "payment_method" => $v["payment_method"], "notes" => $v["notes"] ?? null, "status" => "pending", "created_at" => now(), "updated_at" => now(), ]); return response()->json(["success" => true, "order_number" => $orderNum]); }); // Get single article by ID Route::get("/articles/{id}", function ($id) { $article = \App\Models\Article::find($id); if (!$article) return response()->json(["error" => "Not found"], 404); $source = $article->source; return response()->json([ "article" => [ "id" => $article->id, "title" => $article->title, "content" => $article->content, "excerpt" => $article->excerpt, "image" => $article->image, "source_name" => $source ? $source->name : null, "source_icon" => $source ? $source->icon : null, "source_id" => $article->source_id, "category_slug" => $article->category ? $article->category->slug : null, "category" => $article->category ? ["slug" => $article->category->slug, "name" => $article->category->name] : null, "url" => $article->url, "published_at" => $article->published_at, "created_at" => $article->created_at, "views" => $article->views ?? 0, ], ]); })->where("id", "[0-9]+"); // App Ping / Heartbeat (track downloads & live users) Route::post("/app-ping", function (\Illuminate\Http\Request $request) { $deviceId = $request->input("device_id"); if (!$deviceId) return response()->json(["error" => "device_id required"], 422); \DB::table("app_sessions")->updateOrInsert( ["device_id" => $deviceId], [ "user_id" => $request->input("user_id"), "platform" => $request->input("platform", "unknown"), "app_version" => $request->input("app_version"), "country" => $request->input("country"), "ip" => $request->ip(), "last_ping" => now(), ] ); $liveCount = \DB::table("app_sessions")->where("last_ping", ">=", now()->subMinutes(5))->count(); $totalDevices = \DB::table("app_sessions")->count(); return response()->json(["ok" => true, "live" => $liveCount, "total" => $totalDevices]); }); // Article Reactions Route::post("/articles/{id}/react", function (\Illuminate\Http\Request $request, $id) { $reaction = $request->input("reaction"); if (!in_array($reaction, ["like","love","sad","angry","wow"])) { return response()->json(["error" => "Invalid reaction"], 422); } $userId = null; if ($request->bearerToken()) { $user = \App\Models\User::where("api_token", $request->bearerToken())->first(); $userId = $user?->id; } $deviceId = $request->input("device_id"); // Toggle: if already reacted with same type, remove it $existing = \DB::table("article_reactions") ->where("article_id", $id) ->where("reaction", $reaction) ->where(function($q) use ($userId, $deviceId) { if ($userId) $q->where("user_id", $userId); else $q->where("device_id", $deviceId); })->first(); if ($existing) { \DB::table("article_reactions")->where("id", $existing->id)->delete(); } else { \DB::table("article_reactions")->insert([ "article_id" => $id, "user_id" => $userId, "device_id" => $deviceId, "reaction" => $reaction, "created_at" => now(), "updated_at" => now(), ]); } // Return updated counts $counts = \DB::table("article_reactions") ->where("article_id", $id) ->selectRaw("reaction, count(*) as count") ->groupBy("reaction") ->pluck("count", "reaction"); return response()->json(["reactions" => $counts, "toggled" => !$existing]); }); Route::get("/articles/{id}/reactions", function ($id) { $counts = \DB::table("article_reactions") ->where("article_id", $id) ->selectRaw("reaction, count(*) as count") ->groupBy("reaction") ->pluck("count", "reaction"); return response()->json(["reactions" => $counts]); }); // Live stats for in-app display Route::get('/app-stats', function () { $now = now(); // Website visitors $visitorsToday = \App\Models\Visitor::whereDate('visited_at', today())->count(); $liveVisitors = \App\Models\Visitor::where('visited_at', '>=', $now->copy()->subMinutes(5))->distinct('ip')->count('ip'); // App users $appDownloads = \DB::table('app_sessions')->count(); $liveAppUsers = \DB::table('app_sessions')->where('last_ping', '>=', $now->copy()->subMinutes(5))->count(); // Combined live = website + app $totalLive = $liveVisitors + $liveAppUsers; // Readers today (active in last 24h from app_sessions) $readersToday = \DB::table('app_sessions')->whereDate('last_ping', today())->count(); // Add base boost + small random jitter for live feel $boost = 121; $jitter = rand(-3, 5); return response()->json([ 'visitors_today' => $visitorsToday + $boost * 10, 'live_now' => $totalLive + $boost + $jitter, 'live_website' => $liveVisitors, 'live_app' => $liveAppUsers, 'app_downloads' => $appDownloads, 'readers_today' => $readersToday + $visitorsToday + $boost * 10, 'total_views' => (int) \App\Models\Article::sum('views') + $boost * 100, ]); }); // ─── Chat / Messaging ──────────────────────────────────────────────────── // Search users to chat with Route::get('/chat/users', function (\Illuminate\Http\Request $request) { $q = $request->query('q', ''); $email = $request->query('email'); $me = \App\Models\User::where('email', $email)->first(); if (!$me) return response()->json([]); $users = \App\Models\User::where('id', '!=', $me->id) ->where(function ($qb) use ($q) { $qb->where('name', 'like', "%{$q}%"); }) ->select('id', 'name', 'email', 'avatar') ->limit(30) ->get(); return response()->json($users); }); // List conversations Route::get('/chat/conversations', function (\Illuminate\Http\Request $request) { $email = $request->query('email'); $me = \App\Models\User::where('email', $email)->first(); if (!$me) return response()->json([]); $convos = \Illuminate\Support\Facades\DB::table('conversations') ->where('user1_id', $me->id) ->orWhere('user2_id', $me->id) ->orderByDesc('last_message_at') ->limit(50) ->get() ->map(function ($c) use ($me) { $otherId = $c->user1_id == $me->id ? $c->user2_id : $c->user1_id; $other = \App\Models\User::select('id', 'name', 'avatar')->find($otherId); $lastMsg = \Illuminate\Support\Facades\DB::table('messages') ->where('conversation_id', $c->id) ->orderByDesc('id') ->first(); $unread = \Illuminate\Support\Facades\DB::table('messages') ->where('conversation_id', $c->id) ->where('sender_id', '!=', $me->id) ->where('read', false) ->count(); return [ 'id' => $c->id, 'other_user' => $other, 'last_message' => $lastMsg ? $lastMsg->body : null, 'last_message_at' => $c->last_message_at, 'unread_count' => $unread, ]; }); return response()->json($convos); }); // Get or create conversation with a user Route::post('/chat/conversation', function (\Illuminate\Http\Request $request) { $email = $request->input('email'); $me = \App\Models\User::where('email', $email)->first(); if (!$me) return response()->json(['error' => 'Auth required'], 401); $otherId = $request->input('user_id'); if (!$otherId || $otherId == $me->id) return response()->json(['error' => 'Invalid user'], 400); $convo = \Illuminate\Support\Facades\DB::table('conversations') ->where(function ($q) use ($me, $otherId) { $q->where('user1_id', $me->id)->where('user2_id', $otherId); }) ->orWhere(function ($q) use ($me, $otherId) { $q->where('user1_id', $otherId)->where('user2_id', $me->id); }) ->first(); if (!$convo) { $id = \Illuminate\Support\Facades\DB::table('conversations')->insertGetId([ 'user1_id' => $me->id, 'user2_id' => $otherId, 'created_at' => now(), 'updated_at' => now(), ]); $convo = \Illuminate\Support\Facades\DB::table('conversations')->find($id); } $other = \App\Models\User::select('id', 'name', 'avatar')->find($otherId); return response()->json(['id' => $convo->id, 'other_user' => $other]); }); // Get messages in a conversation Route::get('/chat/messages/{conversationId}', function (\Illuminate\Http\Request $request, $conversationId) { $email = $request->query('email'); $me = \App\Models\User::where('email', $email)->first(); if (!$me) return response()->json([]); $convo = \Illuminate\Support\Facades\DB::table('conversations')->find($conversationId); if (!$convo || ($convo->user1_id != $me->id && $convo->user2_id != $me->id)) { return response()->json([]); } \Illuminate\Support\Facades\DB::table('messages') ->where('conversation_id', $conversationId) ->where('sender_id', '!=', $me->id) ->where('read', false) ->update(['read' => true]); $before = $request->query('before'); $query = \Illuminate\Support\Facades\DB::table('messages')->where('conversation_id', $conversationId); if ($before) $query->where('id', '<', $before); $messages = $query->orderByDesc('id')->limit(50)->get()->reverse()->values(); return response()->json($messages); }); // Send a message Route::post('/chat/messages', function (\Illuminate\Http\Request $request) { $email = $request->input('email'); $me = \App\Models\User::where('email', $email)->first(); if (!$me) return response()->json(['error' => 'Auth required'], 401); $conversationId = $request->input('conversation_id'); $body = $request->input('body', ''); $convo = \Illuminate\Support\Facades\DB::table('conversations')->find($conversationId); if (!$convo || ($convo->user1_id != $me->id && $convo->user2_id != $me->id)) { return response()->json(['error' => 'Not found'], 404); } if (empty(trim($body))) return response()->json(['error' => 'Empty'], 400); $id = \Illuminate\Support\Facades\DB::table('messages')->insertGetId([ 'conversation_id' => $conversationId, 'sender_id' => $me->id, 'body' => trim($body), 'type' => $request->input('type', 'text'), 'read' => false, 'created_at' => now(), 'updated_at' => now(), ]); \Illuminate\Support\Facades\DB::table('conversations') ->where('id', $conversationId) ->update(['last_message_at' => now(), 'updated_at' => now()]); return response()->json(\Illuminate\Support\Facades\DB::table('messages')->find($id)); }); // Unread count Route::get('/chat/unread', function (\Illuminate\Http\Request $request) { $email = $request->query('email'); $me = \App\Models\User::where('email', $email)->first(); if (!$me) return response()->json(['unread' => 0]); $convoIds = \Illuminate\Support\Facades\DB::table('conversations') ->where('user1_id', $me->id)->orWhere('user2_id', $me->id)->pluck('id'); $count = \Illuminate\Support\Facades\DB::table('messages') ->whereIn('conversation_id', $convoIds) ->where('sender_id', '!=', $me->id) ->where('read', false)->count(); return response()->json(['unread' => $count]); });