diff --git a/eval/fixtures/laravel-pms/.env.example b/eval/fixtures/laravel-pms/.env.example new file mode 100644 index 0000000..a48b693 --- /dev/null +++ b/eval/fixtures/laravel-pms/.env.example @@ -0,0 +1,14 @@ +# Copy to .env and fill in. The .env file is gitignored — never commit real +# secrets. Generate APP_KEY with `php artisan key:generate`. +APP_NAME="Laravel PMS" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +# SQLite is used so the app runs from a fresh clone with no database server. +# The file lives at database/database.sqlite (created by `php artisan migrate`). +DB_CONNECTION=sqlite + +# Server-side only. Fill in with your Seam API key. +SEAM_API_KEY= diff --git a/eval/fixtures/laravel-pms/.gitignore b/eval/fixtures/laravel-pms/.gitignore new file mode 100644 index 0000000..4fc8dd3 --- /dev/null +++ b/eval/fixtures/laravel-pms/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +/vendor + +# Local SQLite dev database +*.sqlite +*.sqlite-shm +*.sqlite-wal +database/*.sqlite + +# Environment (keep the example, ignore the real thing) +.env +.env.* +!.env.example + +# Laravel runtime +/storage/*.key +/storage/framework/cache/* +/storage/framework/sessions/* +/storage/framework/views/* +/storage/logs/* +/bootstrap/cache/* + +# Tooling +/.phpunit.cache +/public/hot +/public/storage + +# macOS +.DS_Store diff --git a/eval/fixtures/laravel-pms/README.md b/eval/fixtures/laravel-pms/README.md new file mode 100644 index 0000000..cae0f55 --- /dev/null +++ b/eval/fixtures/laravel-pms/README.md @@ -0,0 +1,37 @@ +# Laravel PMS + +A tiny property-management app built with [Laravel](https://laravel.com/), +Eloquent, and Blade templates. It manages **spaces** (bookable rooms, suites, +cabins…), takes **reservations** against them, and lists the **guests** who have +booked. It is the Laravel/PHP counterpart of the FastAPI `fastapi-pms` and +Next.js `nextjs-pms` samples, with the same domain so the three exercise the +same integration. + +## Getting started + +```bash +composer install +cp .env.example .env # then fill in SEAM_API_KEY +php artisan key:generate # sets APP_KEY +php artisan migrate --seed # creates database/database.sqlite and seeds a few spaces +php artisan serve +``` + +Open [http://localhost:8000](http://localhost:8000). The SQLite database +(`database/database.sqlite`) is created by `php artisan migrate`. Running with +`--seed` adds a few example spaces so the booking form's picker isn't empty; use +plain `php artisan migrate` if you'd rather start with none. + +## Layout + +- `routes/web.php` — the routes for every page and form post. +- `app/Models/` — the `Space` and `Reservation` Eloquent models. +- `app/Http/Controllers/` — `BookingController` (public form), `ReservationController` + (front desk), `SpaceController` (inventory), `GuestController` (derived list). +- `app/Services/AvailabilityService.php` — overlap/capacity checks shared by + booking and the front desk. +- `app/Exceptions/BookingException.php` — the guest-readable "can't book" error. +- `app/SpaceKinds.php` — the kinds of space and their display labels. +- `database/migrations/` — the `spaces` and `reservations` schema. +- `database/seeders/DatabaseSeeder.php` — a few starter spaces. +- `resources/views/` — the Blade pages: booking form, reservations, spaces, guests. diff --git a/eval/fixtures/laravel-pms/app/Exceptions/BookingException.php b/eval/fixtures/laravel-pms/app/Exceptions/BookingException.php new file mode 100644 index 0000000..5e28c39 --- /dev/null +++ b/eval/fixtures/laravel-pms/app/Exceptions/BookingException.php @@ -0,0 +1,14 @@ +where('status', 'active') + ->orderBy('name') + ->get(); + + return view('book', ['spaces' => $spaces]); + } + + /** Create a reservation from the public booking form. */ + public function book(Request $request): RedirectResponse + { + $data = $request->validate([ + 'guest_name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email'], + 'phone' => ['required', 'string', 'min:5'], + 'check_in' => ['required', 'date'], + 'check_out' => ['required', 'date', 'after:check_in'], + 'party_size' => ['integer', 'min:1', 'max:20'], + 'notes' => ['nullable', 'string', 'max:1000'], + 'space_id' => ['nullable', 'integer', 'exists:spaces,id'], + ]); + + $spaceId = isset($data['space_id']) ? (int) $data['space_id'] : null; + $partySize = (int) ($data['party_size'] ?? 1); + + if ($spaceId !== null) { + try { + $this->availability->assertSpaceBookable( + spaceId: $spaceId, + checkIn: $data['check_in'], + checkOut: $data['check_out'], + partySize: $partySize, + ); + } catch (BookingException $error) { + return back()->withInput()->with('error', $error->getMessage()); + } + } + + Reservation::create([ + 'guest_name' => $data['guest_name'], + 'email' => $data['email'], + 'phone' => $data['phone'], + 'check_in' => $data['check_in'], + 'check_out' => $data['check_out'], + 'party_size' => $partySize, + 'notes' => $data['notes'] ?? null, + 'space_id' => $spaceId, + ]); + + return redirect('/reservations'); + } +} diff --git a/eval/fixtures/laravel-pms/app/Http/Controllers/Controller.php b/eval/fixtures/laravel-pms/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/eval/fixtures/laravel-pms/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +orderByDesc('created_at') + ->get(); + + $byEmail = []; + foreach ($reservations as $reservation) { + $key = strtolower(trim($reservation->email)); + + if (isset($byEmail[$key])) { + $byEmail[$key]['reservation_count']++; + } else { + // rows are newest-first, so the first hit is the guest's latest details + $byEmail[$key] = [ + 'name' => $reservation->guest_name, + 'email' => $reservation->email, + 'phone' => $reservation->phone, + 'reservation_count' => 1, + ]; + } + } + + return view('guests', ['guests' => array_values($byEmail)]); + } +} diff --git a/eval/fixtures/laravel-pms/app/Http/Controllers/ReservationController.php b/eval/fixtures/laravel-pms/app/Http/Controllers/ReservationController.php new file mode 100644 index 0000000..ec7fbea --- /dev/null +++ b/eval/fixtures/laravel-pms/app/Http/Controllers/ReservationController.php @@ -0,0 +1,108 @@ +with('space') + ->orderByDesc('created_at') + ->get(); + + $spaces = Space::query() + ->orderBy('status') + ->orderBy('name') + ->get(); + + return view('reservations', [ + 'reservations' => $reservations, + 'spaces' => $spaces, + ]); + } + + /** Update a reservation's status (front desk). */ + public function updateStatus(Request $request, Reservation $reservation): RedirectResponse + { + $data = $request->validate([ + 'status' => ['required', 'in:pending,confirmed,cancelled'], + ]); + + // Cancelling releases the space, so reviving a cancelled stay has to win + // its space back — someone else may have taken it in the meantime. + if ( + $reservation->status === 'cancelled' + && $data['status'] !== 'cancelled' + && $reservation->space_id !== null + ) { + try { + $this->availability->assertSpaceBookable( + spaceId: $reservation->space_id, + checkIn: $reservation->check_in, + checkOut: $reservation->check_out, + partySize: $reservation->party_size, + excludeReservationId: $reservation->id, + ); + } catch (BookingException) { + return redirect('/reservations'); + } + } + + $reservation->update(['status' => $data['status']]); + + return redirect('/reservations'); + } + + /** Assign, move, or clear a reservation's space (front desk). */ + public function assign(Request $request, Reservation $reservation): RedirectResponse + { + $data = $request->validate([ + 'space_id' => ['nullable', 'integer', 'exists:spaces,id'], + ]); + + $spaceId = isset($data['space_id']) ? (int) $data['space_id'] : null; + + if ($spaceId !== null) { + try { + $this->availability->assertSpaceBookable( + spaceId: $spaceId, + checkIn: $reservation->check_in, + checkOut: $reservation->check_out, + partySize: $reservation->party_size, + excludeReservationId: $reservation->id, + ); + } catch (BookingException) { + return redirect('/reservations'); + } + } + + $reservation->update(['space_id' => $spaceId]); + + return redirect('/reservations'); + } + + /** Delete a reservation (front desk). */ + public function destroy(Reservation $reservation): RedirectResponse + { + $reservation->delete(); + + return redirect('/reservations'); + } +} diff --git a/eval/fixtures/laravel-pms/app/Http/Controllers/SpaceController.php b/eval/fixtures/laravel-pms/app/Http/Controllers/SpaceController.php new file mode 100644 index 0000000..9f2d0fd --- /dev/null +++ b/eval/fixtures/laravel-pms/app/Http/Controllers/SpaceController.php @@ -0,0 +1,106 @@ +orderBy('status') + ->orderBy('name') + ->get(); + + return view('spaces', [ + 'spaces' => $spaces, + 'kinds' => SpaceKinds::KINDS, + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $this->validated($request); + + Space::create($this->toRow($data)); + + return redirect('/spaces'); + } + + public function update(Request $request, Space $space): RedirectResponse + { + $data = $this->validated($request, $space->id); + + $space->update($this->toRow($data)); + + return redirect('/spaces'); + } + + /** + * Archive or restore a space. Archiving keeps it out of the booking picker + * without touching the reservations that already reference it. + */ + public function setStatus(Request $request, Space $space): RedirectResponse + { + $data = $request->validate([ + 'status' => ['required', 'in:active,archived'], + ]); + + $space->update(['status' => $data['status']]); + + return redirect('/spaces'); + } + + /** + * Validate the create / edit form. Names are unique, so the collision is + * surfaced as a friendly message instead of a raw database error. + * + * @return array + */ + private function validated(Request $request, ?int $ignoreId = null): array + { + return $request->validate( + [ + 'name' => [ + 'required', 'string', 'max:80', + Rule::unique('spaces', 'name')->ignore($ignoreId), + ], + 'kind' => ['required', Rule::in(SpaceKinds::KINDS)], + 'capacity' => ['integer', 'min:1', 'max:40'], + 'rate' => ['nullable', 'numeric', 'min:0', 'max:1000000'], + 'notes' => ['nullable', 'string', 'max:500'], + ], + [ + 'name.unique' => 'A space named “:input” already exists.', + ], + ); + } + + /** + * Column values for a Space, converting the rate to integer cents. + * + * @param array $data + * @return array + */ + private function toRow(array $data): array + { + $rate = $data['rate'] ?? null; + + return [ + 'name' => $data['name'], + 'kind' => $data['kind'], + 'capacity' => (int) ($data['capacity'] ?? 2), + 'rate_cents' => $rate === null ? null : (int) round(((float) $rate) * 100), + 'notes' => $data['notes'] ?? null, + ]; + } +} diff --git a/eval/fixtures/laravel-pms/app/Models/Reservation.php b/eval/fixtures/laravel-pms/app/Models/Reservation.php new file mode 100644 index 0000000..492b8e1 --- /dev/null +++ b/eval/fixtures/laravel-pms/app/Models/Reservation.php @@ -0,0 +1,67 @@ + 1, + 'status' => 'pending', + ]; + + protected function casts(): array + { + return [ + 'party_size' => 'integer', + 'space_id' => 'integer', + 'created_at' => 'datetime', + ]; + } + + /** + * Assigned space. Nullable: a stay can be taken before the front desk has + * decided which space the guest gets. + * + * @return BelongsTo + */ + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } +} diff --git a/eval/fixtures/laravel-pms/app/Models/Space.php b/eval/fixtures/laravel-pms/app/Models/Space.php new file mode 100644 index 0000000..9416836 --- /dev/null +++ b/eval/fixtures/laravel-pms/app/Models/Space.php @@ -0,0 +1,56 @@ + 'room', + 'capacity' => 2, + 'status' => 'active', + ]; + + protected function casts(): array + { + return [ + 'capacity' => 'integer', + 'rate_cents' => 'integer', + 'created_at' => 'datetime', + ]; + } + + /** @return HasMany */ + public function reservations(): HasMany + { + return $this->hasMany(Reservation::class); + } +} diff --git a/eval/fixtures/laravel-pms/app/Providers/AppServiceProvider.php b/eval/fixtures/laravel-pms/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/eval/fixtures/laravel-pms/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ + + */ + public function bookedSpaceIds( + string $checkIn, + string $checkOut, + ?int $excludeReservationId = null, + ): array { + $query = Reservation::query() + ->whereNotNull('space_id') + ->where('status', '!=', 'cancelled') + // ISO YYYY-MM-DD sorts lexicographically, so text compare is date compare. + ->where('check_in', '<', $checkOut) + ->where('check_out', '>', $checkIn); + + if ($excludeReservationId !== null) { + $query->where('id', '!=', $excludeReservationId); + } + + return $query->pluck('space_id') + ->filter(fn ($spaceId) => $spaceId !== null) + ->map(fn ($spaceId) => (int) $spaceId) + ->all(); + } + + /** + * Assert a space can take a stay, throwing BookingException if not. + */ + public function assertSpaceBookable( + int $spaceId, + string $checkIn, + string $checkOut, + int $partySize, + ?int $excludeReservationId = null, + ): Space { + $space = Space::find($spaceId); + + if ($space === null) { + throw new BookingException('That space no longer exists.'); + } + if ($space->status !== 'active') { + throw new BookingException("{$space->name} is archived and can't be booked."); + } + if ($partySize > $space->capacity) { + throw new BookingException( + "{$space->name} sleeps {$space->capacity}, but this stay is for {$partySize}." + ); + } + + $booked = $this->bookedSpaceIds($checkIn, $checkOut, $excludeReservationId); + if (in_array($spaceId, $booked, true)) { + throw new BookingException("{$space->name} is already booked for those dates."); + } + + return $space; + } +} diff --git a/eval/fixtures/laravel-pms/app/SpaceKinds.php b/eval/fixtures/laravel-pms/app/SpaceKinds.php new file mode 100644 index 0000000..fee13f4 --- /dev/null +++ b/eval/fixtures/laravel-pms/app/SpaceKinds.php @@ -0,0 +1,31 @@ + */ + public const KINDS = ['room', 'suite', 'cabin', 'villa', 'tent', 'other']; + + /** @var array */ + public const LABELS = [ + 'room' => 'Room', + 'suite' => 'Suite', + 'cabin' => 'Cabin', + 'villa' => 'Villa', + 'tent' => 'Tent', + 'other' => 'Space', + ]; + + /** Human label for a kind, falling back to the generic "Space". */ + public static function label(string $kind): string + { + return self::LABELS[$kind] ?? 'Space'; + } +} diff --git a/eval/fixtures/laravel-pms/artisan b/eval/fixtures/laravel-pms/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/eval/fixtures/laravel-pms/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/eval/fixtures/laravel-pms/bootstrap/app.php b/eval/fixtures/laravel-pms/bootstrap/app.php new file mode 100644 index 0000000..7b162da --- /dev/null +++ b/eval/fixtures/laravel-pms/bootstrap/app.php @@ -0,0 +1,18 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + // + }) + ->withExceptions(function (Exceptions $exceptions) { + // + })->create(); diff --git a/eval/fixtures/laravel-pms/bootstrap/providers.php b/eval/fixtures/laravel-pms/bootstrap/providers.php new file mode 100644 index 0000000..38b258d --- /dev/null +++ b/eval/fixtures/laravel-pms/bootstrap/providers.php @@ -0,0 +1,5 @@ + env('APP_NAME', 'Laravel PMS'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/eval/fixtures/laravel-pms/config/database.php b/eval/fixtures/laravel-pms/config/database.php new file mode 100644 index 0000000..0f69647 --- /dev/null +++ b/eval/fixtures/laravel-pms/config/database.php @@ -0,0 +1,118 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + ], + +]; diff --git a/eval/fixtures/laravel-pms/database/migrations/2024_01_01_000001_create_spaces_table.php b/eval/fixtures/laravel-pms/database/migrations/2024_01_01_000001_create_spaces_table.php new file mode 100644 index 0000000..28722be --- /dev/null +++ b/eval/fixtures/laravel-pms/database/migrations/2024_01_01_000001_create_spaces_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name')->unique(); + $table->string('kind')->default('room'); + // Maximum party size this space sleeps. + $table->integer('capacity')->default(2); + // Nightly rate in cents, or null when no rate has been set. + $table->integer('rate_cents')->nullable(); + $table->string('status')->default('active'); + $table->text('notes')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('spaces'); + } +}; diff --git a/eval/fixtures/laravel-pms/database/migrations/2024_01_01_000002_create_reservations_table.php b/eval/fixtures/laravel-pms/database/migrations/2024_01_01_000002_create_reservations_table.php new file mode 100644 index 0000000..7905576 --- /dev/null +++ b/eval/fixtures/laravel-pms/database/migrations/2024_01_01_000002_create_reservations_table.php @@ -0,0 +1,47 @@ +id(); + + // Guest / user data. + $table->string('guest_name'); + $table->string('email'); + $table->string('phone'); + + // Stay details. Dates are ISO YYYY-MM-DD strings, which sort as dates. + $table->string('check_in'); + $table->string('check_out'); + $table->integer('party_size')->default(1); + $table->text('notes')->nullable(); + + // Assigned space. Nullable: a stay can be taken before the front desk + // has decided which space the guest gets. Clearing the space on delete + // keeps the reservation row valid. + $table->foreignId('space_id') + ->nullable() + ->constrained('spaces') + ->nullOnDelete(); + + // Lifecycle. + $table->string('status')->default('pending'); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reservations'); + } +}; diff --git a/eval/fixtures/laravel-pms/database/seeders/DatabaseSeeder.php b/eval/fixtures/laravel-pms/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..4b0e995 --- /dev/null +++ b/eval/fixtures/laravel-pms/database/seeders/DatabaseSeeder.php @@ -0,0 +1,43 @@ + 'Seagrass Suite', + 'kind' => 'suite', + 'capacity' => 4, + 'rate_cents' => 24_000, + 'notes' => 'Ocean view, walk-in shower', + ], + [ + 'name' => 'Dune Cabin', + 'kind' => 'cabin', + 'capacity' => 2, + 'rate_cents' => 16_000, + 'notes' => null, + ], + [ + 'name' => 'Harbor Room 101', + 'kind' => 'room', + 'capacity' => 2, + 'rate_cents' => 12_000, + 'notes' => null, + ], + ]; + + foreach ($spaces as $space) { + Space::firstOrCreate(['name' => $space['name']], $space); + } + } +} diff --git a/eval/fixtures/laravel-pms/fixture.json b/eval/fixtures/laravel-pms/fixture.json new file mode 100644 index 0000000..41ecc1d --- /dev/null +++ b/eval/fixtures/laravel-pms/fixture.json @@ -0,0 +1,5 @@ +{ + "name": "laravel-pms", + "sdk": "php", + "framework": "Laravel" +} diff --git a/eval/fixtures/laravel-pms/public/index.php b/eval/fixtures/laravel-pms/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/eval/fixtures/laravel-pms/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/eval/fixtures/laravel-pms/resources/views/book.blade.php b/eval/fixtures/laravel-pms/resources/views/book.blade.php new file mode 100644 index 0000000..4195718 --- /dev/null +++ b/eval/fixtures/laravel-pms/resources/views/book.blade.php @@ -0,0 +1,48 @@ +@extends('layouts.app') + +@section('title', 'Book a stay · Laravel PMS') + +@section('content') +

