From 090427cf2cf3574074e5710db77166ff2d71a213 Mon Sep 17 00:00:00 2001 From: Abdul Kareem Date: Sun, 22 Feb 2026 01:17:35 +0300 Subject: [PATCH] Fix API reliability and CORS config for hosted deployment --- .env.example | 5 +- Dockerfile | 12 +--- .../Commands/SendReservationReminders.php | 5 +- app/Http/Controllers/Api/AdminController.php | 4 ++ .../Controllers/Api/FeedbackController.php | 40 +++++++++++ .../Controllers/Api/ReservationController.php | 5 +- app/Http/Controllers/Api/VenueController.php | 69 ++++++++++++++++--- app/Models/Notification.php | 1 + app/Models/PricingRule.php | 1 + app/Models/ReservationFee.php | 12 ++++ app/Models/ReservationReminder.php | 14 ++++ app/Models/Role.php | 1 + app/Models/SeatingArea.php | 3 +- app/Models/VenueTable.php | 29 ++++++++ bootstrap/app.php | 3 + config/cors.php | 27 ++++++++ ...0000_ensure_user_feedback_table_exists.php | 28 ++++++++ ...ser_id_nullable_in_user_feedback_table.php | 28 ++++++++ docker/entrypoint.sh | 1 + routes/api.php | 1 + 20 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 config/cors.php create mode 100644 database/migrations/2026_02_19_160000_ensure_user_feedback_table_exists.php create mode 100644 database/migrations/2026_02_20_000001_make_user_id_nullable_in_user_feedback_table.php diff --git a/.env.example b/.env.example index 92abc36..25a8401 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ APP_NAME=Laravel APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=https://tabeleymvp2-8b111c91640a.hosted.ghaymah.systems APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -26,12 +26,15 @@ DB_PORT=5432 DB_DATABASE=tabeley_db DB_USERNAME=postgres DB_PASSWORD=secret +DB_SSLMODE=prefer SESSION_DRIVER=file SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null +SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000,tabeleymvp2-8b111c91640a.hosted.ghaymah.systems +CORS_ALLOWED_ORIGINS=https://tabeleymvp2-8b111c91640a.hosted.ghaymah.systems BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/Dockerfile b/Dockerfile index fc2a8bf..8afe503 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.2-cli +FROM php:8.2-fpm # Install system dependencies RUN apt-get update && apt-get install -y \ @@ -8,9 +8,7 @@ RUN apt-get update && apt-get install -y \ libonig-dev \ libxml2-dev \ zip \ - unzip \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* + unzip # Install PHP extensions RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd @@ -29,11 +27,7 @@ RUN composer install --no-interaction --no-dev --optimize-autoloader # Set permissions RUN chown -R www-data:www-data /var/www \ - && chmod -R 755 /var/www/storage \ - && chmod -R 755 /var/www/bootstrap/cache + && chmod -R 755 /var/www/storage -# Expose port 8000 EXPOSE 8000 - -# Start Laravel development server CMD php artisan serve --host=0.0.0.0 --port=8000 diff --git a/app/Console/Commands/SendReservationReminders.php b/app/Console/Commands/SendReservationReminders.php index 423ec81..97c2d47 100644 --- a/app/Console/Commands/SendReservationReminders.php +++ b/app/Console/Commands/SendReservationReminders.php @@ -26,7 +26,7 @@ class SendReservationReminders extends Command $reservations = Reservation::query() ->where('status', 'approved') ->whereRaw( - "STR_TO_DATE(CONCAT(reservation_date,' ',reservation_time), '%Y-%m-%d %H:%i:%s') BETWEEN ? AND ?", + "(reservation_date::text || ' ' || reservation_time::text)::timestamp BETWEEN ? AND ?", [$now->format('Y-m-d H:i:s'), $windowEnd->format('Y-m-d H:i:s')] ) ->get(); @@ -42,13 +42,14 @@ class SendReservationReminders extends Command ReservationReminder::create([ 'reservation_id' => $reservation->id, + 'user_id' => $reservation->customer_id, 'send_at' => $sendAt, 'sent_at' => now(), ]); Notification::create([ 'user_id' => $reservation->customer_id, - 'type' => 'reservation_reminder', + 'type' => 'reminder', 'title' => 'Reservation reminder', 'body' => 'Your reservation is coming up in about ' . $minutes . ' minutes.', 'data_json' => [ diff --git a/app/Http/Controllers/Api/AdminController.php b/app/Http/Controllers/Api/AdminController.php index 086055b..d9d16e9 100644 --- a/app/Http/Controllers/Api/AdminController.php +++ b/app/Http/Controllers/Api/AdminController.php @@ -12,6 +12,8 @@ class AdminController extends Controller // LIST USERS (admin) public function users(Request $request) { + @set_time_limit(120); + $validated = $request->validate([ 'per_page' => 'nullable|integer|min:1|max:200', ]); @@ -49,6 +51,8 @@ class AdminController extends Controller // LIST RESERVATIONS (admin) public function reservations(Request $request) { + @set_time_limit(120); + $validated = $request->validate([ 'per_page' => 'nullable|integer|min:1|max:200', ]); diff --git a/app/Http/Controllers/Api/FeedbackController.php b/app/Http/Controllers/Api/FeedbackController.php index 040e9ec..f8b4048 100644 --- a/app/Http/Controllers/Api/FeedbackController.php +++ b/app/Http/Controllers/Api/FeedbackController.php @@ -29,9 +29,49 @@ class FeedbackController extends Controller ], 201); } + // CREATE FEEDBACK (public landing page) + public function storePublic(Request $request) + { + $validated = $request->validate([ + 'message' => 'required|string|min:5|max:2000', + 'name' => 'nullable|string|max:120', + 'email' => 'nullable|email|max:255', + ]); + + $details = []; + if (! empty($validated['name'])) { + $details[] = 'Name: ' . trim($validated['name']); + } + if (! empty($validated['email'])) { + $details[] = 'Email: ' . trim($validated['email']); + } + + $message = trim($validated['message']); + if (! empty($details)) { + $message .= "\n\n[Landing Page]\n" . implode("\n", $details); + } else { + $message .= "\n\n[Landing Page]"; + } + + $feedback = UserFeedback::create([ + 'user_id' => null, + 'message' => $message, + ]); + + return response()->json([ + 'message' => 'Feedback sent successfully', + 'data' => [ + 'id' => $feedback->id, + 'created_at' => $feedback->created_at, + ], + ], 201); + } + // LIST FEEDBACK (admin) public function adminIndex(Request $request) { + @set_time_limit(120); + $validated = $request->validate([ 'per_page' => 'nullable|integer|min:1|max:100', ]); diff --git a/app/Http/Controllers/Api/ReservationController.php b/app/Http/Controllers/Api/ReservationController.php index acb6d85..49ba763 100644 --- a/app/Http/Controllers/Api/ReservationController.php +++ b/app/Http/Controllers/Api/ReservationController.php @@ -281,6 +281,8 @@ class ReservationController extends Controller // CREATE RESERVATION (customer) public function store(Request $request) { + @set_time_limit(120); + $user = $request->user(); if ($user->isBlocked()) { return response()->json(['message' => 'You are temporarily blocked from making reservations.'], 403); @@ -319,7 +321,8 @@ class ReservationController extends Controller 'rejection_reason' => null, ]); - $table = $this->findAvailableTable($reservation, true, true); + // Avoid long lock waits in high-latency environments; use optimistic read. + $table = $this->findAvailableTable($reservation, true, false); if (! $table) { throw new \RuntimeException('No available table for this reservation.'); } diff --git a/app/Http/Controllers/Api/VenueController.php b/app/Http/Controllers/Api/VenueController.php index d0bc18a..a02a341 100644 --- a/app/Http/Controllers/Api/VenueController.php +++ b/app/Http/Controllers/Api/VenueController.php @@ -11,6 +11,7 @@ use App\Models\Venue; use App\Models\VenueImage; use App\Models\VenueTable; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; @@ -18,6 +19,9 @@ use Illuminate\Validation\ValidationException; class VenueController extends Controller { + private ?bool $hasInlineVenueArraysCache = null; + private ?bool $hasStructuredVenueDataCache = null; + // LIST VENUES (customer) public function index(Request $request) { @@ -129,6 +133,8 @@ class VenueController extends Controller // LIST VENUES (admin) public function adminIndex(Request $request) { + @set_time_limit(120); + $ownerColumn = Venue::ownerColumn(); $validated = $request->validate([ 'search' => 'nullable|string|max:255', @@ -145,7 +151,7 @@ class VenueController extends Controller $q->where('is_active', true); }, ]); - $this->applyVenueRelations($query); + // Keep admin list lightweight; loading full relations here can timeout. if (! empty($validated['search'])) { $query->where('name', 'like', '%' . $validated['search'] . '%'); @@ -171,6 +177,8 @@ class VenueController extends Controller // CREATE VENUE (admin) public function adminStore(Request $request) { + @set_time_limit(120); + $validated = $request->validate([ 'vendor_id' => 'nullable|integer|exists:users,id', 'name' => 'required|string|max:255', @@ -268,6 +276,8 @@ class VenueController extends Controller // UPDATE VENUE (admin) public function adminUpdate(Request $request, int $id) { + @set_time_limit(120); + $validated = $request->validate([ 'vendor_id' => 'nullable|integer|exists:users,id', 'name' => 'sometimes|required|string|max:255', @@ -542,6 +552,10 @@ class VenueController extends Controller } if ($this->hasStructuredVenueData()) { + if (! $venue->relationLoaded('amenitiesRelation')) { + return []; + } + return ($venue->amenitiesRelation ?? collect()) ->pluck('name') ->values() @@ -558,6 +572,10 @@ class VenueController extends Controller } if ($this->hasStructuredVenueData()) { + if (! $venue->relationLoaded('images')) { + return []; + } + return ($venue->images ?? collect()) ->sortBy('sort_order') ->pluck('url') @@ -575,6 +593,10 @@ class VenueController extends Controller } if ($this->hasStructuredVenueData()) { + if (! $venue->relationLoaded('offersRelation')) { + return []; + } + return ($venue->offersRelation ?? collect()) ->map(fn (Offer $offer) => [ 'title' => $offer->title, @@ -591,17 +613,48 @@ class VenueController extends Controller private function hasInlineVenueArrays(): bool { - return Schema::hasColumn('venues', 'amenities') - && Schema::hasColumn('venues', 'image_urls') - && Schema::hasColumn('venues', 'offers'); + if ($this->hasInlineVenueArraysCache !== null) { + return $this->hasInlineVenueArraysCache; + } + + $this->hasInlineVenueArraysCache = $this->rememberSchemaFlag( + 'schema:venues:inline-arrays:v1', + fn () => Schema::hasColumn('venues', 'amenities') + && Schema::hasColumn('venues', 'image_urls') + && Schema::hasColumn('venues', 'offers') + ); + + return $this->hasInlineVenueArraysCache; } private function hasStructuredVenueData(): bool { - return Schema::hasTable('venue_images') - && Schema::hasTable('offers') - && Schema::hasTable('venue_amenities') - && Schema::hasTable('amenities'); + if ($this->hasStructuredVenueDataCache !== null) { + return $this->hasStructuredVenueDataCache; + } + + $this->hasStructuredVenueDataCache = $this->rememberSchemaFlag( + 'schema:venues:structured-data:v1', + fn () => Schema::hasTable('venue_images') + && Schema::hasTable('offers') + && Schema::hasTable('venue_amenities') + && Schema::hasTable('amenities') + ); + + return $this->hasStructuredVenueDataCache; + } + + private function rememberSchemaFlag(string $cacheKey, callable $resolver): bool + { + try { + return (bool) Cache::remember( + $cacheKey, + now()->addMinutes(30), + fn () => (bool) $resolver() + ); + } catch (\Throwable) { + return (bool) $resolver(); + } } private function inlineVenuePayload(array $amenities, array $imageUrls, array $offers): array diff --git a/app/Models/Notification.php b/app/Models/Notification.php index 8250d79..6d4fab0 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; class Notification extends Model { use HasFactory; + public $timestamps = false; protected $fillable = [ 'user_id', diff --git a/app/Models/PricingRule.php b/app/Models/PricingRule.php index d4a3441..b495bf4 100644 --- a/app/Models/PricingRule.php +++ b/app/Models/PricingRule.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; class PricingRule extends Model { use HasFactory; + public $timestamps = false; protected $fillable = [ 'scope', diff --git a/app/Models/ReservationFee.php b/app/Models/ReservationFee.php index d2253c7..ac408e7 100644 --- a/app/Models/ReservationFee.php +++ b/app/Models/ReservationFee.php @@ -8,16 +8,28 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; class ReservationFee extends Model { use HasFactory; + public $timestamps = false; protected $fillable = [ 'reservation_id', 'pricing_rule_id', 'price_per_person', 'party_size', + 'total_fee', 'total_amount', 'currency', ]; + public function setTotalAmountAttribute($value): void + { + $this->attributes['total_fee'] = $value; + } + + public function getTotalAmountAttribute() + { + return $this->attributes['total_fee'] ?? null; + } + public function reservation() { return $this->belongsTo(Reservation::class); diff --git a/app/Models/ReservationReminder.php b/app/Models/ReservationReminder.php index 1b68064..706e258 100644 --- a/app/Models/ReservationReminder.php +++ b/app/Models/ReservationReminder.php @@ -8,18 +8,32 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; class ReservationReminder extends Model { use HasFactory; + public $timestamps = false; protected $fillable = [ 'reservation_id', + 'user_id', + 'remind_at', 'send_at', 'sent_at', ]; protected $casts = [ + 'remind_at' => 'datetime', 'send_at' => 'datetime', 'sent_at' => 'datetime', ]; + public function setSendAtAttribute($value): void + { + $this->attributes['remind_at'] = $value; + } + + public function getSendAtAttribute() + { + return $this->attributes['remind_at'] ?? null; + } + public function reservation() { return $this->belongsTo(Reservation::class); diff --git a/app/Models/Role.php b/app/Models/Role.php index 7d5f0d7..1c3465c 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; class Role extends Model { use HasFactory; + public $timestamps = false; protected $fillable = [ 'name', diff --git a/app/Models/SeatingArea.php b/app/Models/SeatingArea.php index 82551e2..79269cb 100644 --- a/app/Models/SeatingArea.php +++ b/app/Models/SeatingArea.php @@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; class SeatingArea extends Model { use HasFactory; + public $timestamps = false; protected $fillable = [ 'venue_id', 'name', - 'description', + 'is_active', ]; public function venue() diff --git a/app/Models/VenueTable.php b/app/Models/VenueTable.php index f384fa1..39d0d47 100644 --- a/app/Models/VenueTable.php +++ b/app/Models/VenueTable.php @@ -4,19 +4,48 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Facades\Schema; class VenueTable extends Model { use HasFactory; + public $timestamps = false; + protected static ?string $labelColumn = null; + protected $fillable = [ 'venue_id', 'seating_area_id', 'name', + 'table_number', 'capacity', 'is_active', ]; + public static function labelColumn(): string + { + if (self::$labelColumn !== null) { + return self::$labelColumn; + } + + self::$labelColumn = Schema::hasColumn('venue_tables', 'name') + ? 'name' + : 'table_number'; + + return self::$labelColumn; + } + + public function setNameAttribute($value): void + { + $this->attributes[self::labelColumn()] = $value; + } + + public function getNameAttribute() + { + $column = self::labelColumn(); + return $this->attributes[$column] ?? null; + } + public function venue() { return $this->belongsTo(Venue::class); diff --git a/bootstrap/app.php b/bootstrap/app.php index 83898ee..8e38aac 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Auth\AuthenticationException; +use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) @@ -13,6 +14,8 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->append(HandleCors::class); + // Treat all requests as API-style: don't redirect guests, return 401/403. $middleware->redirectGuestsTo(fn () => null); $middleware->alias([ diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..5f53f0f --- /dev/null +++ b/config/cors.php @@ -0,0 +1,27 @@ + trim($origin), + explode(',', (string) env('CORS_ALLOWED_ORIGINS', 'https://tabeleymvp2-8b111c91640a.hosted.ghaymah.systems')) +))); + +return [ + 'paths' => ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + + 'allowed_origins' => $allowedOrigins, + + 'allowed_origins_patterns' => [ + '#^http://localhost(:\d+)?$#', + '#^http://127\.0\.0\.1(:\d+)?$#', + ], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => false, +]; diff --git a/database/migrations/2026_02_19_160000_ensure_user_feedback_table_exists.php b/database/migrations/2026_02_19_160000_ensure_user_feedback_table_exists.php new file mode 100644 index 0000000..71934c0 --- /dev/null +++ b/database/migrations/2026_02_19_160000_ensure_user_feedback_table_exists.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->text('message'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_feedback'); + } +}; diff --git a/database/migrations/2026_02_20_000001_make_user_id_nullable_in_user_feedback_table.php b/database/migrations/2026_02_20_000001_make_user_id_nullable_in_user_feedback_table.php new file mode 100644 index 0000000..11fc4ee --- /dev/null +++ b/database/migrations/2026_02_20_000001_make_user_id_nullable_in_user_feedback_table.php @@ -0,0 +1,28 @@ +whereNull('user_id')->delete(); + DB::statement('ALTER TABLE user_feedback ALTER COLUMN user_id SET NOT NULL'); + } +}; + diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a9ea114..116bcaa 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -26,6 +26,7 @@ set_env DB_PORT "${DB_PORT}" set_env DB_DATABASE "${DB_DATABASE}" set_env DB_USERNAME "${DB_USERNAME}" set_env DB_PASSWORD "${DB_PASSWORD}" +set_env DB_SSLMODE "${DB_SSLMODE}" set_env DB_URL "${DB_URL}" set_env DATABASE_URL "${DATABASE_URL}" set_env APP_ENV "${APP_ENV}" diff --git a/routes/api.php b/routes/api.php index fd1256d..677adfa 100644 --- a/routes/api.php +++ b/routes/api.php @@ -50,6 +50,7 @@ Route::middleware(['auth:sanctum', 'role:vendor'])->patch('/reservations/{id}/co Route::middleware(['auth:sanctum'])->get('/notifications', [NotificationController::class, 'index']); Route::middleware(['auth:sanctum', 'role:customer'])->post('/feedback', [FeedbackController::class, 'store']); +Route::middleware(['throttle:20,1'])->post('/public/feedback', [FeedbackController::class, 'storePublic']); Route::middleware(['auth:sanctum', 'role:admin'])->get('/admin/feedback', [FeedbackController::class, 'adminIndex']); Route::middleware(['auth:sanctum', 'role:admin'])->get('/admin/users', [AdminController::class, 'users']); Route::middleware(['auth:sanctum', 'role:admin'])->get('/admin/reservations', [AdminController::class, 'reservations']);