generated from Tokal/Test
Compare commits
1
Commits
main
..
3c885722f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c885722f3 |
@@ -1,68 +0,0 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://tabeleymvp2-8b111c91640a.hosted.ghaymah.systems
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
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
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
CACHE_STORE=file
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
FROM php:8.2-cli
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git curl unzip zip \
|
||||
libpng-dev libjpeg62-turbo-dev libfreetype6-dev \
|
||||
libicu-dev \
|
||||
libpq-dev \
|
||||
libzip-dev \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
gd intl pdo_pgsql pgsql zip mbstring exif pcntl bcmath \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www
|
||||
COPY . .
|
||||
|
||||
RUN composer install --no-interaction --no-dev --optimize-autoloader \
|
||||
&& chmod -R 775 storage bootstrap/cache
|
||||
|
||||
CMD php artisan serve --host=0.0.0.0 --port=${PORT}
|
||||
@@ -1,850 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Amenity;
|
||||
use App\Models\Offer;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
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;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class VenueController extends Controller
|
||||
{
|
||||
private ?bool $hasInlineVenueArraysCache = null;
|
||||
private ?bool $hasStructuredVenueDataCache = null;
|
||||
|
||||
// LIST VENUES (customer)
|
||||
public function index(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'type' => 'nullable|in:restaurant,cafe',
|
||||
'search' => 'nullable|string|max:255',
|
||||
'per_page' => 'nullable|integer|min:1|max:50',
|
||||
]);
|
||||
|
||||
$query = Venue::query()->select($this->venueSelectColumns());
|
||||
$this->applyVenueRelations($query);
|
||||
|
||||
$query->where('is_active', true);
|
||||
|
||||
if (! empty($validated['type'])) {
|
||||
$query->where('type', $validated['type']);
|
||||
}
|
||||
|
||||
if (! empty($validated['search'])) {
|
||||
$query->where('name', 'like', '%' . $validated['search'] . '%');
|
||||
}
|
||||
|
||||
$perPage = $validated['per_page'] ?? 10;
|
||||
|
||||
$paginator = $query->orderByDesc('id')->paginate($perPage);
|
||||
$data = collect($paginator->items())
|
||||
->map(fn (Venue $venue) => $this->serializeVenue($venue))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// SHOW VENUE (public)
|
||||
public function show(int $id)
|
||||
{
|
||||
$venue = Venue::query()
|
||||
->select($this->venueSelectColumns())
|
||||
->where('is_active', true)
|
||||
->findOrFail($id);
|
||||
|
||||
$venue->loadMissing($this->venueRelations());
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->serializeVenue($venue),
|
||||
]);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
$user = $request->user();
|
||||
$ownerColumn = Venue::ownerColumn();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:restaurant,cafe',
|
||||
'description' => 'nullable|string',
|
||||
'address_text' => 'nullable|string|max:255',
|
||||
'lat' => 'nullable|numeric',
|
||||
'lng' => 'nullable|numeric',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'amenities' => 'nullable|array',
|
||||
'amenities.*' => 'string|max:100',
|
||||
'image_urls' => 'nullable|array|max:10',
|
||||
'image_urls.*' => 'url|max:2048',
|
||||
'offers' => 'nullable|array|max:20',
|
||||
'offers.*.title' => 'required_with:offers|string|max:120',
|
||||
'offers.*.description' => 'nullable|string|max:500',
|
||||
'offers.*.image_url' => 'nullable|url|max:2048',
|
||||
'offers.*.is_active' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$offers = $this->sanitizeOffers($validated['offers'] ?? []);
|
||||
$imageUrls = $this->appendUploadedImages($request, $validated['image_urls'] ?? []);
|
||||
$venue = Venue::create([
|
||||
$ownerColumn => $user->id,
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'address_text' => $validated['address_text'] ?? null,
|
||||
'lat' => $validated['lat'] ?? 0,
|
||||
'lng' => $validated['lng'] ?? 0,
|
||||
'is_active' => true,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
...$this->inlineVenuePayload($validated['amenities'] ?? [], $imageUrls, $offers),
|
||||
]);
|
||||
$this->syncStructuredVenueData(
|
||||
$venue,
|
||||
$validated['amenities'] ?? [],
|
||||
$imageUrls,
|
||||
$offers
|
||||
);
|
||||
$venue = $this->reloadVenueForResponse($venue->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue created successfully',
|
||||
'venue' => $this->serializeVenue($venue),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// LIST VENUES (admin)
|
||||
public function adminIndex(Request $request)
|
||||
{
|
||||
@set_time_limit(120);
|
||||
|
||||
$ownerColumn = Venue::ownerColumn();
|
||||
$validated = $request->validate([
|
||||
'search' => 'nullable|string|max:255',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
]);
|
||||
|
||||
$query = Venue::query()
|
||||
->select([
|
||||
DB::raw($ownerColumn . ' as vendor_id'),
|
||||
...$this->venueSelectColumns(),
|
||||
])
|
||||
->withCount([
|
||||
'tables as table_count' => function ($q) {
|
||||
$q->where('is_active', true);
|
||||
},
|
||||
]);
|
||||
// Keep admin list lightweight; loading full relations here can timeout.
|
||||
|
||||
if (! empty($validated['search'])) {
|
||||
$query->where('name', 'like', '%' . $validated['search'] . '%');
|
||||
}
|
||||
|
||||
$perPage = $validated['per_page'] ?? 20;
|
||||
$paginator = $query->orderByDesc('id')->paginate($perPage);
|
||||
$data = collect($paginator->items())
|
||||
->map(fn (Venue $venue) => $this->serializeVenue($venue, true))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// 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',
|
||||
'type' => 'required|in:restaurant,cafe',
|
||||
'description' => 'nullable|string',
|
||||
'address_text' => 'nullable|string|max:255',
|
||||
'lat' => 'nullable|numeric',
|
||||
'lng' => 'nullable|numeric',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'amenities' => 'nullable|array',
|
||||
'amenities.*' => 'string|max:100',
|
||||
'image_urls' => 'nullable|array|max:10',
|
||||
'image_urls.*' => 'url|max:2048',
|
||||
'images' => 'nullable|array|max:10',
|
||||
'images.*' => 'file|image|max:5120',
|
||||
'offers' => 'nullable|array|max:20',
|
||||
'offers.*.title' => 'required_with:offers|string|max:120',
|
||||
'offers.*.description' => 'nullable|string|max:500',
|
||||
'offers.*.image_url' => 'nullable|url|max:2048',
|
||||
'offers.*.is_active' => 'nullable|boolean',
|
||||
'table_count' => 'nullable|integer|min:1|max:300',
|
||||
'vendor_email' => 'nullable|email|unique:users,email',
|
||||
'vendor_phone' => 'nullable|string|unique:users,phone',
|
||||
'vendor_password' => 'nullable|string|min:6',
|
||||
]);
|
||||
|
||||
$vendorId = $validated['vendor_id'] ?? null;
|
||||
|
||||
if (! $vendorId) {
|
||||
$hasVendorCreds = ! empty($validated['vendor_email']) &&
|
||||
! empty($validated['vendor_phone']) &&
|
||||
! empty($validated['vendor_password']);
|
||||
|
||||
if ($hasVendorCreds) {
|
||||
$vendorRole = Role::firstOrCreate(['name' => 'vendor']);
|
||||
$vendor = User::create([
|
||||
'first_name' => 'Vendor',
|
||||
'last_name' => 'User',
|
||||
'email' => $validated['vendor_email'],
|
||||
'phone' => $validated['vendor_phone'],
|
||||
'password_hash' => Hash::make($validated['vendor_password']),
|
||||
]);
|
||||
$vendor->roles()->syncWithoutDetaching([$vendorRole->id]);
|
||||
$vendorId = $vendor->id;
|
||||
} else {
|
||||
$vendorId = $this->resolveVendorId();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $vendorId) {
|
||||
return response()->json([
|
||||
'message' => 'Provide vendor account data (email, phone, password) or vendor_id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$venue = null;
|
||||
DB::transaction(function () use ($validated, $vendorId, &$venue, $request) {
|
||||
$ownerColumn = Venue::ownerColumn();
|
||||
$imageUrls = $validated['image_urls'] ?? [];
|
||||
$imageUrls = $this->appendUploadedImages($request, $imageUrls);
|
||||
$offers = $this->sanitizeOffers($validated['offers'] ?? []);
|
||||
|
||||
$venue = Venue::create([
|
||||
$ownerColumn => $vendorId,
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'address_text' => $validated['address_text'] ?? null,
|
||||
'lat' => $validated['lat'] ?? 0,
|
||||
'lng' => $validated['lng'] ?? 0,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
...$this->inlineVenuePayload($validated['amenities'] ?? [], $imageUrls, $offers),
|
||||
]);
|
||||
$this->syncStructuredVenueData(
|
||||
$venue,
|
||||
$validated['amenities'] ?? [],
|
||||
$imageUrls,
|
||||
$offers
|
||||
);
|
||||
|
||||
$this->syncVenueTables($venue, (int) ($validated['table_count'] ?? 4));
|
||||
});
|
||||
$venue = $this->reloadVenueForResponse($venue->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue created successfully',
|
||||
'venue' => $this->serializeVenue($venue->loadCount([
|
||||
'tables as table_count' => fn ($q) => $q->where('is_active', true),
|
||||
]), true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// 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',
|
||||
'type' => 'sometimes|required|in:restaurant,cafe',
|
||||
'description' => 'nullable|string',
|
||||
'address_text' => 'nullable|string|max:255',
|
||||
'lat' => 'nullable|numeric',
|
||||
'lng' => 'nullable|numeric',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'amenities' => 'nullable|array',
|
||||
'amenities.*' => 'string|max:100',
|
||||
'image_urls' => 'nullable|array|max:10',
|
||||
'image_urls.*' => 'url|max:2048',
|
||||
'images' => 'nullable|array|max:10',
|
||||
'images.*' => 'file|image|max:5120',
|
||||
'offers' => 'nullable|array|max:20',
|
||||
'offers.*.title' => 'required_with:offers|string|max:120',
|
||||
'offers.*.description' => 'nullable|string|max:500',
|
||||
'offers.*.image_url' => 'nullable|url|max:2048',
|
||||
'offers.*.is_active' => 'nullable|boolean',
|
||||
'table_count' => 'nullable|integer|min:1|max:300',
|
||||
]);
|
||||
|
||||
$venue = Venue::findOrFail($id);
|
||||
DB::transaction(function () use (&$venue, $validated, $request) {
|
||||
if (array_key_exists('offers', $validated)) {
|
||||
$validated['offers'] = $this->sanitizeOffers($validated['offers'] ?? []);
|
||||
}
|
||||
|
||||
$hasAmenityInput = array_key_exists('amenities', $validated);
|
||||
$hasOfferInput = array_key_exists('offers', $validated);
|
||||
$hasImageInput = array_key_exists('image_urls', $validated) || $request->hasFile('images');
|
||||
$resolvedImageUrls = $hasImageInput
|
||||
? $this->appendUploadedImages(
|
||||
$request,
|
||||
array_key_exists('image_urls', $validated)
|
||||
? ($validated['image_urls'] ?? [])
|
||||
: $this->resolveImageUrls($venue)
|
||||
)
|
||||
: null;
|
||||
|
||||
$payload = collect($validated)->only([
|
||||
'vendor_id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'address_text',
|
||||
'lat',
|
||||
'lng',
|
||||
'is_active',
|
||||
'phone',
|
||||
])->all();
|
||||
|
||||
if ($this->hasInlineVenueArrays()) {
|
||||
if ($hasAmenityInput) {
|
||||
$payload['amenities'] = $validated['amenities'] ?? [];
|
||||
}
|
||||
if ($hasOfferInput) {
|
||||
$payload['offers'] = $validated['offers'] ?? [];
|
||||
}
|
||||
if ($hasImageInput) {
|
||||
$payload['image_urls'] = $resolvedImageUrls ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$venue->fill($payload);
|
||||
$venue->save();
|
||||
$this->syncStructuredVenueData(
|
||||
$venue,
|
||||
$hasAmenityInput ? ($validated['amenities'] ?? []) : null,
|
||||
$hasImageInput ? ($resolvedImageUrls ?? []) : null,
|
||||
$hasOfferInput ? ($validated['offers'] ?? []) : null
|
||||
);
|
||||
|
||||
if (array_key_exists('table_count', $validated)) {
|
||||
$this->syncVenueTables($venue, (int) $validated['table_count']);
|
||||
}
|
||||
});
|
||||
$venue = $this->reloadVenueForResponse($venue->id)->loadCount([
|
||||
'tables as table_count' => fn ($q) => $q->where('is_active', true),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue updated successfully',
|
||||
'venue' => $this->serializeVenue($venue, true),
|
||||
]);
|
||||
}
|
||||
|
||||
// DELETE VENUE (admin)
|
||||
public function adminDestroy(int $id)
|
||||
{
|
||||
$venue = Venue::findOrFail($id);
|
||||
$venue->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue deleted successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveVendorId(): ?int
|
||||
{
|
||||
$vendor = User::query()
|
||||
->whereHas('roles', function ($q) {
|
||||
$q->where('name', 'vendor');
|
||||
})
|
||||
->first();
|
||||
|
||||
return $vendor?->id;
|
||||
}
|
||||
|
||||
private function syncVenueTables(Venue $venue, int $targetCount): void
|
||||
{
|
||||
$targetCount = max(1, min($targetCount, 300));
|
||||
|
||||
$activeTables = VenueTable::query()
|
||||
->where('venue_id', $venue->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$currentCount = $activeTables->count();
|
||||
|
||||
if ($currentCount < $targetCount) {
|
||||
for ($i = $currentCount + 1; $i <= $targetCount; $i++) {
|
||||
VenueTable::create([
|
||||
'venue_id' => $venue->id,
|
||||
'seating_area_id' => null,
|
||||
VenueTable::labelColumn() => 'Table ' . $i,
|
||||
'capacity' => 4,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($currentCount > $targetCount) {
|
||||
$toDisable = $activeTables->slice($targetCount);
|
||||
foreach ($toDisable as $table) {
|
||||
$table->is_active = false;
|
||||
$table->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sanitizeOffers(array $offers): array
|
||||
{
|
||||
return collect($offers)
|
||||
->filter(fn ($offer) => is_array($offer))
|
||||
->map(function (array $offer) {
|
||||
return [
|
||||
'title' => trim((string) ($offer['title'] ?? '')),
|
||||
'description' => trim((string) ($offer['description'] ?? '')),
|
||||
'image_url' => trim((string) ($offer['image_url'] ?? '')),
|
||||
'is_active' => array_key_exists('is_active', $offer)
|
||||
? (bool) $offer['is_active']
|
||||
: true,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $offer) => $offer['title'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function appendUploadedImages(Request $request, array $imageUrls): array
|
||||
{
|
||||
$urls = collect($imageUrls)
|
||||
->map(fn ($url) => trim((string) $url))
|
||||
->filter(fn ($url) => $url !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $image) {
|
||||
$path = $image->store('venues', 'public');
|
||||
// Store a relative public-storage path to avoid environment-specific hosts (localhost/127.0.0.1).
|
||||
$urls[] = '/storage/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
|
||||
$urls = collect($urls)->unique()->values()->all();
|
||||
if (count($urls) > 10) {
|
||||
throw ValidationException::withMessages([
|
||||
'images' => ['Maximum 10 images allowed per venue.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
private function venueSelectColumns(): array
|
||||
{
|
||||
$columns = [
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'address_text',
|
||||
'lat',
|
||||
'lng',
|
||||
'is_active',
|
||||
'phone',
|
||||
];
|
||||
|
||||
if ($this->hasInlineVenueArrays()) {
|
||||
$columns[] = 'amenities';
|
||||
$columns[] = 'image_urls';
|
||||
$columns[] = 'offers';
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
private function venueRelations(): array
|
||||
{
|
||||
if (! $this->hasStructuredVenueData()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'images:id,venue_id,url,sort_order',
|
||||
'offersRelation:id,venue_id,title,description,image_url,is_active,start_at,end_at',
|
||||
'amenitiesRelation:id,name',
|
||||
];
|
||||
}
|
||||
|
||||
private function applyVenueRelations($query): void
|
||||
{
|
||||
$relations = $this->venueRelations();
|
||||
if (! empty($relations)) {
|
||||
$query->with($relations);
|
||||
}
|
||||
}
|
||||
|
||||
private function reloadVenueForResponse(int $venueId): Venue
|
||||
{
|
||||
$query = Venue::query()->select($this->venueSelectColumns());
|
||||
$this->applyVenueRelations($query);
|
||||
|
||||
return $query->findOrFail($venueId);
|
||||
}
|
||||
|
||||
private function serializeVenue(Venue $venue, bool $includeAdminFields = false): array
|
||||
{
|
||||
$data = [
|
||||
'id' => $venue->id,
|
||||
'name' => $venue->name,
|
||||
'type' => $venue->type,
|
||||
'description' => $venue->description,
|
||||
'address_text' => $venue->address_text,
|
||||
'lat' => $venue->lat,
|
||||
'lng' => $venue->lng,
|
||||
'phone' => $venue->phone,
|
||||
'is_active' => (bool) $venue->is_active,
|
||||
'amenities' => $this->resolveAmenities($venue),
|
||||
'image_urls' => $this->resolveImageUrls($venue),
|
||||
'offers' => $this->resolveOffers($venue),
|
||||
];
|
||||
|
||||
if ($includeAdminFields) {
|
||||
$ownerColumn = Venue::ownerColumn();
|
||||
$data['vendor_id'] = $venue->vendor_id ?? $venue->{$ownerColumn} ?? null;
|
||||
$data['table_count'] = $venue->table_count ?? null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function resolveAmenities(Venue $venue): array
|
||||
{
|
||||
if ($this->hasInlineVenueArrays()) {
|
||||
return is_array($venue->amenities) ? $venue->amenities : [];
|
||||
}
|
||||
|
||||
if ($this->hasStructuredVenueData()) {
|
||||
if (! $venue->relationLoaded('amenitiesRelation')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ($venue->amenitiesRelation ?? collect())
|
||||
->pluck('name')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function resolveImageUrls(Venue $venue): array
|
||||
{
|
||||
if ($this->hasInlineVenueArrays()) {
|
||||
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()) {
|
||||
if (! $venue->relationLoaded('images')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ($venue->images ?? collect())
|
||||
->sortBy('sort_order')
|
||||
->pluck('url')
|
||||
->map(fn ($url) => $this->normalizePublicAssetUrl($url))
|
||||
->filter(fn ($url) => $url !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function resolveOffers(Venue $venue): array
|
||||
{
|
||||
if ($this->hasInlineVenueArrays()) {
|
||||
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()) {
|
||||
if (! $venue->relationLoaded('offersRelation')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ($venue->offersRelation ?? collect())
|
||||
->map(fn (Offer $offer) => [
|
||||
'title' => $offer->title,
|
||||
'description' => $offer->description,
|
||||
'image_url' => $this->normalizePublicAssetUrl($offer->image_url) ?: null,
|
||||
'is_active' => (bool) $offer->is_active,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function hasInlineVenueArrays(): bool
|
||||
{
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
if (! $this->hasInlineVenueArrays()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'amenities' => $amenities,
|
||||
'image_urls' => $imageUrls,
|
||||
'offers' => $offers,
|
||||
];
|
||||
}
|
||||
|
||||
private function syncStructuredVenueData(Venue $venue, ?array $amenities, ?array $imageUrls, ?array $offers): void
|
||||
{
|
||||
if (! $this->hasStructuredVenueData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($amenities !== null) {
|
||||
$amenityIds = collect($amenities)
|
||||
->map(fn ($name) => trim((string) $name))
|
||||
->filter(fn ($name) => $name !== '')
|
||||
->map(fn ($name) => Amenity::firstOrCreate(['name' => $name])->id)
|
||||
->values()
|
||||
->all();
|
||||
$venue->amenitiesRelation()->sync($amenityIds);
|
||||
}
|
||||
|
||||
if ($imageUrls !== null) {
|
||||
VenueImage::query()->where('venue_id', $venue->id)->delete();
|
||||
foreach (array_values($imageUrls) as $index => $url) {
|
||||
VenueImage::create([
|
||||
'venue_id' => $venue->id,
|
||||
'url' => $url,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($offers !== null) {
|
||||
Offer::query()->where('venue_id', $venue->id)->delete();
|
||||
foreach ($offers as $offer) {
|
||||
Offer::create([
|
||||
'venue_id' => $venue->id,
|
||||
'title' => $offer['title'],
|
||||
'description' => $offer['description'] ?: null,
|
||||
'image_url' => $offer['image_url'] ?: null,
|
||||
'is_active' => $offer['is_active'] ?? true,
|
||||
'start_at' => now(),
|
||||
'end_at' => now()->addDays(30),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Amenity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
];
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Offer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'venue_id',
|
||||
'title',
|
||||
'description',
|
||||
'image_url',
|
||||
'start_at',
|
||||
'end_at',
|
||||
'is_active',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ReservationStatusHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
protected static ?string $resolvedTable = null;
|
||||
|
||||
protected $fillable = [
|
||||
'reservation_id',
|
||||
'old_status',
|
||||
'new_status',
|
||||
'changed_by_user_id',
|
||||
'note',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
$this->table = self::tableName();
|
||||
}
|
||||
|
||||
public static function tableName(): string
|
||||
{
|
||||
if (self::$resolvedTable !== null) {
|
||||
return self::$resolvedTable;
|
||||
}
|
||||
|
||||
self::$resolvedTable = Schema::hasTable('reservation_status_histories')
|
||||
? 'reservation_status_histories'
|
||||
: 'reservation_status_history';
|
||||
|
||||
return self::$resolvedTable;
|
||||
}
|
||||
|
||||
public function reservation()
|
||||
{
|
||||
return $this->belongsTo(Reservation::class);
|
||||
}
|
||||
|
||||
public function changedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'changed_by_user_id');
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ReservationTableAssignment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
protected static ?string $resolvedTable = null;
|
||||
protected static ?string $resolvedTableForeignKey = null;
|
||||
|
||||
protected $fillable = [
|
||||
'reservation_id',
|
||||
'table_id',
|
||||
'venue_table_id',
|
||||
'assigned_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'assigned_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
$this->table = self::tableName();
|
||||
}
|
||||
|
||||
public static function tableName(): string
|
||||
{
|
||||
if (self::$resolvedTable !== null) {
|
||||
return self::$resolvedTable;
|
||||
}
|
||||
|
||||
self::$resolvedTable = Schema::hasTable('reservation_table_assignments')
|
||||
? 'reservation_table_assignments'
|
||||
: 'reservation_tables';
|
||||
|
||||
return self::$resolvedTable;
|
||||
}
|
||||
|
||||
public static function tableForeignKey(): string
|
||||
{
|
||||
if (self::$resolvedTableForeignKey !== null) {
|
||||
return self::$resolvedTableForeignKey;
|
||||
}
|
||||
|
||||
self::$resolvedTableForeignKey = Schema::hasColumn(self::tableName(), 'venue_table_id')
|
||||
? 'venue_table_id'
|
||||
: 'table_id';
|
||||
|
||||
return self::$resolvedTableForeignKey;
|
||||
}
|
||||
|
||||
public function setVenueTableIdAttribute($value): void
|
||||
{
|
||||
$this->attributes[self::tableForeignKey()] = $value;
|
||||
}
|
||||
|
||||
public function getVenueTableIdAttribute()
|
||||
{
|
||||
$column = self::tableForeignKey();
|
||||
return $this->attributes[$column] ?? null;
|
||||
}
|
||||
|
||||
public function reservation()
|
||||
{
|
||||
return $this->belongsTo(Reservation::class);
|
||||
}
|
||||
|
||||
public function table()
|
||||
{
|
||||
return $this->belongsTo(VenueTable::class, self::tableForeignKey());
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class UserBlock extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'user_blocks';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'level',
|
||||
'reason',
|
||||
'blocked_until',
|
||||
'created_by',
|
||||
'created_by_user_id',
|
||||
'trigger_venue_id',
|
||||
'is_active',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'blocked_until' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class UserStrike extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'user_strikes';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'reservation_id',
|
||||
'venue_id',
|
||||
'type',
|
||||
'created_at',
|
||||
];
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Models\Reservation;
|
||||
use App\Models\SeatingArea;
|
||||
use App\Models\VenueTable;
|
||||
use App\Models\Offer;
|
||||
use App\Models\VenueImage;
|
||||
use App\Models\Amenity;
|
||||
|
||||
class Venue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected static ?string $resolvedOwnerColumn = null;
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'owner_user_id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'address_text',
|
||||
'lat',
|
||||
'lng',
|
||||
'is_active',
|
||||
'address',
|
||||
'phone',
|
||||
'amenities',
|
||||
'image_urls',
|
||||
'offers',
|
||||
'created_by_admin_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amenities' => 'array',
|
||||
'image_urls' => 'array',
|
||||
'offers' => 'array',
|
||||
];
|
||||
|
||||
public static function ownerColumn(): string
|
||||
{
|
||||
if (self::$resolvedOwnerColumn !== null) {
|
||||
return self::$resolvedOwnerColumn;
|
||||
}
|
||||
|
||||
self::$resolvedOwnerColumn = Schema::hasColumn('venues', 'vendor_id')
|
||||
? 'vendor_id'
|
||||
: 'owner_user_id';
|
||||
|
||||
return self::$resolvedOwnerColumn;
|
||||
}
|
||||
|
||||
public function setVendorIdAttribute($value): void
|
||||
{
|
||||
$this->attributes[self::ownerColumn()] = $value;
|
||||
}
|
||||
|
||||
public function getVendorIdAttribute()
|
||||
{
|
||||
$column = self::ownerColumn();
|
||||
return $this->attributes[$column] ?? null;
|
||||
}
|
||||
|
||||
public function vendor()
|
||||
{
|
||||
return $this->belongsTo(User::class, self::ownerColumn());
|
||||
}
|
||||
|
||||
public function reservations()
|
||||
{
|
||||
return $this->hasMany(Reservation::class);
|
||||
}
|
||||
|
||||
public function seatingAreas()
|
||||
{
|
||||
return $this->hasMany(SeatingArea::class);
|
||||
}
|
||||
|
||||
public function tables()
|
||||
{
|
||||
return $this->hasMany(VenueTable::class);
|
||||
}
|
||||
|
||||
public function images()
|
||||
{
|
||||
return $this->hasMany(VenueImage::class);
|
||||
}
|
||||
|
||||
public function offersRelation()
|
||||
{
|
||||
return $this->hasMany(Offer::class);
|
||||
}
|
||||
|
||||
public function amenitiesRelation()
|
||||
{
|
||||
return $this->belongsToMany(Amenity::class, 'venue_amenities', 'venue_id', 'amenity_id');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class VenueImage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'venue_images';
|
||||
|
||||
protected $fillable = [
|
||||
'venue_id',
|
||||
'url',
|
||||
'sort_order',
|
||||
];
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function seatingArea()
|
||||
{
|
||||
return $this->belongsTo(SeatingArea::class);
|
||||
}
|
||||
|
||||
public function assignments()
|
||||
{
|
||||
return $this->hasMany(ReservationTableAssignment::class);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
$allowedOrigins = array_values(array_filter(array_map(
|
||||
static fn ($origin) => 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,
|
||||
];
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('user_feedback')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('user_feedback', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('user_feedback') || ! Schema::hasColumn('user_feedback', 'user_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE user_feedback ALTER COLUMN user_id DROP NOT NULL');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('user_feedback') || ! Schema::hasColumn('user_feedback', 'user_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('user_feedback')->whereNull('user_id')->delete();
|
||||
DB::statement('ALTER TABLE user_feedback ALTER COLUMN user_id SET NOT NULL');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('user_feedback')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('user_feedback', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('user_feedback', 'sender_name')) {
|
||||
$table->string('sender_name', 120)->nullable()->after('user_id');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('user_feedback', 'sender_email')) {
|
||||
$table->string('sender_email')->nullable()->after('sender_name');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('user_feedback', 'source')) {
|
||||
$table->string('source', 40)->nullable()->after('sender_email');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('user_feedback')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('user_feedback', function (Blueprint $table) {
|
||||
$drops = [];
|
||||
|
||||
if (Schema::hasColumn('user_feedback', 'source')) {
|
||||
$drops[] = 'source';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('user_feedback', 'sender_email')) {
|
||||
$drops[] = 'sender_email';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('user_feedback', 'sender_name')) {
|
||||
$drops[] = 'sender_name';
|
||||
}
|
||||
|
||||
if ($drops !== []) {
|
||||
$table->dropColumn($drops);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM php:8.3-cli
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
libicu-dev \
|
||||
libpng-dev \
|
||||
unzip \
|
||||
git \
|
||||
curl
|
||||
|
||||
RUN docker-php-ext-install pdo_pgsql pgsql intl gd
|
||||
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD php artisan serve --host=0.0.0.0 --port=8000
|
||||
+2
-3
@@ -26,7 +26,7 @@ class SendReservationReminders extends Command
|
||||
$reservations = Reservation::query()
|
||||
->where('status', 'approved')
|
||||
->whereRaw(
|
||||
"(reservation_date::text || ' ' || reservation_time::text)::timestamp BETWEEN ? AND ?",
|
||||
"STR_TO_DATE(CONCAT(reservation_date,' ',reservation_time), '%Y-%m-%d %H:%i:%s') BETWEEN ? AND ?",
|
||||
[$now->format('Y-m-d H:i:s'), $windowEnd->format('Y-m-d H:i:s')]
|
||||
)
|
||||
->get();
|
||||
@@ -42,14 +42,13 @@ 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' => 'reminder',
|
||||
'type' => 'reservation_reminder',
|
||||
'title' => 'Reservation reminder',
|
||||
'body' => 'Your reservation is coming up in about ' . $minutes . ' minutes.',
|
||||
'data_json' => [
|
||||
-4
@@ -12,8 +12,6 @@ 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',
|
||||
]);
|
||||
@@ -51,8 +49,6 @@ 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',
|
||||
]);
|
||||
-34
@@ -17,9 +17,6 @@ class FeedbackController extends Controller
|
||||
|
||||
$feedback = UserFeedback::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'sender_name' => trim(($request->user()->first_name ?? '') . ' ' . ($request->user()->last_name ?? '')) ?: null,
|
||||
'sender_email' => $request->user()->email,
|
||||
'source' => 'mobile_app',
|
||||
'message' => $validated['message'],
|
||||
]);
|
||||
|
||||
@@ -32,37 +29,9 @@ 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' => 'required|string|max:120',
|
||||
'email' => 'required|email|max:255',
|
||||
]);
|
||||
|
||||
$feedback = UserFeedback::create([
|
||||
'user_id' => null,
|
||||
'sender_name' => trim($validated['name']),
|
||||
'sender_email' => trim($validated['email']),
|
||||
'source' => 'landing_page',
|
||||
'message' => trim($validated['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',
|
||||
]);
|
||||
@@ -76,9 +45,6 @@ class FeedbackController extends Controller
|
||||
$data = collect($paginator->items())->map(function (UserFeedback $item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'sender_name' => $item->sender_name,
|
||||
'sender_email' => $item->sender_email,
|
||||
'source' => $item->source,
|
||||
'message' => $item->message,
|
||||
'created_at' => $item->created_at,
|
||||
'user' => [
|
||||
+73
-86
@@ -6,17 +6,16 @@ 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;
|
||||
use App\Models\Venue;
|
||||
use App\Models\UserBlock;
|
||||
use App\Models\UserStrike;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ReservationController extends Controller
|
||||
{
|
||||
@@ -31,7 +30,7 @@ class ReservationController extends Controller
|
||||
|
||||
$query = Reservation::query()
|
||||
->whereHas('venue', function ($q) use ($request) {
|
||||
$q->where(Venue::ownerColumn(), $request->user()->id);
|
||||
$q->where('vendor_id', $request->user()->id);
|
||||
})
|
||||
->with([
|
||||
'venue:id,name,type,address_text',
|
||||
@@ -87,7 +86,7 @@ class ReservationController extends Controller
|
||||
$reservation = Reservation::query()
|
||||
->where('id', $id)
|
||||
->whereHas('venue', function ($q) use ($request) {
|
||||
$q->where(Venue::ownerColumn(), $request->user()->id);
|
||||
$q->where('vendor_id', $request->user()->id);
|
||||
})
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
@@ -226,7 +225,7 @@ class ReservationController extends Controller
|
||||
{
|
||||
return Reservation::query()
|
||||
->whereHas('venue', function ($q) use ($request) {
|
||||
$q->where(Venue::ownerColumn(), $request->user()->id);
|
||||
$q->where('vendor_id', $request->user()->id);
|
||||
})
|
||||
->find($id);
|
||||
}
|
||||
@@ -279,8 +278,6 @@ 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);
|
||||
@@ -302,11 +299,12 @@ class ReservationController extends Controller
|
||||
}
|
||||
|
||||
$reservation = null;
|
||||
$fee = null;
|
||||
$table = null;
|
||||
$reminder = null;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $user, &$reservation, &$table, &$reminder) {
|
||||
DB::transaction(function () use ($validated, $user, &$reservation, &$fee, &$table, &$reminder) {
|
||||
$reservation = Reservation::create([
|
||||
'code' => $this->generateCode(),
|
||||
'customer_id' => $user->id,
|
||||
@@ -318,8 +316,7 @@ class ReservationController extends Controller
|
||||
'rejection_reason' => null,
|
||||
]);
|
||||
|
||||
// Avoid long lock waits in high-latency environments; use optimistic read.
|
||||
$table = $this->findAvailableTable($reservation, true, false);
|
||||
$table = $this->findAvailableTable($reservation, true, true);
|
||||
if (! $table) {
|
||||
throw new \RuntimeException('No available table for this reservation.');
|
||||
}
|
||||
@@ -329,6 +326,7 @@ class ReservationController extends Controller
|
||||
'venue_table_id' => $table->id,
|
||||
]);
|
||||
|
||||
$fee = $this->createReservationFee($reservation);
|
||||
$reminder = $this->createReservationReminder($reservation);
|
||||
$this->logStatusChange($reservation, null, 'pending', $user->id);
|
||||
});
|
||||
@@ -346,6 +344,12 @@ 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,
|
||||
@@ -367,6 +371,54 @@ 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')->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', 'type')
|
||||
->where('venue_type', $venue->type)
|
||||
->where('is_active', true)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($typeRule) {
|
||||
return $typeRule;
|
||||
}
|
||||
|
||||
return PricingRule::where('scope', 'global')->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);
|
||||
@@ -396,7 +448,7 @@ class ReservationController extends Controller
|
||||
$tableIds = $tables->pluck('id')->all();
|
||||
|
||||
$assignedQuery = ReservationTableAssignment::query()
|
||||
->whereIn(ReservationTableAssignment::tableForeignKey(), $tableIds)
|
||||
->whereIn('venue_table_id', $tableIds)
|
||||
->whereHas('reservation', function ($q) use ($reservation, $includePending) {
|
||||
$statuses = ['approved', 'completed'];
|
||||
if ($includePending) {
|
||||
@@ -449,7 +501,6 @@ class ReservationController extends Controller
|
||||
|
||||
return ReservationReminder::create([
|
||||
'reservation_id' => $reservation->id,
|
||||
'user_id' => $reservation->customer_id,
|
||||
'send_at' => $sendAt,
|
||||
'sent_at' => null,
|
||||
]);
|
||||
@@ -512,11 +563,10 @@ class ReservationController extends Controller
|
||||
{
|
||||
Notification::create([
|
||||
'user_id' => $reservation->customer_id,
|
||||
'type' => 'reservation_status',
|
||||
'type' => $type,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'data_json' => [
|
||||
'event' => $type,
|
||||
'reservation_id' => $reservation->id,
|
||||
'venue_id' => $reservation->venue_id,
|
||||
'status' => $reservation->status,
|
||||
@@ -546,80 +596,17 @@ class ReservationController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('users', 'strike_count')
|
||||
&& Schema::hasColumn('users', 'blocked_until')
|
||||
&& Schema::hasColumn('users', 'blocked_permanent')) {
|
||||
$customer->strike_count = $customer->strike_count + 1;
|
||||
$customer->strike_count = $customer->strike_count + 1;
|
||||
|
||||
if ($customer->strike_count >= 9) {
|
||||
$customer->blocked_permanent = true;
|
||||
} elseif ($customer->strike_count >= 6) {
|
||||
$customer->blocked_until = now()->addDays(30);
|
||||
} elseif ($customer->strike_count >= 3) {
|
||||
$customer->blocked_until = now()->addDays(7);
|
||||
}
|
||||
|
||||
$customer->save();
|
||||
return;
|
||||
if ($customer->strike_count >= 9) {
|
||||
$customer->blocked_permanent = true;
|
||||
} elseif ($customer->strike_count >= 6) {
|
||||
$customer->blocked_until = now()->addDays(30);
|
||||
} elseif ($customer->strike_count >= 3) {
|
||||
$customer->blocked_until = now()->addDays(7);
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('user_strikes') || ! Schema::hasTable('user_blocks')) {
|
||||
return;
|
||||
}
|
||||
|
||||
UserStrike::create([
|
||||
'user_id' => $customer->id,
|
||||
'type' => 'no_show',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$strikeCount = UserStrike::query()
|
||||
->where('user_id', $customer->id)
|
||||
->where('type', 'no_show')
|
||||
->count();
|
||||
|
||||
UserBlock::query()
|
||||
->where('user_id', $customer->id)
|
||||
->where('is_active', true)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
if ($strikeCount >= 9) {
|
||||
UserBlock::create([
|
||||
'user_id' => $customer->id,
|
||||
'level' => 'permanent',
|
||||
'reason' => 'Exceeded no-show threshold',
|
||||
'blocked_until' => null,
|
||||
'created_by' => 'system',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($strikeCount >= 6) {
|
||||
UserBlock::create([
|
||||
'user_id' => $customer->id,
|
||||
'level' => 'month',
|
||||
'reason' => 'Exceeded no-show threshold',
|
||||
'blocked_until' => now()->addDays(30),
|
||||
'created_by' => 'system',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($strikeCount >= 3) {
|
||||
UserBlock::create([
|
||||
'user_id' => $customer->id,
|
||||
'level' => 'week',
|
||||
'reason' => 'Exceeded no-show threshold',
|
||||
'blocked_until' => now()->addDays(7),
|
||||
'created_by' => 'system',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
$customer->save();
|
||||
}
|
||||
|
||||
private function canCancel(Reservation $reservation): bool
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Venue;
|
||||
use App\Models\VenueTable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class VenueController extends Controller
|
||||
{
|
||||
// LIST VENUES (customer)
|
||||
public function index(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'type' => 'nullable|in:restaurant,cafe',
|
||||
'search' => 'nullable|string|max:255',
|
||||
'per_page' => 'nullable|integer|min:1|max:50',
|
||||
]);
|
||||
|
||||
$query = Venue::query()
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'address_text',
|
||||
'lat',
|
||||
'lng',
|
||||
'amenities',
|
||||
'image_urls',
|
||||
'offers',
|
||||
]);
|
||||
|
||||
$query->where('is_active', true);
|
||||
|
||||
if (! empty($validated['type'])) {
|
||||
$query->where('type', $validated['type']);
|
||||
}
|
||||
|
||||
if (! empty($validated['search'])) {
|
||||
$query->where('name', 'like', '%' . $validated['search'] . '%');
|
||||
}
|
||||
|
||||
$perPage = $validated['per_page'] ?? 10;
|
||||
|
||||
$paginator = $query->orderByDesc('id')->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->items(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// SHOW VENUE (public)
|
||||
public function show(int $id)
|
||||
{
|
||||
$venue = Venue::query()
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'address_text',
|
||||
'lat',
|
||||
'lng',
|
||||
'amenities',
|
||||
'image_urls',
|
||||
'offers',
|
||||
])
|
||||
->where('is_active', true)
|
||||
->findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
'data' => $venue,
|
||||
]);
|
||||
}
|
||||
|
||||
// CREATE VENUE (vendor only)
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:restaurant,cafe',
|
||||
'description' => 'nullable|string',
|
||||
'address_text' => 'nullable|string|max:255',
|
||||
'lat' => 'nullable|numeric',
|
||||
'lng' => 'nullable|numeric',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'amenities' => 'nullable|array',
|
||||
'amenities.*' => 'string|max:100',
|
||||
'image_urls' => 'nullable|array|max:10',
|
||||
'image_urls.*' => 'url|max:2048',
|
||||
'offers' => 'nullable|array|max:20',
|
||||
'offers.*.title' => 'required_with:offers|string|max:120',
|
||||
'offers.*.description' => 'nullable|string|max:500',
|
||||
'offers.*.image_url' => 'nullable|url|max:2048',
|
||||
'offers.*.is_active' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$venue = Venue::create([
|
||||
'vendor_id' => $user->id,
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'address_text' => $validated['address_text'] ?? null,
|
||||
'lat' => $validated['lat'] ?? null,
|
||||
'lng' => $validated['lng'] ?? null,
|
||||
'is_active' => true,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'amenities' => $validated['amenities'] ?? [],
|
||||
'image_urls' => $validated['image_urls'] ?? [],
|
||||
'offers' => $this->sanitizeOffers($validated['offers'] ?? []),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue created successfully',
|
||||
'venue' => $venue,
|
||||
], 201);
|
||||
}
|
||||
|
||||
// LIST VENUES (admin)
|
||||
public function adminIndex(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'search' => 'nullable|string|max:255',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
]);
|
||||
|
||||
$query = Venue::query()
|
||||
->select([
|
||||
'id',
|
||||
'vendor_id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'address_text',
|
||||
'lat',
|
||||
'lng',
|
||||
'is_active',
|
||||
'phone',
|
||||
'amenities',
|
||||
'image_urls',
|
||||
'offers',
|
||||
])
|
||||
->withCount([
|
||||
'tables as table_count' => function ($q) {
|
||||
$q->where('is_active', true);
|
||||
},
|
||||
]);
|
||||
|
||||
if (! empty($validated['search'])) {
|
||||
$query->where('name', 'like', '%' . $validated['search'] . '%');
|
||||
}
|
||||
|
||||
$perPage = $validated['per_page'] ?? 20;
|
||||
$paginator = $query->orderByDesc('id')->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->items(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// CREATE VENUE (admin)
|
||||
public function adminStore(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'nullable|integer|exists:users,id',
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|in:restaurant,cafe',
|
||||
'description' => 'nullable|string',
|
||||
'address_text' => 'nullable|string|max:255',
|
||||
'lat' => 'nullable|numeric',
|
||||
'lng' => 'nullable|numeric',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'amenities' => 'nullable|array',
|
||||
'amenities.*' => 'string|max:100',
|
||||
'image_urls' => 'nullable|array|max:10',
|
||||
'image_urls.*' => 'url|max:2048',
|
||||
'images' => 'nullable|array|max:10',
|
||||
'images.*' => 'file|image|max:5120',
|
||||
'offers' => 'nullable|array|max:20',
|
||||
'offers.*.title' => 'required_with:offers|string|max:120',
|
||||
'offers.*.description' => 'nullable|string|max:500',
|
||||
'offers.*.image_url' => 'nullable|url|max:2048',
|
||||
'offers.*.is_active' => 'nullable|boolean',
|
||||
'table_count' => 'nullable|integer|min:1|max:300',
|
||||
'vendor_email' => 'nullable|email|unique:users,email',
|
||||
'vendor_phone' => 'nullable|string|unique:users,phone',
|
||||
'vendor_password' => 'nullable|string|min:6',
|
||||
]);
|
||||
|
||||
$vendorId = $validated['vendor_id'] ?? null;
|
||||
|
||||
if (! $vendorId) {
|
||||
$hasVendorCreds = ! empty($validated['vendor_email']) &&
|
||||
! empty($validated['vendor_phone']) &&
|
||||
! empty($validated['vendor_password']);
|
||||
|
||||
if ($hasVendorCreds) {
|
||||
$vendorRole = Role::firstOrCreate(['name' => 'vendor']);
|
||||
$vendor = User::create([
|
||||
'first_name' => 'Vendor',
|
||||
'last_name' => 'User',
|
||||
'email' => $validated['vendor_email'],
|
||||
'phone' => $validated['vendor_phone'],
|
||||
'password' => Hash::make($validated['vendor_password']),
|
||||
]);
|
||||
$vendor->roles()->syncWithoutDetaching([$vendorRole->id]);
|
||||
$vendorId = $vendor->id;
|
||||
} else {
|
||||
$vendorId = $this->resolveVendorId();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $vendorId) {
|
||||
return response()->json([
|
||||
'message' => 'Provide vendor account data (email, phone, password) or vendor_id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$venue = null;
|
||||
DB::transaction(function () use ($validated, $vendorId, &$venue, $request) {
|
||||
$imageUrls = $validated['image_urls'] ?? [];
|
||||
$imageUrls = $this->appendUploadedImages($request, $imageUrls);
|
||||
|
||||
$venue = Venue::create([
|
||||
'vendor_id' => $vendorId,
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'address_text' => $validated['address_text'] ?? null,
|
||||
'lat' => $validated['lat'] ?? null,
|
||||
'lng' => $validated['lng'] ?? null,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'amenities' => $validated['amenities'] ?? [],
|
||||
'image_urls' => $imageUrls,
|
||||
'offers' => $this->sanitizeOffers($validated['offers'] ?? []),
|
||||
]);
|
||||
|
||||
$this->syncVenueTables($venue, (int) ($validated['table_count'] ?? 4));
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue created successfully',
|
||||
'venue' => $venue->loadCount([
|
||||
'tables as table_count' => function ($q) {
|
||||
$q->where('is_active', true);
|
||||
},
|
||||
]),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// UPDATE VENUE (admin)
|
||||
public function adminUpdate(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'nullable|integer|exists:users,id',
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'type' => 'sometimes|required|in:restaurant,cafe',
|
||||
'description' => 'nullable|string',
|
||||
'address_text' => 'nullable|string|max:255',
|
||||
'lat' => 'nullable|numeric',
|
||||
'lng' => 'nullable|numeric',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'amenities' => 'nullable|array',
|
||||
'amenities.*' => 'string|max:100',
|
||||
'image_urls' => 'nullable|array|max:10',
|
||||
'image_urls.*' => 'url|max:2048',
|
||||
'images' => 'nullable|array|max:10',
|
||||
'images.*' => 'file|image|max:5120',
|
||||
'offers' => 'nullable|array|max:20',
|
||||
'offers.*.title' => 'required_with:offers|string|max:120',
|
||||
'offers.*.description' => 'nullable|string|max:500',
|
||||
'offers.*.image_url' => 'nullable|url|max:2048',
|
||||
'offers.*.is_active' => 'nullable|boolean',
|
||||
'table_count' => 'nullable|integer|min:1|max:300',
|
||||
]);
|
||||
|
||||
$venue = Venue::findOrFail($id);
|
||||
DB::transaction(function () use (&$venue, $validated, $request) {
|
||||
if (array_key_exists('offers', $validated)) {
|
||||
$validated['offers'] = $this->sanitizeOffers($validated['offers'] ?? []);
|
||||
}
|
||||
|
||||
$baseImageUrls = array_key_exists('image_urls', $validated)
|
||||
? ($validated['image_urls'] ?? [])
|
||||
: ($venue->image_urls ?? []);
|
||||
$validated['image_urls'] = $this->appendUploadedImages($request, $baseImageUrls);
|
||||
|
||||
$venue->fill($validated);
|
||||
$venue->save();
|
||||
|
||||
if (array_key_exists('table_count', $validated)) {
|
||||
$this->syncVenueTables($venue, (int) $validated['table_count']);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue updated successfully',
|
||||
'venue' => $venue->loadCount([
|
||||
'tables as table_count' => function ($q) {
|
||||
$q->where('is_active', true);
|
||||
},
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// DELETE VENUE (admin)
|
||||
public function adminDestroy(int $id)
|
||||
{
|
||||
$venue = Venue::findOrFail($id);
|
||||
$venue->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Venue deleted successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveVendorId(): ?int
|
||||
{
|
||||
$vendor = User::query()
|
||||
->whereHas('roles', function ($q) {
|
||||
$q->where('name', 'vendor');
|
||||
})
|
||||
->first();
|
||||
|
||||
return $vendor?->id;
|
||||
}
|
||||
|
||||
private function syncVenueTables(Venue $venue, int $targetCount): void
|
||||
{
|
||||
$targetCount = max(1, min($targetCount, 300));
|
||||
|
||||
$activeTables = VenueTable::query()
|
||||
->where('venue_id', $venue->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$currentCount = $activeTables->count();
|
||||
|
||||
if ($currentCount < $targetCount) {
|
||||
for ($i = $currentCount + 1; $i <= $targetCount; $i++) {
|
||||
VenueTable::create([
|
||||
'venue_id' => $venue->id,
|
||||
'seating_area_id' => null,
|
||||
'name' => 'Table ' . $i,
|
||||
'capacity' => 4,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($currentCount > $targetCount) {
|
||||
$toDisable = $activeTables->slice($targetCount);
|
||||
foreach ($toDisable as $table) {
|
||||
$table->is_active = false;
|
||||
$table->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sanitizeOffers(array $offers): array
|
||||
{
|
||||
return collect($offers)
|
||||
->filter(fn ($offer) => is_array($offer))
|
||||
->map(function (array $offer) {
|
||||
return [
|
||||
'title' => trim((string) ($offer['title'] ?? '')),
|
||||
'description' => trim((string) ($offer['description'] ?? '')),
|
||||
'image_url' => trim((string) ($offer['image_url'] ?? '')),
|
||||
'is_active' => array_key_exists('is_active', $offer)
|
||||
? (bool) $offer['is_active']
|
||||
: true,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $offer) => $offer['title'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function appendUploadedImages(Request $request, array $imageUrls): array
|
||||
{
|
||||
$urls = collect($imageUrls)
|
||||
->map(fn ($url) => trim((string) $url))
|
||||
->filter(fn ($url) => $url !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $image) {
|
||||
$path = $image->store('venues', 'public');
|
||||
$urls[] = rtrim($request->getSchemeAndHttpHost(), '/') . '/storage/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
|
||||
$urls = collect($urls)->unique()->values()->all();
|
||||
if (count($urls) > 10) {
|
||||
throw ValidationException::withMessages([
|
||||
'images' => ['Maximum 10 images allowed per venue.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
class Notification extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
@@ -8,7 +8,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
class PricingRule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'scope',
|
||||
@@ -8,28 +8,16 @@ 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);
|
||||
@@ -8,32 +8,18 @@ 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);
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class ReservationStatusHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'reservation_id',
|
||||
'old_status',
|
||||
'new_status',
|
||||
'changed_by_user_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public function reservation()
|
||||
{
|
||||
return $this->belongsTo(Reservation::class);
|
||||
}
|
||||
|
||||
public function changedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'changed_by_user_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class ReservationTableAssignment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'reservation_id',
|
||||
'venue_table_id',
|
||||
'assigned_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'assigned_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function reservation()
|
||||
{
|
||||
return $this->belongsTo(Reservation::class);
|
||||
}
|
||||
|
||||
public function table()
|
||||
{
|
||||
return $this->belongsTo(VenueTable::class, 'venue_table_id');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
class Role extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
@@ -8,12 +8,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
class SeatingArea extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'venue_id',
|
||||
'name',
|
||||
'is_active',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function venue()
|
||||
@@ -12,15 +12,11 @@ use App\Models\Reservation;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserFeedback;
|
||||
use App\Models\UserBlock;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
protected static ?bool $hasLegacyBlockColumns = null;
|
||||
protected static ?bool $hasUserBlocksTable = null;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -70,7 +66,7 @@ class User extends Authenticatable
|
||||
|
||||
public function venues()
|
||||
{
|
||||
return $this->hasMany(Venue::class, Venue::ownerColumn());
|
||||
return $this->hasMany(Venue::class, 'vendor_id');
|
||||
}
|
||||
|
||||
public function reservations()
|
||||
@@ -90,53 +86,14 @@ class User extends Authenticatable
|
||||
|
||||
public function isBlocked(): bool
|
||||
{
|
||||
if ($this->hasLegacyBlockColumns()) {
|
||||
if ($this->blocked_permanent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->blocked_until && Carbon::parse($this->blocked_until)->isFuture()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
if ($this->blocked_permanent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->hasUserBlocksTable()) {
|
||||
return false;
|
||||
if ($this->blocked_until && Carbon::parse($this->blocked_until)->isFuture()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return UserBlock::query()
|
||||
->where('user_id', $this->id)
|
||||
->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('blocked_until')
|
||||
->orWhere('blocked_until', '>', now());
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function hasLegacyBlockColumns(): bool
|
||||
{
|
||||
if (self::$hasLegacyBlockColumns !== null) {
|
||||
return self::$hasLegacyBlockColumns;
|
||||
}
|
||||
|
||||
self::$hasLegacyBlockColumns = Schema::hasColumn('users', 'blocked_until')
|
||||
&& Schema::hasColumn('users', 'blocked_permanent')
|
||||
&& Schema::hasColumn('users', 'strike_count');
|
||||
|
||||
return self::$hasLegacyBlockColumns;
|
||||
}
|
||||
|
||||
private function hasUserBlocksTable(): bool
|
||||
{
|
||||
if (self::$hasUserBlocksTable !== null) {
|
||||
return self::$hasUserBlocksTable;
|
||||
}
|
||||
|
||||
self::$hasUserBlocksTable = Schema::hasTable('user_blocks');
|
||||
|
||||
return self::$hasUserBlocksTable;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,9 @@ class UserFeedback extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'user_feedback';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'sender_name',
|
||||
'sender_email',
|
||||
'source',
|
||||
'message',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use App\Models\Reservation;
|
||||
use App\Models\SeatingArea;
|
||||
use App\Models\VenueTable;
|
||||
|
||||
class Venue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'vendor_id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'address_text',
|
||||
'lat',
|
||||
'lng',
|
||||
'is_active',
|
||||
'address',
|
||||
'phone',
|
||||
'amenities',
|
||||
'image_urls',
|
||||
'offers',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amenities' => 'array',
|
||||
'image_urls' => 'array',
|
||||
'offers' => 'array',
|
||||
];
|
||||
|
||||
public function vendor()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'vendor_id');
|
||||
}
|
||||
|
||||
public function reservations()
|
||||
{
|
||||
return $this->hasMany(Reservation::class);
|
||||
}
|
||||
|
||||
public function seatingAreas()
|
||||
{
|
||||
return $this->hasMany(SeatingArea::class);
|
||||
}
|
||||
|
||||
public function tables()
|
||||
{
|
||||
return $this->hasMany(VenueTable::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class VenueTable extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'venue_id',
|
||||
'seating_area_id',
|
||||
'name',
|
||||
'capacity',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
public function venue()
|
||||
{
|
||||
return $this->belongsTo(Venue::class);
|
||||
}
|
||||
|
||||
public function seatingArea()
|
||||
{
|
||||
return $this->belongsTo(SeatingArea::class);
|
||||
}
|
||||
|
||||
public function assignments()
|
||||
{
|
||||
return $this->hasMany(ReservationTableAssignment::class);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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__))
|
||||
@@ -14,8 +13,6 @@ 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([
|
||||
@@ -7,10 +7,6 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-pdo_pgsql": "*",
|
||||
"ext-zip": "*",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
+2
-6
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "bf1df9d7ebfcbdc683785439b472c678",
|
||||
"content-hash": "ad91ab9bde70e3576f2b64dd11542bc4",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -8452,11 +8452,7 @@
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-pdo_pgsql": "*",
|
||||
"ext-zip": "*"
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
@@ -65,7 +65,7 @@ return [
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', 'https://tabeleymvp2-8b111c91640a.hosted.ghaymah.systems'),
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
+1
-2
@@ -10,8 +10,7 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('pricing_rules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Keep enum values aligned with the PostgreSQL schema (`pricing_scope_enum`).
|
||||
$table->enum('scope', ['global_type', 'venue']);
|
||||
$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);
|
||||
@@ -11,9 +11,17 @@ services:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
APP_URL: ${APP_URL:-http://127.0.0.1:8000}
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: ${DB_DATABASE:-tabeley_db}
|
||||
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-secret}
|
||||
RUN_MIGRATIONS: "true"
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- storage_data:/var/www/storage
|
||||
- storage_data:/var/www/html/storage
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
@@ -24,7 +32,7 @@ services:
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
- ./public:/var/www/public:ro
|
||||
- ./public:/var/www/html/public:ro
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
db:
|
||||
@@ -3,11 +3,14 @@ set -e
|
||||
|
||||
cd /var/www
|
||||
|
||||
echo "🚀 Starting Tabeley Production Container..."
|
||||
|
||||
# Create .env if not exists
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
# Update .env keys from runtime environment variables when provided.
|
||||
# Function to safely update env values
|
||||
set_env() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
@@ -20,31 +23,38 @@ set_env() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Inject runtime environment variables
|
||||
set_env DB_CONNECTION "${DB_CONNECTION}"
|
||||
set_env DB_HOST "${DB_HOST}"
|
||||
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}"
|
||||
set_env APP_DEBUG "${APP_DEBUG}"
|
||||
set_env APP_URL "${APP_URL}"
|
||||
|
||||
# Laravel setup
|
||||
php artisan config:clear || true
|
||||
if ! grep -q "^APP_KEY=base64:" .env; then
|
||||
|
||||
if [ -z "$APP_KEY" ]; then
|
||||
php artisan key:generate --force --no-interaction || true
|
||||
fi
|
||||
|
||||
php artisan storage:link || true
|
||||
|
||||
# Run migrations only if enabled
|
||||
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||
echo "📦 Running migrations..."
|
||||
php artisan migrate --force --no-interaction || true
|
||||
fi
|
||||
|
||||
# Cache for production performance
|
||||
php artisan config:cache || true
|
||||
php artisan route:cache || true
|
||||
php artisan view:cache || true
|
||||
|
||||
echo "✅ Container ready."
|
||||
|
||||
exec "$@"
|
||||
@@ -1,7 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /var/www/public;
|
||||
root /var/www/html/public;
|
||||
index index.php index.html;
|
||||
|
||||
client_max_body_size 20M;
|
||||
@@ -14,8 +14,8 @@ server {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/public$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/public;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||
fastcgi_read_timeout 300;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user