Book a stay

+ + @php($formError = session('error') ?? ($errors->any() ? $errors->first() : null)) + @if ($formError) +

{{ $formError }}

+ @endif + +
+ @csrf + + + + + + + + + + + + + + + + + + + + + + + + +

+
+@endsection diff --git a/eval/fixtures/laravel-pms/resources/views/guests.blade.php b/eval/fixtures/laravel-pms/resources/views/guests.blade.php new file mode 100644 index 0000000..43b8c40 --- /dev/null +++ b/eval/fixtures/laravel-pms/resources/views/guests.blade.php @@ -0,0 +1,23 @@ +@extends('layouts.app') + +@section('title', 'Guests · Laravel PMS') + +@section('content') +

Guests

+

{{ count($guests) }} unique guest(s)

+ + @if (empty($guests)) +
No guests yet.
+ @else + @foreach ($guests as $guest) + + @endforeach + @endif +@endsection diff --git a/eval/fixtures/laravel-pms/resources/views/layouts/app.blade.php b/eval/fixtures/laravel-pms/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..7e17bd5 --- /dev/null +++ b/eval/fixtures/laravel-pms/resources/views/layouts/app.blade.php @@ -0,0 +1,76 @@ + + + + + + @yield('title', 'Laravel PMS') + + + + +
@yield('content')
+ + diff --git a/eval/fixtures/laravel-pms/resources/views/reservations.blade.php b/eval/fixtures/laravel-pms/resources/views/reservations.blade.php new file mode 100644 index 0000000..7fbf92a --- /dev/null +++ b/eval/fixtures/laravel-pms/resources/views/reservations.blade.php @@ -0,0 +1,72 @@ +@extends('layouts.app') + +@section('title', 'Reservations · Laravel PMS') + +@section('content') +

