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,
]);
});