first commit
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
#[Signature('cache:clear-cities')]
|
||||
#[Description('Clear all cached city and attraction data')]
|
||||
class ClearCitiesCache extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Clearing cities cache...');
|
||||
|
||||
try {
|
||||
// Get the Redis connection used by cache
|
||||
$redis = Cache::getStore()->getRedis();
|
||||
$prefix = config('cache.prefix');
|
||||
|
||||
// Get all cache keys that match our patterns
|
||||
$patterns = [
|
||||
'wikipedia:*',
|
||||
'opentripmap:*',
|
||||
'overpass:*',
|
||||
];
|
||||
|
||||
$clearedCount = 0;
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$fullPattern = $prefix . $pattern;
|
||||
$keys = $redis->keys($fullPattern);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
// Remove the prefix to get the actual cache key
|
||||
$cacheKey = str_replace($prefix, '', $key);
|
||||
Cache::forget($cacheKey);
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($clearedCount === 0) {
|
||||
$this->comment('No cache entries found to clear.');
|
||||
} else {
|
||||
$this->info("Successfully cleared {$clearedCount} cache entries.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error clearing cache: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\FetchAttractionsJob;
|
||||
use App\Models\City;
|
||||
use App\Services\OpenTripMapService;
|
||||
use App\Services\OverpassService;
|
||||
use Illuminate\Console\Attributes\Argument;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('attractions:refresh {cityId}')]
|
||||
#[Description('Refresh attraction data for a specific city')]
|
||||
#[Argument('cityId', description: 'The ID of the city to refresh attractions for')]
|
||||
class RefreshAttractions extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(
|
||||
OpenTripMapService $openTripMapService,
|
||||
OverpassService $overpassService
|
||||
): int {
|
||||
$cityId = $this->argument('cityId');
|
||||
|
||||
// Find the city
|
||||
$city = City::find($cityId);
|
||||
|
||||
if (!$city) {
|
||||
$this->error("City with ID {$cityId} not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Refreshing attractions for {$city->name}, {$city->country}...");
|
||||
|
||||
if (!$city->latitude || !$city->longitude) {
|
||||
$this->error("City coordinates not set. Cannot fetch attractions.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear cache for this city's coordinates
|
||||
$this->info("Clearing cached data...");
|
||||
$openTripMapService->clearCache($city->latitude, $city->longitude);
|
||||
$overpassService->clearCache($city->latitude, $city->longitude);
|
||||
|
||||
// Delete existing attractions for this city
|
||||
$deletedCount = $city->attractions()->delete();
|
||||
$this->info("Deleted {$deletedCount} existing attractions.");
|
||||
|
||||
// Dispatch job to fetch new attractions
|
||||
$this->info("Fetching fresh attraction data...");
|
||||
FetchAttractionsJob::dispatch($city, true);
|
||||
|
||||
$this->info("Attractions refresh job dispatched successfully.");
|
||||
$this->comment("The job will process in the background. Check logs for completion status.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Error refreshing attractions: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\WikipediaService;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('test:wikipedia')]
|
||||
#[Description('Test Wikipedia API integration')]
|
||||
class TestWikipediaCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private WikipediaService $wikipediaService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Testing Wikipedia API integration...');
|
||||
$this->newLine();
|
||||
|
||||
// Test 1: Search for cities
|
||||
$this->info('Test 1: Searching for "Paris"');
|
||||
try {
|
||||
$results = $this->wikipediaService->searchCities('Paris');
|
||||
$this->line('Found ' . count($results) . ' results:');
|
||||
foreach (array_slice($results, 0, 3) as $result) {
|
||||
$this->line(' - ' . $result['title'] . ': ' . $result['description']);
|
||||
}
|
||||
$this->newLine();
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Search failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 2: Get city summary
|
||||
$this->info('Test 2: Getting summary for "Paris"');
|
||||
try {
|
||||
$cityData = $this->wikipediaService->getCitySummary('Paris');
|
||||
if ($cityData) {
|
||||
$this->line('Title: ' . $cityData['title']);
|
||||
$this->line('Summary: ' . substr($cityData['summary'], 0, 200) . '...');
|
||||
$this->line('Coordinates: ' . $cityData['latitude'] . ', ' . $cityData['longitude']);
|
||||
$this->line('URL: ' . $cityData['url']);
|
||||
} else {
|
||||
$this->warn('No data found for Paris');
|
||||
}
|
||||
$this->newLine();
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Summary fetch failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 3: Test caching
|
||||
$this->info('Test 3: Testing cache (fetching Paris again)');
|
||||
$start = microtime(true);
|
||||
$this->wikipediaService->getCitySummary('Paris');
|
||||
$elapsed = (microtime(true) - $start) * 1000;
|
||||
$this->line('Fetch time: ' . round($elapsed, 2) . 'ms (should be fast if cached)');
|
||||
$this->newLine();
|
||||
|
||||
$this->info('✓ All tests completed successfully!');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CityResource;
|
||||
use App\Http\Resources\AttractionResource;
|
||||
use App\Models\City;
|
||||
use App\Jobs\FetchCityDataJob;
|
||||
use App\Services\WikipediaService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CityController extends Controller
|
||||
{
|
||||
/**
|
||||
* Search for cities
|
||||
*/
|
||||
public function search(Request $request, WikipediaService $wikipediaService)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'q' => 'required|string|min:2|max:255',
|
||||
]);
|
||||
|
||||
$query = $validated['q'];
|
||||
|
||||
// Add to search history if session is available (for web routes)
|
||||
if ($request->hasSession()) {
|
||||
$this->addToSearchHistory($request, $query);
|
||||
}
|
||||
|
||||
// Search in local database first
|
||||
$localResults = $this->searchLocalCities($query);
|
||||
|
||||
$wikipediaResults = null;
|
||||
|
||||
// If no local results, fetch from Wikipedia and create new city
|
||||
if ($localResults->isEmpty()) {
|
||||
$newCity = $this->createCityFromWikipedia($query, $wikipediaService);
|
||||
|
||||
if ($newCity) {
|
||||
$localResults = collect([$newCity]);
|
||||
}
|
||||
}
|
||||
|
||||
$response = [
|
||||
'local' => CityResource::collection($localResults),
|
||||
'wikipedia' => $wikipediaResults,
|
||||
];
|
||||
|
||||
// Add recent searches if session is available
|
||||
if ($request->hasSession()) {
|
||||
$response['recent_searches'] = $request->session()->get('search_history', []);
|
||||
}
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add search query to session history
|
||||
* Note: This method should only be called when hasSession() is true
|
||||
*/
|
||||
private function addToSearchHistory(Request $request, string $query): void
|
||||
{
|
||||
if (!$request->hasSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$searchHistory = $request->session()->get('search_history', []);
|
||||
|
||||
// Remove duplicate if exists
|
||||
$searchHistory = array_filter($searchHistory, function($item) use ($query) {
|
||||
return strtolower($item) !== strtolower($query);
|
||||
});
|
||||
|
||||
// Add new search to the beginning
|
||||
array_unshift($searchHistory, $query);
|
||||
|
||||
// Keep only last 10 searches
|
||||
$searchHistory = array_slice($searchHistory, 0, 10);
|
||||
|
||||
$request->session()->put('search_history', $searchHistory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for cities in local database
|
||||
*/
|
||||
private function searchLocalCities(string $query)
|
||||
{
|
||||
return City::where('name', 'LIKE', "%{$query}%")
|
||||
->orWhere('country', 'LIKE', "%{$query}%")
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a city from Wikipedia data
|
||||
*/
|
||||
private function createCityFromWikipedia(string $query, WikipediaService $wikipediaService)
|
||||
{
|
||||
try {
|
||||
$wikipediaData = $wikipediaService->getCitySummary($query);
|
||||
|
||||
if (!$wikipediaData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$countryName = $this->fetchCountryName($wikipediaData['title']);
|
||||
|
||||
$city = City::create([
|
||||
'name' => $wikipediaData['title'],
|
||||
'country' => $countryName,
|
||||
'latitude' => $wikipediaData['latitude'] ?? null,
|
||||
'longitude' => $wikipediaData['longitude'] ?? null,
|
||||
'wikipedia_summary' => $wikipediaData['summary'] ?? null,
|
||||
'wikipedia_url' => $wikipediaData['url'] ?? null,
|
||||
'thumbnail_url' => $wikipediaData['thumbnail'] ?? null,
|
||||
'image_url' => $wikipediaData['image'] ?? null,
|
||||
'wikipedia_page_id' => $wikipediaData['page_id'] ?? null,
|
||||
'wikipedia_cached_at' => now(),
|
||||
]);
|
||||
|
||||
// Dispatch job to fetch attractions for the newly created city
|
||||
if ($city && $city->latitude && $city->longitude) {
|
||||
\App\Jobs\FetchAttractionsJob::dispatch($city);
|
||||
}
|
||||
|
||||
return $city;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to create city from Wikipedia', [
|
||||
'query' => $query,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch country name from external API
|
||||
*/
|
||||
private function fetchCountryName(string $cityName)
|
||||
{
|
||||
try {
|
||||
$response = Http::post('https://countriesnow.space/api/v0.1/countries/population/cities', [
|
||||
'city' => $cityName,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
return $data['data']['country'] ?? 'Unknown';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to fetch country name', [
|
||||
'city' => $cityName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified city
|
||||
*/
|
||||
public function show(City $city)
|
||||
{
|
||||
$city->loadCount('attractions');
|
||||
|
||||
// fetch attractions, or dispatch a job to fetch them
|
||||
|
||||
return new CityResource($city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attractions for a city with pagination
|
||||
*/
|
||||
public function attractions(Request $request, City $city)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'category' => 'nullable|string|max:50',
|
||||
'limit' => 'nullable|integer|min:1|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
$query = $city->attractions()
|
||||
->select(['id', 'city_id', 'name', 'description', 'latitude', 'longitude', 'category', 'rating', 'image_url'])
|
||||
->orderBy('name', 'asc');
|
||||
|
||||
// Filter by category if provided
|
||||
if ($request->has('category') && $request->category) {
|
||||
$query->where('category', $request->category);
|
||||
}
|
||||
|
||||
// Use pagination for better performance
|
||||
$perPage = $request->input('limit', 20);
|
||||
$attractions = $query->paginate($perPage);
|
||||
|
||||
return AttractionResource::collection($attractions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map data for a city with optimized query
|
||||
*/
|
||||
public function mapData(City $city)
|
||||
{
|
||||
// Eager load only necessary columns for map display
|
||||
$city->load(['attractions' => function ($query) {
|
||||
$query->select(['id', 'city_id', 'name', 'latitude', 'longitude', 'category']);
|
||||
}]);
|
||||
|
||||
return response()->json([
|
||||
'city' => [
|
||||
'name' => $city->name,
|
||||
'latitude' => (float) $city->latitude,
|
||||
'longitude' => (float) $city->longitude,
|
||||
],
|
||||
'attractions' => $city->attractions->map(function ($attraction) {
|
||||
return [
|
||||
'id' => $attraction->id,
|
||||
'name' => $attraction->name,
|
||||
'latitude' => (float) $attraction->latitude,
|
||||
'longitude' => (float) $attraction->longitude,
|
||||
'category' => $attraction->category,
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new city (Protected)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'country' => 'required|string|max:255',
|
||||
'latitude' => 'nullable|numeric|between:-90,90',
|
||||
'longitude' => 'nullable|numeric|between:-180,180',
|
||||
]);
|
||||
|
||||
$city = City::create($validated);
|
||||
|
||||
// Dispatch job to fetch data from Wikipedia
|
||||
FetchCityDataJob::dispatch($city);
|
||||
|
||||
return new CityResource($city);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CityResource;
|
||||
use App\Models\City;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get user's favorite cities
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$favorites = $request->user()
|
||||
->favoriteCities()
|
||||
->withCount('attractions')
|
||||
->get();
|
||||
|
||||
return CityResource::collection($favorites);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add city to favorites
|
||||
*/
|
||||
public function store(Request $request, City $city)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Check if already favorited
|
||||
if ($user->favoriteCities()->where('city_id', $city->id)->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'City already in favorites',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$user->favoriteCities()->attach($city->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'City added to favorites',
|
||||
'city' => new CityResource($city),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove city from favorites
|
||||
*/
|
||||
public function destroy(Request $request, City $city)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->favoriteCities()->detach($city->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'City removed from favorites',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\TripResource;
|
||||
use App\Models\Trip;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TripController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of user's trips
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$trips = $request->user()
|
||||
->trips()
|
||||
->withCount('cities')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return TripResource::collection($trips);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created trip
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'city_ids' => 'nullable|array',
|
||||
'city_ids.*' => 'exists:cities,id',
|
||||
]);
|
||||
|
||||
$trip = $request->user()->trips()->create([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
]);
|
||||
|
||||
// Attach cities if provided
|
||||
if (!empty($validated['city_ids'])) {
|
||||
$cityData = [];
|
||||
foreach ($validated['city_ids'] as $index => $cityId) {
|
||||
$cityData[$cityId] = ['order' => $index];
|
||||
}
|
||||
$trip->cities()->attach($cityData);
|
||||
}
|
||||
|
||||
$trip->load('cities')->loadCount('cities');
|
||||
|
||||
return new TripResource($trip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified trip
|
||||
*/
|
||||
public function show(Request $request, Trip $trip)
|
||||
{
|
||||
// Check if trip belongs to user
|
||||
if ($trip->user_id !== $request->user()->id) {
|
||||
return response()->json([
|
||||
'message' => 'Unauthorized',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$trip->load('cities')->loadCount('cities');
|
||||
|
||||
return new TripResource($trip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified trip
|
||||
*/
|
||||
public function update(Request $request, Trip $trip)
|
||||
{
|
||||
// Check if trip belongs to user
|
||||
if ($trip->user_id !== $request->user()->id) {
|
||||
return response()->json([
|
||||
'message' => 'Unauthorized',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'city_ids' => 'nullable|array',
|
||||
'city_ids.*' => 'exists:cities,id',
|
||||
]);
|
||||
|
||||
$trip->update([
|
||||
'name' => $validated['name'] ?? $trip->name,
|
||||
'description' => $validated['description'] ?? $trip->description,
|
||||
]);
|
||||
|
||||
// Update cities if provided
|
||||
if (isset($validated['city_ids'])) {
|
||||
$cityData = [];
|
||||
foreach ($validated['city_ids'] as $index => $cityId) {
|
||||
$cityData[$cityId] = ['order' => $index];
|
||||
}
|
||||
$trip->cities()->sync($cityData);
|
||||
}
|
||||
|
||||
$trip->load('cities')->loadCount('cities');
|
||||
|
||||
return new TripResource($trip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified trip
|
||||
*/
|
||||
public function destroy(Request $request, Trip $trip)
|
||||
{
|
||||
// Check if trip belongs to user
|
||||
if ($trip->user_id !== $request->user()->id) {
|
||||
return response()->json([
|
||||
'message' => 'Unauthorized',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$trip->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Trip deleted successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the authenticated user's profile
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
return new UserResource($request->user());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the authenticated user's profile
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'email' => 'sometimes|required|email|unique:users,email,' . $request->user()->id,
|
||||
]);
|
||||
|
||||
$request->user()->update($validated);
|
||||
|
||||
return new UserResource($request->user());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the login form
|
||||
*/
|
||||
public function showLoginForm()
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login request
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (Auth::attempt($credentials, $request->filled('remember'))) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'The provided credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout request
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the registration form
|
||||
*/
|
||||
public function showRegistrationForm()
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle registration request
|
||||
*/
|
||||
public function register(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
public function register(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['The provided credentials are incorrect.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user (Revoke token)
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Successfully logged out',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated user
|
||||
*/
|
||||
public function user(Request $request)
|
||||
{
|
||||
return response()->json($request->user());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\City;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CityController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the city details page
|
||||
*/
|
||||
public function show(City $city)
|
||||
{
|
||||
$city->load('attractions');
|
||||
|
||||
return view('cities.show', compact('city'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class AttractionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'latitude' => (float) $this->latitude,
|
||||
'longitude' => (float) $this->longitude,
|
||||
'category' => $this->category,
|
||||
'source' => $this->source,
|
||||
'rating' => $this->rating ? (float) $this->rating : null,
|
||||
'image_url' => $this->image_url,
|
||||
'city' => new CityResource($this->whenLoaded('city')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CityResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'name' => $this->name,
|
||||
'country' => $this->country,
|
||||
'latitude' => (float) $this->latitude,
|
||||
'longitude' => (float) $this->longitude,
|
||||
'wikipedia_summary' => $this->wikipedia_summary,
|
||||
'wikipedia_url' => $this->wikipedia_url,
|
||||
'attractions_count' => $this->whenCounted('attractions'),
|
||||
'attractions' => AttractionResource::collection($this->whenLoaded('attractions')),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TripResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'user' => new UserResource($this->whenLoaded('user')),
|
||||
'cities' => CityResource::collection($this->whenLoaded('cities')),
|
||||
'cities_count' => $this->whenCounted('cities'),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'email_verified_at' => $this->email_verified_at?->toISOString(),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\City;
|
||||
use App\Models\Attraction;
|
||||
use App\Services\OpenTripMapService;
|
||||
use App\Services\OverpassService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FetchAttractionsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 120;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public City $city,
|
||||
public bool $forceRefresh = false
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(
|
||||
OpenTripMapService $openTripMapService,
|
||||
OverpassService $overpassService
|
||||
): void {
|
||||
try {
|
||||
// Check if we need to refresh
|
||||
if (!$this->forceRefresh) {
|
||||
$lastAttraction = $this->city->attractions()
|
||||
->orderBy('cached_at', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastAttraction && $lastAttraction->cached_at) {
|
||||
$cacheAge = now()->diffInDays($lastAttraction->cached_at);
|
||||
$cacheTtl = config('services.opentripmap.cache_ttl', 7);
|
||||
|
||||
if ($cacheAge < $cacheTtl) {
|
||||
Log::info("Attractions data is still fresh, skipping fetch", [
|
||||
'city_id' => $this->city->id,
|
||||
'cache_age' => $cacheAge,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->city->latitude || !$this->city->longitude) {
|
||||
Log::warning("City coordinates not set, cannot fetch attractions", [
|
||||
'city_id' => $this->city->id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$attractionsCreated = 0;
|
||||
|
||||
// Fetch from OpenTripMap
|
||||
$openTripMapData = $openTripMapService->getAttractions(
|
||||
$this->city->latitude,
|
||||
$this->city->longitude
|
||||
);
|
||||
|
||||
foreach ($openTripMapData as $data) {
|
||||
$this->createOrUpdateAttraction($data, 'opentripmap');
|
||||
$attractionsCreated++;
|
||||
}
|
||||
|
||||
// Fetch from Overpass
|
||||
$overpassData = $overpassService->getPointsOfInterest(
|
||||
$this->city->latitude,
|
||||
$this->city->longitude
|
||||
);
|
||||
|
||||
foreach ($overpassData as $data) {
|
||||
$this->createOrUpdateAttraction($data, 'overpass');
|
||||
$attractionsCreated++;
|
||||
}
|
||||
|
||||
Log::info("Attractions fetched successfully", [
|
||||
'city_id' => $this->city->id,
|
||||
'city_name' => $this->city->name,
|
||||
'attractions_created' => $attractionsCreated,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error in FetchAttractionsJob", [
|
||||
'city_id' => $this->city->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an attraction
|
||||
*/
|
||||
private function createOrUpdateAttraction(array $data, string $source): void
|
||||
{
|
||||
Attraction::updateOrCreate(
|
||||
[
|
||||
'city_id' => $this->city->id,
|
||||
'source' => $source,
|
||||
'source_id' => $data['source_id'],
|
||||
],
|
||||
[
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'latitude' => $data['latitude'],
|
||||
'longitude' => $data['longitude'],
|
||||
'category' => $data['category'] ?? 'other',
|
||||
'rating' => $data['rating'] ?? null,
|
||||
'image_url' => $data['image_url'] ?? null,
|
||||
'cached_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\City;
|
||||
use App\Services\WikipediaService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FetchCityDataJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 60;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public City $city,
|
||||
public bool $forceRefresh = false
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(WikipediaService $wikipediaService): void
|
||||
{
|
||||
try {
|
||||
// Check if we need to refresh the data
|
||||
if (!$this->forceRefresh && $this->city->wikipedia_cached_at) {
|
||||
$cacheAge = now()->diffInDays($this->city->wikipedia_cached_at);
|
||||
$cacheTtl = config('services.wikipedia.cache_ttl', 30);
|
||||
|
||||
if ($cacheAge < $cacheTtl) {
|
||||
Log::info("City data is still fresh, skipping fetch", [
|
||||
'city_id' => $this->city->id,
|
||||
'cache_age' => $cacheAge,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data from Wikipedia
|
||||
$data = $wikipediaService->getCitySummary($this->city->name);
|
||||
|
||||
if ($data) {
|
||||
// Update city with Wikipedia data
|
||||
$this->city->update([
|
||||
'wikipedia_summary' => $data['summary'],
|
||||
'wikipedia_url' => $data['url'],
|
||||
'thumbnail_url' => $data['thumbnail'] ?? null,
|
||||
'image_url' => $data['image'] ?? null,
|
||||
'wikipedia_page_id' => $data['page_id'],
|
||||
'wikipedia_cached_at' => now(),
|
||||
]);
|
||||
|
||||
// Update coordinates if they exist in Wikipedia data and not set
|
||||
if (isset($data['latitude']) && isset($data['longitude'])) {
|
||||
if (!$this->city->latitude || !$this->city->longitude) {
|
||||
$this->city->update([
|
||||
'latitude' => $data['latitude'],
|
||||
'longitude' => $data['longitude'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("City data fetched successfully", [
|
||||
'city_id' => $this->city->id,
|
||||
'city_name' => $this->city->name,
|
||||
]);
|
||||
|
||||
// Dispatch job to fetch attractions
|
||||
FetchAttractionsJob::dispatch($this->city);
|
||||
} else {
|
||||
Log::warning("Failed to fetch city data from Wikipedia", [
|
||||
'city_id' => $this->city->id,
|
||||
'city_name' => $this->city->name,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error in FetchCityDataJob", [
|
||||
'city_id' => $this->city->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e; // Re-throw to trigger retry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Attraction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $fillable = [
|
||||
'city_id',
|
||||
'name',
|
||||
'description',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'category',
|
||||
'source',
|
||||
'source_id',
|
||||
'rating',
|
||||
'image_url',
|
||||
'cached_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'rating' => 'decimal:2',
|
||||
'cached_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the city that owns this attraction
|
||||
*/
|
||||
public function city(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(City::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class City extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'country',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'wikipedia_summary',
|
||||
'wikipedia_url',
|
||||
'thumbnail_url',
|
||||
'image_url',
|
||||
'wikipedia_page_id',
|
||||
'wikipedia_cached_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'wikipedia_cached_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the route key for the model.
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the model and set up event listeners
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($city) {
|
||||
if (empty($city->slug)) {
|
||||
$city->slug = static::generateUniqueSlug($city->name);
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($city) {
|
||||
if ($city->isDirty('name') && empty($city->slug)) {
|
||||
$city->slug = static::generateUniqueSlug($city->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique slug from city name
|
||||
*/
|
||||
protected static function generateUniqueSlug(string $name): string
|
||||
{
|
||||
$slug = Str::slug($name);
|
||||
$originalSlug = $slug;
|
||||
$count = 1;
|
||||
|
||||
while (static::where('slug', $slug)->exists()) {
|
||||
$slug = $originalSlug . '-' . $count;
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attractions for this city
|
||||
*/
|
||||
public function attractions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attraction::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all trips that include this city
|
||||
*/
|
||||
public function trips(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Trip::class, 'city_trip')
|
||||
->withPivot('order')
|
||||
->withTimestamps()
|
||||
->orderByPivot('order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users who favorited this city
|
||||
*/
|
||||
public function favoritedBy(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'favorites')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Favorite extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'city_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns this favorite
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the city that was favorited
|
||||
*/
|
||||
public function city(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(City::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Trip extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that owns this trip
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cities in this trip
|
||||
*/
|
||||
public function cities(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(City::class, 'city_trip')
|
||||
->withPivot('order')
|
||||
->withTimestamps()
|
||||
->orderByPivot('order');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, HasApiTokens;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all favorites for this user
|
||||
*/
|
||||
public function favorites(): HasMany
|
||||
{
|
||||
return $this->hasMany(Favorite::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all favorite cities for this user
|
||||
*/
|
||||
public function favoriteCities(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(City::class, 'favorites')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all trips for this user
|
||||
*/
|
||||
public function trips(): HasMany
|
||||
{
|
||||
return $this->hasMany(Trip::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OpenTripMapService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $apiKey;
|
||||
private int $cacheTtl;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = 'https://api.opentripmap.com/0.1/en/places';
|
||||
$this->apiKey = config('services.opentripmap.key', env('OPENTRIPMAP_API_KEY'));
|
||||
$this->cacheTtl = config('services.opentripmap.cache_ttl', 7) * 24 * 60; // Convert days to minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attractions near coordinates
|
||||
*
|
||||
* @param float $lat
|
||||
* @param float $lon
|
||||
* @param int $radius in meters (default 5000m = 5km)
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function getAttractions(float $lat, float $lon, int $radius = 5000, int $limit = 50): array
|
||||
{
|
||||
$cacheKey = "opentripmap:attractions:{$lat}:{$lon}:{$radius}:{$limit}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($lat, $lon, $radius, $limit) {
|
||||
try {
|
||||
$response = Http::timeout(15)
|
||||
->get("{$this->baseUrl}/radius", [
|
||||
'apikey' => $this->apiKey,
|
||||
'radius' => $radius,
|
||||
'lon' => $lon,
|
||||
'lat' => $lat,
|
||||
'limit' => $limit,
|
||||
'format' => 'json',
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$features = $response->json('features', []);
|
||||
$attractions = [];
|
||||
|
||||
foreach ($features as $feature) {
|
||||
$properties = $feature['properties'] ?? [];
|
||||
$geometry = $feature['geometry'] ?? [];
|
||||
$coordinates = $geometry['coordinates'] ?? [];
|
||||
|
||||
if (!empty($properties['name'])) {
|
||||
$attractions[] = [
|
||||
'name' => $properties['name'],
|
||||
'source_id' => $properties['xid'] ?? null,
|
||||
'latitude' => $coordinates[1] ?? null,
|
||||
'longitude' => $coordinates[0] ?? null,
|
||||
'category' => $this->mapCategory($properties['kinds'] ?? ''),
|
||||
'rating' => $properties['rate'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $attractions;
|
||||
}
|
||||
|
||||
Log::warning("OpenTripMap API request failed", [
|
||||
'status' => $response->status(),
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
]);
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("OpenTripMap API error", [
|
||||
'error' => $e->getMessage(),
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about an attraction
|
||||
*
|
||||
* @param string $xid OpenTripMap attraction ID
|
||||
* @return array|null
|
||||
*/
|
||||
public function getAttractionDetails(string $xid): ?array
|
||||
{
|
||||
$cacheKey = "opentripmap:details:{$xid}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($xid) {
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->get("{$this->baseUrl}/xid/{$xid}", [
|
||||
'apikey' => $this->apiKey,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'name' => $data['name'] ?? null,
|
||||
'description' => $data['wikipedia_extracts']['text'] ?? $data['info']['descr'] ?? null,
|
||||
'image_url' => $data['preview']['source'] ?? $data['image'] ?? null,
|
||||
'wikipedia_url' => $data['wikipedia'] ?? null,
|
||||
'rating' => $data['rate'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("OpenTripMap details error for xid: {$xid}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenTripMap categories to our simplified categories
|
||||
*
|
||||
* @param string $kinds
|
||||
* @return string
|
||||
*/
|
||||
private function mapCategory(string $kinds): string
|
||||
{
|
||||
$kinds = strtolower($kinds);
|
||||
|
||||
if (str_contains($kinds, 'museums')) {
|
||||
return 'museum';
|
||||
} elseif (str_contains($kinds, 'theatres_and_entertainments') || str_contains($kinds, 'cinemas')) {
|
||||
return 'entertainment';
|
||||
} elseif (str_contains($kinds, 'cultural') || str_contains($kinds, 'interesting_places')) {
|
||||
return 'cultural';
|
||||
} elseif (str_contains($kinds, 'architecture') || str_contains($kinds, 'historic')) {
|
||||
return 'historic';
|
||||
} elseif (str_contains($kinds, 'natural') || str_contains($kinds, 'nature_reserves')) {
|
||||
return 'nature';
|
||||
} elseif (str_contains($kinds, 'sport')) {
|
||||
return 'sport';
|
||||
} elseif (str_contains($kinds, 'food') || str_contains($kinds, 'restaurants')) {
|
||||
return 'restaurant';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for specific coordinates
|
||||
*
|
||||
* @param float $lat
|
||||
* @param float $lon
|
||||
* @return void
|
||||
*/
|
||||
public function clearCache(float $lat, float $lon): void
|
||||
{
|
||||
// Clear all related cache keys
|
||||
Cache::forget("opentripmap:attractions:{$lat}:{$lon}:5000:50");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OverpassService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private int $cacheTtl;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = config('services.overpass.url', 'https://overpass-api.de/api/interpreter');
|
||||
$this->cacheTtl = config('services.overpass.cache_ttl', 7) * 24 * 60; // Convert days to minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get points of interest near coordinates
|
||||
*
|
||||
* @param float $lat
|
||||
* @param float $lon
|
||||
* @param int $radius in meters (default 5000m = 5km)
|
||||
* @return array
|
||||
*/
|
||||
public function getPointsOfInterest(float $lat, float $lon, int $radius = 5000): array
|
||||
{
|
||||
$cacheKey = "overpass:poi:{$lat}:{$lon}:{$radius}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($lat, $lon, $radius) {
|
||||
try {
|
||||
// Overpass QL query for tourism and amenity nodes
|
||||
$query = "[out:json][timeout:25];
|
||||
(
|
||||
node[\"tourism\"~\"attraction|museum|artwork|viewpoint\"](around:{$radius},{$lat},{$lon});
|
||||
node[\"amenity\"~\"restaurant|cafe|bar|theatre|cinema\"](around:{$radius},{$lat},{$lon});
|
||||
node[\"historic\"](around:{$radius},{$lat},{$lon});
|
||||
);
|
||||
out body;
|
||||
>;
|
||||
out skel qt;";
|
||||
|
||||
$response = Http::timeout(30)
|
||||
->asForm()
|
||||
->post($this->baseUrl, [
|
||||
'data' => $query,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$elements = $data['elements'] ?? [];
|
||||
$attractions = [];
|
||||
|
||||
foreach ($elements as $element) {
|
||||
if ($element['type'] === 'node' && !empty($element['tags']['name'])) {
|
||||
$tags = $element['tags'];
|
||||
|
||||
$attractions[] = [
|
||||
'name' => $tags['name'],
|
||||
'source_id' => (string) $element['id'],
|
||||
'latitude' => $element['lat'],
|
||||
'longitude' => $element['lon'],
|
||||
'category' => $this->determineCategory($tags),
|
||||
'description' => $tags['description'] ?? null,
|
||||
'website' => $tags['website'] ?? null,
|
||||
'opening_hours' => $tags['opening_hours'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $attractions;
|
||||
}
|
||||
|
||||
Log::warning("Overpass API request failed", [
|
||||
'status' => $response->status(),
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
]);
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Overpass API error", [
|
||||
'error' => $e->getMessage(),
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine category from OSM tags
|
||||
*
|
||||
* @param array $tags
|
||||
* @return string
|
||||
*/
|
||||
private function determineCategory(array $tags): string
|
||||
{
|
||||
// Tourism tags
|
||||
if (isset($tags['tourism'])) {
|
||||
return match ($tags['tourism']) {
|
||||
'museum' => 'museum',
|
||||
'artwork', 'gallery' => 'cultural',
|
||||
'attraction', 'viewpoint' => 'attraction',
|
||||
'hotel', 'hostel', 'guest_house' => 'accommodation',
|
||||
default => 'tourism',
|
||||
};
|
||||
}
|
||||
|
||||
// Amenity tags
|
||||
if (isset($tags['amenity'])) {
|
||||
return match ($tags['amenity']) {
|
||||
'restaurant', 'fast_food', 'food_court' => 'restaurant',
|
||||
'cafe', 'bar', 'pub' => 'cafe',
|
||||
'theatre', 'cinema' => 'entertainment',
|
||||
'library' => 'cultural',
|
||||
default => 'amenity',
|
||||
};
|
||||
}
|
||||
|
||||
// Historic tags
|
||||
if (isset($tags['historic'])) {
|
||||
return 'historic';
|
||||
}
|
||||
|
||||
// Leisure tags
|
||||
if (isset($tags['leisure'])) {
|
||||
return match ($tags['leisure']) {
|
||||
'park', 'garden' => 'park',
|
||||
'sports_centre', 'stadium' => 'sport',
|
||||
default => 'leisure',
|
||||
};
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get restaurants and cafes near coordinates
|
||||
*
|
||||
* @param float $lat
|
||||
* @param float $lon
|
||||
* @param int $radius
|
||||
* @return array
|
||||
*/
|
||||
public function getRestaurants(float $lat, float $lon, int $radius = 2000): array
|
||||
{
|
||||
$cacheKey = "overpass:restaurants:{$lat}:{$lon}:{$radius}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($lat, $lon, $radius) {
|
||||
try {
|
||||
$query = "[out:json][timeout:25];
|
||||
(
|
||||
node[\"amenity\"=\"restaurant\"](around:{$radius},{$lat},{$lon});
|
||||
node[\"amenity\"=\"cafe\"](around:{$radius},{$lat},{$lon});
|
||||
node[\"amenity\"=\"bar\"](around:{$radius},{$lat},{$lon});
|
||||
);
|
||||
out body;";
|
||||
|
||||
$response = Http::timeout(30)
|
||||
->asForm()
|
||||
->post($this->baseUrl, [
|
||||
'data' => $query,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$elements = $data['elements'] ?? [];
|
||||
$restaurants = [];
|
||||
|
||||
foreach ($elements as $element) {
|
||||
if ($element['type'] === 'node' && !empty($element['tags']['name'])) {
|
||||
$tags = $element['tags'];
|
||||
|
||||
$restaurants[] = [
|
||||
'name' => $tags['name'],
|
||||
'source_id' => (string) $element['id'],
|
||||
'latitude' => $element['lat'],
|
||||
'longitude' => $element['lon'],
|
||||
'category' => 'restaurant',
|
||||
'cuisine' => $tags['cuisine'] ?? null,
|
||||
'phone' => $tags['phone'] ?? null,
|
||||
'website' => $tags['website'] ?? null,
|
||||
'opening_hours' => $tags['opening_hours'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $restaurants;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Overpass restaurants error", [
|
||||
'error' => $e->getMessage(),
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for specific coordinates
|
||||
*
|
||||
* @param float $lat
|
||||
* @param float $lon
|
||||
* @return void
|
||||
*/
|
||||
public function clearCache(float $lat, float $lon): void
|
||||
{
|
||||
Cache::forget("overpass:poi:{$lat}:{$lon}:5000");
|
||||
Cache::forget("overpass:restaurants:{$lat}:{$lon}:2000");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WikipediaService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private int $cacheTtl;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = config('services.wikipedia.url', 'https://en.wikipedia.org/api/rest_v1');
|
||||
$this->cacheTtl = config('services.wikipedia.cache_ttl', 30) * 24 * 60; // Convert days to minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get city summary from Wikipedia
|
||||
*
|
||||
* @param string $cityName
|
||||
* @return array|null
|
||||
*/
|
||||
public function getCitySummary(string $cityName): ?array
|
||||
{
|
||||
$cacheKey = "wikipedia:summary:{$cityName}";
|
||||
|
||||
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($cityName) {
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => 'WikiTravelApp/1.0 (contact@example.com)'
|
||||
])
|
||||
->timeout(10)
|
||||
->get("{$this->baseUrl}/page/summary/" . urlencode($cityName));
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
Log::info("Wiki message ". json_encode($data));
|
||||
|
||||
return [
|
||||
'title' => $data['title'] ?? null,
|
||||
'country' => $data['description'] ?? null,
|
||||
'summary' => $data['extract'] ?? null,
|
||||
'url' => $data['content_urls']['desktop']['page'] ?? null,
|
||||
'page_id' => $data['pageid'] ?? null,
|
||||
'thumbnail' => $data['thumbnail']['source'] ?? null,
|
||||
'image' => $data['originalimage']['source'] ?? null,
|
||||
'latitude' => $data['coordinates']['lat'] ?? null,
|
||||
'longitude' => $data['coordinates']['lon'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
Log::warning("Wikipedia API request failed for city: {$cityName}", [
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Wikipedia API error for city: {$cityName}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for cities on Wikipedia
|
||||
*
|
||||
* @param string $query
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function searchCities(string $query, int $limit = 10): array
|
||||
{
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => 'WikiTravelApp/1.0 (contact@example.com)'
|
||||
])
|
||||
->timeout(10)
|
||||
->get("{$this->baseUrl}/../w/api.php", [
|
||||
'action' => 'opensearch',
|
||||
'search' => $query,
|
||||
'limit' => $limit,
|
||||
'namespace' => 0,
|
||||
'format' => 'json',
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
|
||||
$results = [];
|
||||
$titles = $data[1] ?? [];
|
||||
$descriptions = $data[2] ?? [];
|
||||
$urls = $data[3] ?? [];
|
||||
|
||||
foreach ($titles as $index => $title) {
|
||||
$results[] = [
|
||||
'title' => $title,
|
||||
'description' => $descriptions[$index] ?? '',
|
||||
'url' => $urls[$index] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Wikipedia search error for query: {$query}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific city
|
||||
*
|
||||
* @param string $cityName
|
||||
* @return void
|
||||
*/
|
||||
public function clearCache(string $cityName): void
|
||||
{
|
||||
Cache::forget("wikipedia:summary:{$cityName}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user