Reservations

+

{{ $reservations->count() }} total

+ + @if ($reservations->isEmpty()) +
No reservations yet. Once guests book, they'll show up here.
+ @else + @foreach ($reservations as $reservation) +
+
+ {{ $reservation->guest_name }} + + {{ $reservation->status }} + + #{{ $reservation->id }} +
+
+ {{ $reservation->email }} · + {{ $reservation->phone }} · + {{ $reservation->party_size }} guest(s) +
+
{{ $reservation->check_in }} → {{ $reservation->check_out }}
+ @if ($reservation->notes) +

"{{ $reservation->notes }}"

+ @endif + +
+ @csrf + + +
+ +
+ @if ($reservation->status !== 'confirmed') +
+ @csrf + + +
+ @endif + @if ($reservation->status !== 'cancelled') +
+ @csrf + + +
+ @endif +
+ @csrf + +
+
+
+ @endforeach + @endif +@endsection diff --git a/eval/fixtures/laravel-pms/resources/views/spaces.blade.php b/eval/fixtures/laravel-pms/resources/views/spaces.blade.php new file mode 100644 index 0000000..2fdf3f7 --- /dev/null +++ b/eval/fixtures/laravel-pms/resources/views/spaces.blade.php @@ -0,0 +1,61 @@ +@extends('layouts.app') + +@section('title', 'Spaces · Laravel PMS') + +@section('content') +

