diff --git a/app/Http/Controllers/Api/ReservationController.php b/app/Http/Controllers/Api/ReservationController.php index 49ba763..d06f5c9 100644 --- a/app/Http/Controllers/Api/ReservationController.php +++ b/app/Http/Controllers/Api/ReservationController.php @@ -6,8 +6,6 @@ use App\Http\Controllers\Controller; use App\Models\Reservation; use App\Models\Notification; use App\Models\ReservationStatusHistory; -use App\Models\ReservationFee; -use App\Models\PricingRule; use App\Models\ReservationTableAssignment; use App\Models\VenueTable; use App\Models\ReservationReminder; @@ -304,12 +302,11 @@ class ReservationController extends Controller } $reservation = null; - $fee = null; $table = null; $reminder = null; try { - DB::transaction(function () use ($validated, $user, &$reservation, &$fee, &$table, &$reminder) { + DB::transaction(function () use ($validated, $user, &$reservation, &$table, &$reminder) { $reservation = Reservation::create([ 'code' => $this->generateCode(), 'customer_id' => $user->id, @@ -332,7 +329,6 @@ class ReservationController extends Controller 'venue_table_id' => $table->id, ]); - $fee = $this->createReservationFee($reservation); $reminder = $this->createReservationReminder($reservation); $this->logStatusChange($reservation, null, 'pending', $user->id); }); @@ -350,12 +346,6 @@ class ReservationController extends Controller 'reservation_time' => $reservation->reservation_time, 'party_size' => $reservation->party_size, ], - 'fee' => [ - 'price_per_person' => $fee->price_per_person, - 'party_size' => $fee->party_size, - 'total_amount' => $fee->total_amount, - 'currency' => $fee->currency, - ], 'table' => [ 'id' => $table->id, 'name' => $table->name, @@ -377,60 +367,6 @@ class ReservationController extends Controller return $code; } - private function createReservationFee(Reservation $reservation): ReservationFee - { - $venue = $reservation->venue ?: Venue::find($reservation->venue_id); - - $rule = $this->getPricingRule($venue); - $pricePerPerson = $rule ? (float) $rule->price_per_person : 0.0; - $total = $pricePerPerson * (int) $reservation->party_size; - $currency = env('APP_CURRENCY', 'USD'); - - return ReservationFee::create([ - 'reservation_id' => $reservation->id, - 'pricing_rule_id' => $rule?->id, - 'price_per_person' => $pricePerPerson, - 'party_size' => $reservation->party_size, - 'total_amount' => $total, - 'currency' => $currency, - ]); - } - - private function getPricingRule(?Venue $venue): ?PricingRule - { - if (! $venue) { - return PricingRule::where('scope', 'global_type') - ->where('is_active', true) - ->latest('id') - ->first(); - } - - $venueRule = PricingRule::where('scope', 'venue') - ->where('venue_id', $venue->id) - ->where('is_active', true) - ->latest('id') - ->first(); - - if ($venueRule) { - return $venueRule; - } - - $typeRule = PricingRule::where('scope', 'global_type') - ->where('venue_type', (string) $venue->type) - ->where('is_active', true) - ->latest('id') - ->first(); - - if ($typeRule) { - return $typeRule; - } - - return PricingRule::where('scope', 'global_type') - ->where('is_active', true) - ->latest('id') - ->first(); - } - private function findAvailableTable(Reservation $reservation, bool $includePending, bool $lockRows = false): ?VenueTable { $durationMinutes = (int) env('RESERVATION_DURATION_MINUTES', 90); diff --git a/app/Http/Controllers/Api/VenueController.php b/app/Http/Controllers/Api/VenueController.php index a02a341..53fcda3 100644 --- a/app/Http/Controllers/Api/VenueController.php +++ b/app/Http/Controllers/Api/VenueController.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; class VenueController extends Controller @@ -77,6 +78,21 @@ class VenueController extends Controller ]); } + // PUBLIC FILE PROXY (serves files from storage/app/public without relying on /public/storage symlink) + public function publicFile(string $path) + { + $normalized = ltrim(trim($path), '/'); + if ($normalized === '' || str_contains($normalized, '..')) { + abort(404); + } + + if (! Storage::disk('public')->exists($normalized)) { + abort(404); + } + + return Storage::disk('public')->response($normalized); + } + // CREATE VENUE (vendor only) public function store(Request $request) { @@ -453,7 +469,8 @@ class VenueController extends Controller if ($request->hasFile('images')) { foreach ($request->file('images') as $image) { $path = $image->store('venues', 'public'); - $urls[] = rtrim($request->getSchemeAndHttpHost(), '/') . '/storage/' . ltrim($path, '/'); + // Store a relative public-storage path to avoid environment-specific hosts (localhost/127.0.0.1). + $urls[] = '/storage/' . ltrim($path, '/'); } } @@ -568,7 +585,11 @@ class VenueController extends Controller private function resolveImageUrls(Venue $venue): array { if ($this->hasInlineVenueArrays()) { - return is_array($venue->image_urls) ? $venue->image_urls : []; + return collect(is_array($venue->image_urls) ? $venue->image_urls : []) + ->map(fn ($url) => $this->normalizePublicAssetUrl($url)) + ->filter(fn ($url) => $url !== '') + ->values() + ->all(); } if ($this->hasStructuredVenueData()) { @@ -579,6 +600,8 @@ class VenueController extends Controller return ($venue->images ?? collect()) ->sortBy('sort_order') ->pluck('url') + ->map(fn ($url) => $this->normalizePublicAssetUrl($url)) + ->filter(fn ($url) => $url !== '') ->values() ->all(); } @@ -589,7 +612,18 @@ class VenueController extends Controller private function resolveOffers(Venue $venue): array { if ($this->hasInlineVenueArrays()) { - return is_array($venue->offers) ? $venue->offers : []; + return collect(is_array($venue->offers) ? $venue->offers : []) + ->map(function ($offer) { + if (! is_array($offer)) { + return null; + } + + $offer['image_url'] = $this->normalizePublicAssetUrl($offer['image_url'] ?? null) ?: null; + return $offer; + }) + ->filter() + ->values() + ->all(); } if ($this->hasStructuredVenueData()) { @@ -601,7 +635,7 @@ class VenueController extends Controller ->map(fn (Offer $offer) => [ 'title' => $offer->title, 'description' => $offer->description, - 'image_url' => $offer->image_url, + 'image_url' => $this->normalizePublicAssetUrl($offer->image_url) ?: null, 'is_active' => (bool) $offer->is_active, ]) ->values() @@ -712,4 +746,105 @@ class VenueController extends Controller } } } + + private function normalizePublicAssetUrl(mixed $value): string + { + $raw = trim((string) ($value ?? '')); + if ($raw === '') { + return ''; + } + + $appUrl = (string) config('app.url', ''); + $appHost = parse_url($appUrl, PHP_URL_HOST); + $appScheme = parse_url($appUrl, PHP_URL_SCHEME) ?: 'https'; + + // Relative public storage paths -> proxy route + if (str_starts_with($raw, '/storage/')) { + return $this->publicStorageProxyUrl(substr($raw, strlen('/storage/'))); + } + if (str_starts_with($raw, 'storage/')) { + return $this->publicStorageProxyUrl(substr($raw, strlen('storage/'))); + } + + if (! preg_match('#^https?://#i', $raw)) { + return $raw; + } + + $parts = parse_url($raw); + if (! is_array($parts) || empty($parts['host'])) { + return $raw; + } + + $host = strtolower((string) $parts['host']); + $path = (string) ($parts['path'] ?? ''); + + // Any URL pointing to /storage/* should be served through the API proxy to avoid /storage symlink issues. + if (str_starts_with($path, '/storage/')) { + return $this->publicStorageProxyUrl(substr($path, strlen('/storage/'))); + } + + // Rewrite local/private hosts to the configured app host. + if ($this->isLocalOrPrivateHost($host) && ! empty($appHost)) { + $port = parse_url($appUrl, PHP_URL_PORT); + $rewritten = $parts; + $rewritten['scheme'] = $appScheme; + $rewritten['host'] = (string) $appHost; + if ($port !== null) { + $rewritten['port'] = (int) $port; + } else { + unset($rewritten['port']); + } + return $this->buildUrlFromParts($rewritten); + } + + // Prefer HTTPS for hosted domains and the current app host. + $isHostedGhaymah = str_ends_with($host, '.hosted.ghaymah.systems'); + if (($isHostedGhaymah || (! empty($appHost) && $host === strtolower((string) $appHost))) + && (($parts['scheme'] ?? 'http') === 'http')) { + $parts['scheme'] = 'https'; + return $this->buildUrlFromParts($parts); + } + + return $raw; + } + + private function publicStorageProxyUrl(string $relativePath): string + { + $clean = ltrim($relativePath, '/'); + if ($clean === '') { + return ''; + } + + $base = rtrim((string) config('app.url', ''), '/'); + if ($base === '') { + $base = request()->getSchemeAndHttpHost(); + } + + return $base . '/api/public-files/' . str_replace('%2F', '/', rawurlencode($clean)); + } + + private function isLocalOrPrivateHost(string $host): bool + { + if (in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0'], true)) { + return true; + } + + if (preg_match('/^(10\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[0-1])\\.)/', $host) === 1) { + return true; + } + + return false; + } + + private function buildUrlFromParts(array $parts): string + { + $scheme = (string) ($parts['scheme'] ?? 'https'); + $host = (string) ($parts['host'] ?? ''); + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + $path = (string) ($parts['path'] ?? ''); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; + + return $scheme . '://' . $host . $port . $path . $query . $fragment; + } } diff --git a/database/migrations/2026_02_13_120000_create_pricing_rules_table.php b/database/migrations/2026_02_13_120000_create_pricing_rules_table.php index 1e35abe..2b93c37 100644 --- a/database/migrations/2026_02_13_120000_create_pricing_rules_table.php +++ b/database/migrations/2026_02_13_120000_create_pricing_rules_table.php @@ -10,7 +10,8 @@ return new class extends Migration { Schema::create('pricing_rules', function (Blueprint $table) { $table->id(); - $table->enum('scope', ['global', 'type', 'venue']); + // Keep enum values aligned with the PostgreSQL schema (`pricing_scope_enum`). + $table->enum('scope', ['global_type', 'venue']); $table->string('venue_type')->nullable(); $table->foreignId('venue_id')->nullable()->constrained('venues')->nullOnDelete(); $table->decimal('price_per_person', 10, 2); diff --git a/routes/api.php b/routes/api.php index 677adfa..d5c06ec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,6 +29,9 @@ Route::middleware(['auth:sanctum', 'role:customer'])->get('/customer-only', func return response()->json(['ok' => true]); }); +Route::get('/public-files/{path}', [VenueController::class, 'publicFile']) + ->where('path', '.*'); + Route::get('/venues', [VenueController::class, 'index']); Route::get('/venues/{id}', [VenueController::class, 'show']); Route::middleware(['auth:sanctum', 'role:vendor'])->post('/venues', [VenueController::class, 'store']);