Spaces

+ + @php($formError = session('error') ?? ($errors->any() ? $errors->first() : null)) + @if ($formError) +

{{ $formError }}

+ @endif + +
+ @csrf +

Add a space

+ + + + + + + + + + + + + + + +

+
+ + @foreach ($spaces as $space) +
+ {{ $space->name }} · {{ \App\SpaceKinds::label($space->kind) }} · sleeps {{ $space->capacity }} + {{ $space->status }} + @if ($space->rate_cents) +
Rate: {{ number_format($space->rate_cents / 100, 2) }} / night
+ @endif + @if ($space->notes) +

{{ $space->notes }}

+ @endif +
+ @csrf + + +
+
+ @endforeach +@endsection diff --git a/eval/fixtures/laravel-pms/routes/console.php b/eval/fixtures/laravel-pms/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/eval/fixtures/laravel-pms/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/eval/fixtures/laravel-pms/routes/web.php b/eval/fixtures/laravel-pms/routes/web.php new file mode 100644 index 0000000..9a5d50d --- /dev/null +++ b/eval/fixtures/laravel-pms/routes/web.php @@ -0,0 +1,26 @@ + 7.1" + +# SQLite for the local dev database. +gem "sqlite3", "~> 1.7" + +# App server. +gem "puma", "~> 6.4" + +# Seam SDK — listed as a dependency, but NOT yet integrated anywhere in this +# app. A future integration would use it (server-side only) to issue smart-lock +# access for a reservation. See README + .env.example (SEAM_API_KEY). +gem "seam" + +group :development, :test do + # Loads .env into ENV so SEAM_API_KEY (and friends) are available in dev. + gem "dotenv-rails" +end diff --git a/eval/fixtures/rails-pms/README.md b/eval/fixtures/rails-pms/README.md new file mode 100644 index 0000000..77b0d27 --- /dev/null +++ b/eval/fixtures/rails-pms/README.md @@ -0,0 +1,37 @@ +# Rails PMS + +A tiny property-management app built with [Ruby on Rails](https://rubyonrails.org/), +Active Record, and ERB views. It manages **spaces** (bookable rooms, suites, +cabins…), takes **reservations** against them, and lists the **guests** who have +booked. It is the Rails/Ruby counterpart of the FastAPI `fastapi-pms` and +Next.js `nextjs-pms` samples, with the same domain so all three exercise the +same integration. + +## Getting started + +```bash +bundle install +cp .env.example .env # then fill in SEAM_API_KEY +bin/rails db:setup # creates the SQLite database and seeds a few spaces +bin/rails server +``` + +Open [http://localhost:3000](http://localhost:3000). The SQLite database +(`db/development.sqlite3`) is created by `db:setup`. + +## Layout + +- `config/routes.rb` — the routes for each page and front-desk action. +- `app/models/space.rb` / `app/models/reservation.rb` — the Active Record models. +- `app/models/availability.rb` — overlap/capacity checks shared by booking and the front desk. +- `app/models/guest.rb` — the derived guest view (reservations deduped by email). +- `app/controllers/` — `bookings` (public form), `reservations` (front desk), `spaces` (inventory), `guests`. +- `app/views/` — the ERB pages: booking form, reservations, spaces, guests. +- `db/` — migrations, schema, and seeds. + +## Pages + +- `/` — the booking form (home). +- `/reservations` — the front desk: reservation cards with guest, status, dates, and the assigned space. +- `/spaces` — inventory: add spaces and archive/restore them. +- `/guests` — the deduped guest list. diff --git a/eval/fixtures/rails-pms/app/controllers/application_controller.rb b/eval/fixtures/rails-pms/app/controllers/application_controller.rb new file mode 100644 index 0000000..1c07694 --- /dev/null +++ b/eval/fixtures/rails-pms/app/controllers/application_controller.rb @@ -0,0 +1,3 @@ +class ApplicationController < ActionController::Base + protect_from_forgery with: :exception +end diff --git a/eval/fixtures/rails-pms/app/controllers/bookings_controller.rb b/eval/fixtures/rails-pms/app/controllers/bookings_controller.rb new file mode 100644 index 0000000..ea65739 --- /dev/null +++ b/eval/fixtures/rails-pms/app/controllers/bookings_controller.rb @@ -0,0 +1,72 @@ +# The public booking flow: the landing page and the form that creates a stay. +class BookingsController < ApplicationController + # The landing page with the booking form and the list of active spaces. + def index + @spaces = Space.only_active.order(:name) + @error = nil + render :index + end + + # Create a reservation from the public booking form. + def create + reservation = Reservation.new(booking_params) + + unless reservation.valid? + return render_home_error( + reservation.errors.full_messages.first || "That booking looks invalid.", + :unprocessable_entity, + ) + end + + if reservation.space_id.present? + begin + Availability.assert_space_bookable( + space_id: reservation.space_id, + check_in: reservation.check_in, + check_out: reservation.check_out, + party_size: reservation.party_size, + ) + rescue Availability::BookingError => error + return render_home_error(error.message, :conflict) + end + end + + reservation.save! + redirect_to reservations_path, status: :see_other + end + + private + + def booking_params + permitted = + params.permit( + :guest_name, + :email, + :phone, + :check_in, + :check_out, + :party_size, + :notes, + :space_id, + ) + + { + guest_name: permitted[:guest_name].to_s, + email: permitted[:email].to_s, + phone: permitted[:phone].to_s, + check_in: permitted[:check_in].to_s, + check_out: permitted[:check_out].to_s, + # Default to 1 guest when the field is blank. + party_size: permitted[:party_size].presence || 1, + notes: permitted[:notes].presence, + # Blank = let the front desk assign a space later. + space_id: permitted[:space_id].presence, + } + end + + def render_home_error(message, status) + @spaces = Space.only_active.order(:name) + @error = message + render :index, status: status + end +end diff --git a/eval/fixtures/rails-pms/app/controllers/guests_controller.rb b/eval/fixtures/rails-pms/app/controllers/guests_controller.rb new file mode 100644 index 0000000..24b534e --- /dev/null +++ b/eval/fixtures/rails-pms/app/controllers/guests_controller.rb @@ -0,0 +1,6 @@ +# The derived guests page (reservations deduped by email). +class GuestsController < ApplicationController + def index + @guests = Guest.list + end +end diff --git a/eval/fixtures/rails-pms/app/controllers/reservations_controller.rb b/eval/fixtures/rails-pms/app/controllers/reservations_controller.rb new file mode 100644 index 0000000..7631166 --- /dev/null +++ b/eval/fixtures/rails-pms/app/controllers/reservations_controller.rb @@ -0,0 +1,70 @@ +# The front desk: list reservations, change status, assign a space, delete. +class ReservationsController < ApplicationController + # The front-desk list, with the spaces available for reassignment. + def index + @reservations = Reservation.list + @spaces = Space.list + end + + # Update a reservation's status (front desk). + def update_status + reservation = Reservation.find_by(id: params[:id]) + return redirect_to(reservations_path, status: :see_other) if reservation.nil? + + new_status = params[:status].to_s + unless Reservation::STATUSES.include?(new_status) + return redirect_to(reservations_path, status: :see_other) + end + + # Cancelling releases the space, so reviving a cancelled stay has to win its + # space back — someone else may have taken it in the meantime. + if reservation.status == "cancelled" && new_status != "cancelled" && reservation.space_id.present? + begin + Availability.assert_space_bookable( + space_id: reservation.space_id, + check_in: reservation.check_in, + check_out: reservation.check_out, + party_size: reservation.party_size, + exclude_reservation_id: reservation.id, + ) + rescue Availability::BookingError + return redirect_to(reservations_path, status: :see_other) + end + end + + reservation.update!(status: new_status) + redirect_to reservations_path, status: :see_other + end + + # Assign, move, or clear a reservation's space (front desk). + def assign_space + reservation = Reservation.find_by(id: params[:id]) + return redirect_to(reservations_path, status: :see_other) if reservation.nil? + + space_id = params[:space_id].presence + + if space_id.present? + begin + Availability.assert_space_bookable( + space_id: space_id.to_i, + check_in: reservation.check_in, + check_out: reservation.check_out, + party_size: reservation.party_size, + exclude_reservation_id: reservation.id, + ) + rescue Availability::BookingError + return redirect_to(reservations_path, status: :see_other) + end + end + + reservation.update!(space_id: space_id) + redirect_to reservations_path, status: :see_other + end + + # Delete a reservation (front desk). + def destroy + reservation = Reservation.find_by(id: params[:id]) + reservation&.destroy + redirect_to reservations_path, status: :see_other + end +end diff --git a/eval/fixtures/rails-pms/app/controllers/spaces_controller.rb b/eval/fixtures/rails-pms/app/controllers/spaces_controller.rb new file mode 100644 index 0000000..6032060 --- /dev/null +++ b/eval/fixtures/rails-pms/app/controllers/spaces_controller.rb @@ -0,0 +1,76 @@ +# Space inventory: create, edit, and archive/restore bookable spaces. +class SpacesController < ApplicationController + def index + @spaces = Space.list + @kinds = Space::KINDS + @error = nil + render :index + end + + def create + space = Space.new(space_params) + if space.save + redirect_to spaces_path, status: :see_other + else + render_spaces_error(space) + end + end + + def update + space = Space.find_by(id: params[:id]) + return redirect_to(spaces_path, status: :see_other) if space.nil? + + if space.update(space_params) + redirect_to spaces_path, status: :see_other + else + render_spaces_error(space) + end + end + + # Archive or restore a space. Archiving keeps it out of the booking picker + # without touching the reservations that already reference it. + def set_status + space = Space.find_by(id: params[:id]) + if space + new_status = params[:status].to_s + space.update!(status: new_status) if Space::STATUSES.include?(new_status) + end + redirect_to spaces_path, status: :see_other + end + + private + + def space_params + permitted = params.permit(:name, :kind, :capacity, :rate, :notes) + + kind = permitted[:kind].presence || "room" + kind = "room" unless Space::KINDS.include?(kind) + + # Nightly rate is entered in whole currency units; store integer cents. + rate = permitted[:rate].presence + + { + name: permitted[:name].to_s, + kind: kind, + capacity: permitted[:capacity].presence || 2, + rate_cents: rate.nil? ? nil : (rate.to_f * 100).round, + notes: permitted[:notes].presence, + } + end + + def render_spaces_error(space) + @spaces = Space.list + @kinds = Space::KINDS + @error = friendly_space_error(space) + render :index, status: :unprocessable_entity + end + + # Names are unique, so surface the collision instead of a raw database error. + def friendly_space_error(space) + if space.errors.of_kind?(:name, :taken) + "A space named “#{space.name}” already exists." + else + space.errors.full_messages.first || "That space looks invalid." + end + end +end diff --git a/eval/fixtures/rails-pms/app/helpers/application_helper.rb b/eval/fixtures/rails-pms/app/helpers/application_helper.rb new file mode 100644 index 0000000..f90652c --- /dev/null +++ b/eval/fixtures/rails-pms/app/helpers/application_helper.rb @@ -0,0 +1,14 @@ +module ApplicationHelper + # Badge background color for a reservation status. + def reservation_status_color(status) + case status + when "confirmed" then "#d1fae5" + when "cancelled" then "#fee2e2" + else "#fef3c7" + end + end + + def space_kind_label(kind) + Space::KIND_LABELS.fetch(kind, "Space") + end +end diff --git a/eval/fixtures/rails-pms/app/models/application_record.rb b/eval/fixtures/rails-pms/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/eval/fixtures/rails-pms/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/eval/fixtures/rails-pms/app/models/availability.rb b/eval/fixtures/rails-pms/app/models/availability.rb new file mode 100644 index 0000000..e415ce7 --- /dev/null +++ b/eval/fixtures/rails-pms/app/models/availability.rb @@ -0,0 +1,67 @@ +require "set" + +# Availability helpers shared by booking and front-desk reassignment. +module Availability + # A guest-readable reason a space can't take a stay. + class BookingError < StandardError; end + + module_function + + # Space ids already held for the given range, excluding one reservation. + # + # Reservations hold a space for the half-open interval [check_in, check_out), + # so a same-day turnover (one guest out, the next in) is not a conflict. + # Cancelled reservations release the space. + def booked_space_ids(check_in:, check_out:, exclude_reservation_id: nil) + # ISO YYYY-MM-DD sorts lexicographically, so a text compare is a date compare. + scope = + Reservation + .where.not(space_id: nil) + .where.not(status: "cancelled") + .where("check_in < ?", check_out) + .where("check_out > ?", check_in) + scope = scope.where.not(id: exclude_reservation_id) if exclude_reservation_id + + scope.distinct.pluck(:space_id).compact.to_set + end + + # Assert a space can take a stay, raising BookingError if not. + def assert_space_bookable(space_id:, check_in:, check_out:, party_size:, exclude_reservation_id: nil) + space = Space.find_by(id: space_id) + raise BookingError, "That space no longer exists." if space.nil? + raise BookingError, "#{space.name} is archived and can't be booked." unless space.active? + + if party_size > space.capacity + raise BookingError, + "#{space.name} sleeps #{space.capacity}, but this stay is for #{party_size}." + end + + booked = + booked_space_ids( + check_in: check_in, + check_out: check_out, + exclude_reservation_id: exclude_reservation_id, + ) + raise BookingError, "#{space.name} is already booked for those dates." if booked.include?(space.id) + + space + end + + # Active spaces annotated with whether they can take the given stay. + def list_space_availability(check_in:, check_out:, party_size:) + booked = booked_space_ids(check_in: check_in, check_out: check_out) + + Space + .only_active + .order(:name) + .map do |space| + if booked.include?(space.id) + { space: space, available: false, reason: "Booked for these dates" } + elsif party_size > space.capacity + { space: space, available: false, reason: "Sleeps #{space.capacity}" } + else + { space: space, available: true, reason: nil } + end + end + end +end diff --git a/eval/fixtures/rails-pms/app/models/guest.rb b/eval/fixtures/rails-pms/app/models/guest.rb new file mode 100644 index 0000000..1f45491 --- /dev/null +++ b/eval/fixtures/rails-pms/app/models/guest.rb @@ -0,0 +1,38 @@ +# A derived view of a guest: reservations deduped by lowercased email, keeping +# the latest contact details and a count. Not an Active Record model — there is +# no guests table; guests are computed from reservations. +class Guest + attr_reader :name, :email, :phone + attr_accessor :reservation_count + + def initialize(name:, email:, phone:, reservation_count:) + @name = name + @email = email + @phone = phone + @reservation_count = reservation_count + end + + # Unique guests (deduped by email), with how many reservations each has. + def self.list + by_email = {} + + Reservation.newest_first.each do |reservation| + key = reservation.email.to_s.strip.downcase + existing = by_email[key] + + if existing + existing.reservation_count += 1 + else + # rows are newest-first, so the first hit is the guest's latest details + by_email[key] = new( + name: reservation.guest_name, + email: reservation.email, + phone: reservation.phone, + reservation_count: 1, + ) + end + end + + by_email.values + end +end diff --git a/eval/fixtures/rails-pms/app/models/reservation.rb b/eval/fixtures/rails-pms/app/models/reservation.rb new file mode 100644 index 0000000..19514c5 --- /dev/null +++ b/eval/fixtures/rails-pms/app/models/reservation.rb @@ -0,0 +1,40 @@ +# A single reservation. +# +# Guest contact details are stored inline (no separate accounts / login) to keep +# the PMS minimal. Dates are ISO YYYY-MM-DD strings, which sort as dates. +class Reservation < ApplicationRecord + STATUSES = %w[pending confirmed cancelled].freeze + + # Nullable: a stay can be taken before the front desk has decided which space + # the guest gets. Clearing the space on delete keeps the reservation valid. + belongs_to :space, optional: true + + validates :guest_name, presence: true + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :phone, presence: true, length: { minimum: 5 } + validates :check_in, presence: true + validates :check_out, presence: true + validates :party_size, + numericality: { + only_integer: true, + greater_than_or_equal_to: 1, + less_than_or_equal_to: 20, + } + validates :status, inclusion: { in: STATUSES } + validate :check_out_after_check_in + + scope :newest_first, -> { order(created_at: :desc) } + + # All reservations, newest first, with the assigned space eager-loaded. + def self.list + newest_first.includes(:space) + end + + private + + def check_out_after_check_in + return if check_in.blank? || check_out.blank? + + errors.add(:check_out, "must be after check-in") if check_out <= check_in + end +end diff --git a/eval/fixtures/rails-pms/app/models/space.rb b/eval/fixtures/rails-pms/app/models/space.rb new file mode 100644 index 0000000..5a3a8b9 --- /dev/null +++ b/eval/fixtures/rails-pms/app/models/space.rb @@ -0,0 +1,45 @@ +# A bookable space (room, suite, cabin…). +# +# Spaces are archived rather than deleted so past reservations keep pointing at +# something real. +class Space < ApplicationRecord + # The kinds of bookable space a property can offer, plus their display labels. + KINDS = %w[room suite cabin villa tent other].freeze + KIND_LABELS = { + "room" => "Room", + "suite" => "Suite", + "cabin" => "Cabin", + "villa" => "Villa", + "tent" => "Tent", + "other" => "Space", + }.freeze + + STATUSES = %w[active archived].freeze + + has_many :reservations, dependent: :nullify + + validates :name, presence: true, uniqueness: true, length: { maximum: 80 } + validates :kind, inclusion: { in: KINDS } + validates :capacity, + numericality: { + only_integer: true, + greater_than_or_equal_to: 1, + less_than_or_equal_to: 40, + } + validates :status, inclusion: { in: STATUSES } + + scope :only_active, -> { where(status: "active") } + + # Every space, active first then alphabetical. + def self.list + order(Arel.sql("status ASC"), Arel.sql("name ASC")) + end + + def active? + status == "active" + end + + def kind_label + KIND_LABELS.fetch(kind, "Space") + end +end diff --git a/eval/fixtures/rails-pms/app/views/bookings/index.html.erb b/eval/fixtures/rails-pms/app/views/bookings/index.html.erb new file mode 100644 index 0000000..40c840b --- /dev/null +++ b/eval/fixtures/rails-pms/app/views/bookings/index.html.erb @@ -0,0 +1,41 @@ +<% content_for :title, "Book a stay · Rails PMS" %> + +

Book a stay

+<% if @error %> +

<%= @error %>

+<% end %> + +<%= form_with url: book_path, method: :post, class: "card" do %> + + + + + + + + + + + + + + + + + + + + + + + + +

+<% end %> diff --git a/eval/fixtures/rails-pms/app/views/guests/index.html.erb b/eval/fixtures/rails-pms/app/views/guests/index.html.erb new file mode 100644 index 0000000..290bc8a --- /dev/null +++ b/eval/fixtures/rails-pms/app/views/guests/index.html.erb @@ -0,0 +1,19 @@ +<% content_for :title, "Guests · Rails PMS" %> + +

Guests

+

<%= @guests.length %> unique guest(s)

+ +<% if @guests.empty? %> +
No guests yet.
+<% else %> + <% @guests.each do |guest| %> + + <% end %> +<% end %> diff --git a/eval/fixtures/rails-pms/app/views/layouts/application.html.erb b/eval/fixtures/rails-pms/app/views/layouts/application.html.erb new file mode 100644 index 0000000..430dcb0 --- /dev/null +++ b/eval/fixtures/rails-pms/app/views/layouts/application.html.erb @@ -0,0 +1,77 @@ + + + + + + <%= content_for?(:title) ? yield(:title) : "Rails PMS" %> + <%= csrf_meta_tags %> + + + + +
<%= yield %>
+ + diff --git a/eval/fixtures/rails-pms/app/views/reservations/index.html.erb b/eval/fixtures/rails-pms/app/views/reservations/index.html.erb new file mode 100644 index 0000000..11643fe --- /dev/null +++ b/eval/fixtures/rails-pms/app/views/reservations/index.html.erb @@ -0,0 +1,62 @@ +<% content_for :title, "Reservations · Rails PMS" %> + +

Reservations

+

<%= @reservations.length %> total

+ +<% if @reservations.empty? %> +
No reservations yet. Once guests book, they'll show up here.
+<% else %> + <% @reservations.each do |reservation| %> +
+
+ <%= reservation.guest_name %> + + <%= reservation.status %> + + #<%= reservation.id %> +
+
+ <%= reservation.email %> · + <%= reservation.phone %> · + <%= reservation.party_size %> guest(s) +
+
<%= reservation.check_in %> → <%= reservation.check_out %>
+ <% if reservation.notes.present? %> +

"<%= reservation.notes %>"

+ <% end %> + + <%= form_with url: assign_reservation_path(reservation), method: :post do %> + + + <% end %> + +
+ <% if reservation.status != "confirmed" %> + <%= button_to "Confirm", + status_reservation_path(reservation), + params: { status: "confirmed" }, + form: { style: "display: inline" } %> + <% end %> + <% if reservation.status != "cancelled" %> + <%= button_to "Cancel", + status_reservation_path(reservation), + params: { status: "cancelled" }, + form: { style: "display: inline" } %> + <% end %> + <%= button_to "Delete", + reservation_path(reservation), + method: :post, + form: { style: "display: inline" } %> +
+
+ <% end %> +<% end %> diff --git a/eval/fixtures/rails-pms/app/views/spaces/index.html.erb b/eval/fixtures/rails-pms/app/views/spaces/index.html.erb new file mode 100644 index 0000000..5d3837d --- /dev/null +++ b/eval/fixtures/rails-pms/app/views/spaces/index.html.erb @@ -0,0 +1,47 @@ +<% content_for :title, "Spaces · Rails PMS" %> + +

Spaces

+<% if @error %> +

<%= @error %>

+<% end %> + +<%= form_with url: spaces_path, method: :post, class: "card" do %> +

Add a space

+ + + + + + + + + + + + + + + +

+<% end %> + +<% @spaces.each do |space| %> +
+ <%= space.name %> · <%= space.kind_label %> · sleeps <%= space.capacity %> + <%= space.status %> + <% if space.rate_cents %> +
Rate: <%= format("%.2f", space.rate_cents / 100.0) %> / night
+ <% end %> + <% if space.notes.present? %> +

<%= space.notes %>

+ <% end %> + <%= button_to (space.active? ? "Archive" : "Restore"), + status_space_path(space), + params: { status: (space.active? ? "archived" : "active") }, + form: { style: "display: inline" } %> +
+<% end %> diff --git a/eval/fixtures/rails-pms/bin/rails b/eval/fixtures/rails-pms/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/eval/fixtures/rails-pms/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/eval/fixtures/rails-pms/bin/rake b/eval/fixtures/rails-pms/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/eval/fixtures/rails-pms/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/eval/fixtures/rails-pms/bin/setup b/eval/fixtures/rails-pms/bin/setup new file mode 100755 index 0000000..205b793 --- /dev/null +++ b/eval/fixtures/rails-pms/bin/setup @@ -0,0 +1,22 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment + # automatically. Run it whenever you pull new code. + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" +end diff --git a/eval/fixtures/rails-pms/config.ru b/eval/fixtures/rails-pms/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/eval/fixtures/rails-pms/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/eval/fixtures/rails-pms/config/application.rb b/eval/fixtures/rails-pms/config/application.rb new file mode 100644 index 0000000..3940f80 --- /dev/null +++ b/eval/fixtures/rails-pms/config/application.rb @@ -0,0 +1,22 @@ +require_relative "boot" + +require "rails" +# Pick only the frameworks this PMS actually needs (no Action Mailer, Active +# Job, Action Cable, or the asset pipeline — the views inline their own CSS). +require "active_model/railtie" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" + +# Require the gems listed in Gemfile, including any gems limited to the current +# Rails environment. +Bundler.require(*Rails.groups) + +module RailsPms + class Application < Rails::Application + config.load_defaults 7.1 + + # Server-rendered ERB views, not an API-only app. + config.api_only = false + end +end diff --git a/eval/fixtures/rails-pms/config/boot.rb b/eval/fixtures/rails-pms/config/boot.rb new file mode 100644 index 0000000..2820116 --- /dev/null +++ b/eval/fixtures/rails-pms/config/boot.rb @@ -0,0 +1,3 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/eval/fixtures/rails-pms/config/database.yml b/eval/fixtures/rails-pms/config/database.yml new file mode 100644 index 0000000..a15e038 --- /dev/null +++ b/eval/fixtures/rails-pms/config/database.yml @@ -0,0 +1,17 @@ +# SQLite. The database files live under db/ and are gitignored. +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: db/development.sqlite3 + +test: + <<: *default + database: db/test.sqlite3 + +production: + <<: *default + database: db/production.sqlite3 diff --git a/eval/fixtures/rails-pms/config/environment.rb b/eval/fixtures/rails-pms/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/eval/fixtures/rails-pms/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/eval/fixtures/rails-pms/config/environments/development.rb b/eval/fixtures/rails-pms/config/environments/development.rb new file mode 100644 index 0000000..1ef08d2 --- /dev/null +++ b/eval/fixtures/rails-pms/config/environments/development.rb @@ -0,0 +1,19 @@ +Rails.application.configure do + # Settings specified here take precedence over those in config/application.rb. + + config.enable_reloading = true + config.eager_load = false + config.consider_all_requests_local = true + config.server_timing = true + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + config.active_record.verbose_query_logs = true + + # Raise error when a before_action's only/except options reference missing + # actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/eval/fixtures/rails-pms/config/environments/production.rb b/eval/fixtures/rails-pms/config/environments/production.rb new file mode 100644 index 0000000..379c339 --- /dev/null +++ b/eval/fixtures/rails-pms/config/environments/production.rb @@ -0,0 +1,17 @@ +Rails.application.configure do + # Settings specified here take precedence over those in config/application.rb. + + config.enable_reloading = false + config.eager_load = true + config.consider_all_requests_local = false + + # Serve static files from the /public folder. + config.public_file_server.enabled = true + + config.log_level = :info + config.log_tags = [:request_id] + + config.active_record.dump_schema_after_migration = false + config.i18n.fallbacks = true + config.active_support.report_deprecations = false +end diff --git a/eval/fixtures/rails-pms/config/environments/test.rb b/eval/fixtures/rails-pms/config/environments/test.rb new file mode 100644 index 0000000..5a9892f --- /dev/null +++ b/eval/fixtures/rails-pms/config/environments/test.rb @@ -0,0 +1,14 @@ +Rails.application.configure do + # Settings specified here take precedence over those in config/application.rb. + + config.enable_reloading = false + config.eager_load = false + + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + config.action_dispatch.show_exceptions = :rescuable + config.action_controller.allow_forgery_protection = false + + config.active_support.deprecation = :stderr +end diff --git a/eval/fixtures/rails-pms/config/initializers/filter_parameter_logging.rb b/eval/fixtures/rails-pms/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..6a6de4b --- /dev/null +++ b/eval/fixtures/rails-pms/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,5 @@ +# Configure parameters to be partially matched (e.g. passw matches password) +# and filtered from the log file. Use this to limit dumping sensitive data. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, +] diff --git a/eval/fixtures/rails-pms/config/puma.rb b/eval/fixtures/rails-pms/config/puma.rb new file mode 100644 index 0000000..8660d46 --- /dev/null +++ b/eval/fixtures/rails-pms/config/puma.rb @@ -0,0 +1,12 @@ +# Puma configuration. Sensible defaults for local development. + +max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } +min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } +threads min_threads_count, max_threads_count + +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" + +port ENV.fetch("PORT") { 3000 } +environment ENV.fetch("RAILS_ENV") { "development" } + +plugin :tmp_restart diff --git a/eval/fixtures/rails-pms/config/routes.rb b/eval/fixtures/rails-pms/config/routes.rb new file mode 100644 index 0000000..93f2607 --- /dev/null +++ b/eval/fixtures/rails-pms/config/routes.rb @@ -0,0 +1,20 @@ +Rails.application.routes.draw do + # Public booking flow. + root "bookings#index" + post "/book", to: "bookings#create", as: :book + + # Front desk. + get "/reservations", to: "reservations#index", as: :reservations + post "/reservations/:id/status", to: "reservations#update_status", as: :status_reservation + post "/reservations/:id/assign", to: "reservations#assign_space", as: :assign_reservation + post "/reservations/:id/delete", to: "reservations#destroy", as: :reservation + + # Space inventory. + get "/spaces", to: "spaces#index", as: :spaces + post "/spaces", to: "spaces#create" + post "/spaces/:id", to: "spaces#update", as: :space + post "/spaces/:id/status", to: "spaces#set_status", as: :status_space + + # Derived guests view. + get "/guests", to: "guests#index", as: :guests +end diff --git a/eval/fixtures/rails-pms/db/migrate/20240101000001_create_spaces.rb b/eval/fixtures/rails-pms/db/migrate/20240101000001_create_spaces.rb new file mode 100644 index 0000000..2faa26a --- /dev/null +++ b/eval/fixtures/rails-pms/db/migrate/20240101000001_create_spaces.rb @@ -0,0 +1,15 @@ +class CreateSpaces < ActiveRecord::Migration[7.1] + def change + create_table :spaces do |t| + t.string :name, null: false + t.string :kind, null: false, default: "room" + t.integer :capacity, null: false, default: 2 + t.integer :rate_cents + t.string :status, null: false, default: "active" + t.text :notes + t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" } + end + + add_index :spaces, :name, unique: true + end +end diff --git a/eval/fixtures/rails-pms/db/migrate/20240101000002_create_reservations.rb b/eval/fixtures/rails-pms/db/migrate/20240101000002_create_reservations.rb new file mode 100644 index 0000000..43630dd --- /dev/null +++ b/eval/fixtures/rails-pms/db/migrate/20240101000002_create_reservations.rb @@ -0,0 +1,24 @@ +class CreateReservations < ActiveRecord::Migration[7.1] + def change + create_table :reservations do |t| + # Guest / user data. + t.string :guest_name, null: false + t.string :email, null: false + t.string :phone, null: false + + # Stay details. Dates are ISO YYYY-MM-DD strings, which sort as dates. + t.string :check_in, null: false + t.string :check_out, null: false + t.integer :party_size, null: false, default: 1 + t.text :notes + + # Assigned space (nullable). Clearing the space on delete keeps the + # reservation row valid. + t.references :space, foreign_key: { on_delete: :nullify }, null: true + + # Lifecycle. + t.string :status, null: false, default: "pending" + t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" } + end + end +end diff --git a/eval/fixtures/rails-pms/db/schema.rb b/eval/fixtures/rails-pms/db/schema.rb new file mode 100644 index 0000000..8c59176 --- /dev/null +++ b/eval/fixtures/rails-pms/db/schema.rb @@ -0,0 +1,36 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running +# `bin/rails db:schema:load`. When creating a new database, `bin/rails db:setup` +# tends to be faster and is preferred. + +ActiveRecord::Schema[7.1].define(version: 2024_01_01_000002) do + create_table "reservations", force: :cascade do |t| + t.string "guest_name", null: false + t.string "email", null: false + t.string "phone", null: false + t.string "check_in", null: false + t.string "check_out", null: false + t.integer "party_size", default: 1, null: false + t.text "notes" + t.integer "space_id" + t.string "status", default: "pending", null: false + t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false + t.index ["space_id"], name: "index_reservations_on_space_id" + end + + create_table "spaces", force: :cascade do |t| + t.string "name", null: false + t.string "kind", default: "room", null: false + t.integer "capacity", default: 2, null: false + t.integer "rate_cents" + t.string "status", default: "active", null: false + t.text "notes" + t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false + t.index ["name"], name: "index_spaces_on_name", unique: true + end + + add_foreign_key "reservations", "spaces", on_delete: :nullify +end diff --git a/eval/fixtures/rails-pms/db/seeds.rb b/eval/fixtures/rails-pms/db/seeds.rb new file mode 100644 index 0000000..2759b8a --- /dev/null +++ b/eval/fixtures/rails-pms/db/seeds.rb @@ -0,0 +1,13 @@ +# Seed a few spaces the first time so the booking form's picker isn't empty on +# a fresh clone. Idempotent: only seeds when there are no spaces yet. +if Space.count.zero? + Space.create!( + name: "Seagrass Suite", + kind: "suite", + capacity: 4, + rate_cents: 24_000, + notes: "Ocean view, walk-in shower", + ) + Space.create!(name: "Dune Cabin", kind: "cabin", capacity: 2, rate_cents: 16_000) + Space.create!(name: "Harbor Room 101", kind: "room", capacity: 2, rate_cents: 12_000) +end diff --git a/eval/fixtures/rails-pms/fixture.json b/eval/fixtures/rails-pms/fixture.json new file mode 100644 index 0000000..7f06b0b --- /dev/null +++ b/eval/fixtures/rails-pms/fixture.json @@ -0,0 +1,5 @@ +{ + "name": "rails-pms", + "sdk": "ruby", + "framework": "Rails" +} diff --git a/eval/fixtures/rails-pms/log/.keep b/eval/fixtures/rails-pms/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/eval/gates.ts b/eval/gates.ts index 8c7f297..3b9b2b3 100644 --- a/eval/gates.ts +++ b/eval/gates.ts @@ -11,10 +11,13 @@ export function evaluateGates(args: { envUntouched: !changedFiles.some( (file) => file === '.env' || file.endsWith('/.env'), ), - // JS: `from 'seam'` / `require('seam')`; Python: `from seam import …` / - // `import seam` — so the gate holds across javascript and python fixtures. + // Recognizes a Seam SDK import/instantiation across every fixture language: + // JS: from 'seam' | require('seam') + // Python: from seam import … | import seam + // Ruby: require 'seam' (no parens) + // PHP: use Seam\… | new [\]Seam\Seam seamImported: - /from ['"]seam['"]|require\(['"]seam['"]\)|from seam import|import seam\b/.test( + /from ['"]seam['"]|require\(['"]seam['"]\)|from seam import|import seam\b|require ['"]seam['"]|use Seam\\|new \\?Seam\\Seam/.test( diff, ), noStandalonePage: !changedFiles.some((file) => diff --git a/test/eval/gates.test.ts b/test/eval/gates.test.ts index 1462580..8952c52 100644 --- a/test/eval/gates.test.ts +++ b/test/eval/gates.test.ts @@ -39,6 +39,38 @@ test('seamImported: recognizes the Python import forms', () => { expect(bareImport.seamImported).toBe(true) }) +test('seamImported: recognizes the Ruby require form', () => { + const doubleQuoted = evaluateGates({ + changedFiles: ['app/controllers/seam_controller.rb'], + diff: diffAdding('app/controllers/seam_controller.rb', 'require "seam"'), + }) + const singleQuoted = evaluateGates({ + changedFiles: ['config/initializers/seam.rb'], + diff: diffAdding('config/initializers/seam.rb', "require 'seam'"), + }) + expect(doubleQuoted.seamImported).toBe(true) + expect(singleQuoted.seamImported).toBe(true) +}) + +test('seamImported: recognizes the PHP use / instantiation forms', () => { + const useImport = evaluateGates({ + changedFiles: ['app/Http/Controllers/SeamController.php'], + diff: diffAdding( + 'app/Http/Controllers/SeamController.php', + 'use Seam\\Seam;', + ), + }) + const instantiation = evaluateGates({ + changedFiles: ['app/Http/Controllers/SeamController.php'], + diff: diffAdding( + 'app/Http/Controllers/SeamController.php', + '$seam = new \\Seam\\Seam();', + ), + }) + expect(useImport.seamImported).toBe(true) + expect(instantiation.seamImported).toBe(true) +}) + test('seamImported: a lookalike package name does not count', () => { const gates = evaluateGates({ changedFiles: ['app.py'],