initial commit

This commit is contained in:
bastien
2026-06-28 12:29:53 +02:00
parent 03e85d2f9d
commit b2443b654c
220 changed files with 24969 additions and 1 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+86
View File
@@ -0,0 +1,86 @@
APP_NAME=Laverie
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=fr
APP_FALLBACK_LOCALE=fr
APP_FAKER_LOCALE=fr_FR
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
# Base de données MySQL (schéma laverie — voir docker-compose.yml)
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laverie
DB_USERNAME=laverie
DB_PASSWORD=laverie
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
# 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="noreply@laverie.local"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=eu-west-3
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# --- Configuration Laverie ---
# Clé API pour les webhooks / intégrations machines (header X-Machine-Api-Key)
LAVERIE_MACHINE_API_KEY=demo-machine-api-key-change-me
# Simulation de cycle machine (démo sans matériel)
LAVERIE_SIM_CYCLE_SECONDS=30
# Réservations
LAVERIE_BOOKING_FEE=1.00
LAVERIE_BOOKING_CANCEL_GRACE_HOURS=2
LAVERIE_BOOKING_NO_SHOW_GRACE_MINUTES=15
LAVERIE_BOOKING_PENALTY_AMOUNT=3.00
# Machines
LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES=10
# Paiements
LAVERIE_PAYMENT_PROVIDER=simulated
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+27
View File
@@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+62 -1
View File
@@ -1,2 +1,63 @@
# backend
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
```cmd
/c/wamp64/bin/php/php8.3.28/php.exe artisan serve
vite
```
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
@@ -0,0 +1,29 @@
<?php
namespace App\Domain\Integration;
readonly class MachineCommandResult
{
public function __construct(
public bool $success,
public string $status,
public ?string $externalReference = null,
public ?string $correlationId = null,
) {}
public static function success(
string $status = 'acknowledged',
?string $externalReference = null,
?string $correlationId = null,
): self {
return new self(true, $status, $externalReference, $correlationId);
}
public static function failure(
string $status = 'failed',
?string $externalReference = null,
?string $correlationId = null,
): self {
return new self(false, $status, $externalReference, $correlationId);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domain\Integration;
use App\Models\Machine;
interface MachineProviderInterface
{
public function startCycle(Machine $machine, array $payload): MachineCommandResult;
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult;
public function refreshStatus(Machine $machine): MachineStatusSnapshot;
public function handleInboundEvent(array $payload): void;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domain\Integration;
use Carbon\CarbonInterface;
readonly class MachineStatusSnapshot
{
/**
* @param array<string, mixed> $metadata
*/
public function __construct(
public string $status,
public ?CarbonInterface $lastHeartbeat = null,
public array $metadata = [],
) {}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Domain\Integration;
use App\Jobs\SimulateCycleCompletion;
use App\Models\Machine;
use App\Models\MachineCommand;
use App\Models\MachineEvent;
use App\Models\MachineStatusHistory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class SimulatedMachineProvider implements MachineProviderInterface
{
public function startCycle(Machine $machine, array $payload): MachineCommandResult
{
$correlationId = (string) Str::uuid();
$externalReference = 'sim-'.Str::random(12);
$provider = config('laverie.simulation.provider_name', 'simulated');
$durationSeconds = (int) config('laverie.simulation.cycle_duration_seconds', 30);
$cycleEndsAt = now()->addSeconds($durationSeconds);
$command = MachineCommand::query()->create([
'uuid' => (string) Str::uuid(),
'machine_id' => $machine->id,
'provider' => $provider,
'command_type' => 'start_cycle',
'payload' => $payload,
'status' => 'acknowledged',
'external_reference' => $externalReference,
'correlation_id' => $correlationId,
'requested_by_user_id' => $payload['requested_by_user_id'] ?? null,
'sent_at' => now(),
'responded_at' => now(),
]);
$previousStatus = $machine->status;
$machine->update([
'status' => 'running',
'current_user_id' => $payload['requested_by_user_id'] ?? $machine->current_user_id,
'cycle_started_at' => now(),
'cycle_ends_at' => $cycleEndsAt,
'last_heartbeat_at' => now(),
]);
$this->recordStatusChange($machine, $previousStatus, 'running', $provider, 'cycle_started');
$this->recordEvent($machine, $provider, 'cycle_started', [
'command_uuid' => $command->uuid,
'wash_uuid' => $payload['wash_uuid'] ?? null,
]);
SimulateCycleCompletion::dispatch(
$machine->id,
$command->id,
$payload['wash_id'] ?? null,
)->delay($cycleEndsAt);
return MachineCommandResult::success('acknowledged', $externalReference, $correlationId);
}
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult
{
$provider = config('laverie.simulation.provider_name', 'simulated');
$correlationId = (string) Str::uuid();
MachineCommand::query()->create([
'uuid' => (string) Str::uuid(),
'machine_id' => $machine->id,
'provider' => $provider,
'command_type' => 'stop_cycle',
'payload' => $payload,
'status' => 'acknowledged',
'correlation_id' => $correlationId,
'sent_at' => now(),
'responded_at' => now(),
]);
$previousStatus = $machine->status;
$machine->update([
'status' => 'available',
'current_user_id' => null,
'cycle_started_at' => null,
'cycle_ends_at' => null,
'last_heartbeat_at' => now(),
]);
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'cycle_stopped');
$this->recordEvent($machine, $provider, 'cycle_completed', ['reason' => 'manual_stop']);
return MachineCommandResult::success('acknowledged', null, $correlationId);
}
public function refreshStatus(Machine $machine): MachineStatusSnapshot
{
$machine->refresh();
return new MachineStatusSnapshot(
status: $machine->status,
lastHeartbeat: $machine->last_heartbeat_at,
metadata: [
'cycle_started_at' => $machine->cycle_started_at?->toIso8601String(),
'cycle_ends_at' => $machine->cycle_ends_at?->toIso8601String(),
'current_user_id' => $machine->current_user_id,
],
);
}
public function handleInboundEvent(array $payload): void
{
$externalMachineId = $payload['external_machine_id'] ?? $payload['machine_id'] ?? null;
if ($externalMachineId === null) {
return;
}
$integration = \App\Models\MachineIntegration::query()
->where('external_machine_id', (string) $externalMachineId)
->where('is_active', true)
->first();
if ($integration === null) {
return;
}
$machine = $integration->machine;
$provider = $integration->provider;
$eventType = $payload['event_type'] ?? 'status_changed';
if ($eventType === 'heartbeat') {
$machine->update(['last_heartbeat_at' => now()]);
if ($machine->status === 'offline') {
$previousStatus = $machine->status;
$machine->update(['status' => 'available']);
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'heartbeat');
}
}
if ($eventType === 'machine_offline') {
$previousStatus = $machine->status;
$machine->update(['status' => 'offline']);
$this->recordStatusChange($machine, $previousStatus, 'offline', $provider, 'machine_offline');
}
if ($eventType === 'machine_online') {
$previousStatus = $machine->status;
$machine->update(['status' => 'available', 'last_heartbeat_at' => now()]);
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'machine_online');
}
if ($eventType === 'cycle_completed') {
$previousStatus = $machine->status;
$machine->update([
'status' => 'available',
'current_user_id' => null,
'cycle_started_at' => null,
'cycle_ends_at' => null,
'last_heartbeat_at' => now(),
]);
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'cycle_completed');
}
if ($eventType === 'error_reported') {
$previousStatus = $machine->status;
$machine->update(['status' => 'error']);
$this->recordStatusChange($machine, $previousStatus, 'error', $provider, 'error_reported');
}
$this->recordEvent(
$machine,
$provider,
$eventType,
$payload,
$payload['external_event_id'] ?? null,
);
}
private function recordStatusChange(
Machine $machine,
?string $previousStatus,
string $newStatus,
string $source,
string $reason,
): void {
MachineStatusHistory::query()->create([
'machine_id' => $machine->id,
'previous_status' => $previousStatus,
'new_status' => $newStatus,
'source' => $source,
'reason' => $reason,
'created_at' => Carbon::now(),
]);
}
/**
* @param array<string, mixed> $payload
*/
private function recordEvent(
Machine $machine,
string $provider,
string $eventType,
array $payload,
?string $externalEventId = null,
): void {
MachineEvent::query()->create([
'machine_id' => $machine->id,
'provider' => $provider,
'external_event_id' => $externalEventId,
'event_type' => $eventType,
'payload' => $payload,
'occurred_at' => now(),
'received_at' => now(),
'processed_at' => now(),
'processing_status' => 'processed',
]);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class BookingConflictException extends RuntimeException
{
public function __construct(string $message = 'Ce créneau est déjà réservé.')
{
parent::__construct($message);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class MachineNotAvailableException extends RuntimeException
{
public function __construct(string $message = 'La machine n\'est pas disponible.')
{
parent::__construct($message);
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class WalletInsufficientFundsException extends RuntimeException
{
public function __construct(
public readonly float $required,
public readonly float $available,
) {
parent::__construct("Solde insuffisant : {$available} EUR disponibles, {$required} EUR requis.");
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Requests\Api\V1\Auth\LoginRequest;
use App\Http\Requests\Api\V1\Auth\RefreshRequest;
use App\Http\Requests\Api\V1\Auth\RegisterRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Models\Wallet;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\PersonalAccessToken;
class AuthController extends Controller
{
use RespondsWithJson;
public function register(RegisterRequest $request): JsonResponse
{
$user = User::query()->create([
'first_name' => $request->string('first_name')->toString(),
'last_name' => $request->string('last_name')->toString(),
'email' => $request->string('email')->toString(),
'phone' => $request->input('phone'),
'birthdate' => $request->input('birthdate'),
'password' => $request->string('password')->toString(),
'locale' => $request->input('locale', 'fr'),
'is_active' => true,
]);
Wallet::query()->create([
'user_id' => $user->id,
'currency' => 'EUR',
'current_balance' => 0,
'status' => 'active',
]);
$tokens = $this->issueTokenPair($user, $request->input('device_name', 'mobile'));
return $this->created([
'user' => new UserResource($user),
...$tokens,
], 'Inscription réussie.');
}
public function login(LoginRequest $request): JsonResponse
{
$user = User::query()->where('email', $request->string('email')->toString())->first();
if ($user === null || ! Hash::check($request->string('password')->toString(), $user->password)) {
return $this->error('Identifiants invalides.', 401);
}
if (! $user->is_active) {
return $this->error('Compte utilisateur désactivé.', 403);
}
$tokens = $this->issueTokenPair($user, $request->input('device_name', 'mobile'));
return $this->success([
'user' => new UserResource($user),
...$tokens,
], 'Connexion réussie.');
}
public function refresh(RefreshRequest $request): JsonResponse
{
$token = PersonalAccessToken::findToken($request->string('refresh_token')->toString());
if ($token === null || ! $token->can('refresh') || ! $token->can('user')) {
return $this->error('Refresh token invalide.', 401);
}
$user = $token->tokenable;
if (! $user instanceof User || ! $user->is_active) {
return $this->error('Utilisateur non autorisé.', 403);
}
$token->delete();
$user->tokens()->where('name', 'like', '%-access')->delete();
$tokens = $this->issueTokenPair($user, 'mobile');
return $this->success([
'user' => new UserResource($user),
...$tokens,
], 'Token renouvelé.');
}
public function logout(Request $request): JsonResponse
{
$request->user()?->currentAccessToken()?->delete();
return $this->success(message: 'Déconnexion réussie.');
}
public function me(Request $request): JsonResponse
{
return $this->success([
'user' => new UserResource($request->user()),
]);
}
private function issueTokenPair(User $user, string $deviceName): array
{
$accessMinutes = (int) config('laverie.auth.access_token_ttl_minutes', 60);
$refreshDays = (int) config('laverie.auth.refresh_token_ttl_days', 30);
$accessToken = $user->createToken(
"{$deviceName}-access",
['user', 'access'],
now()->addMinutes($accessMinutes),
);
$refreshToken = $user->createToken(
"{$deviceName}-refresh",
['user', 'refresh'],
now()->addDays($refreshDays),
);
return [
'access_token' => $accessToken->plainTextToken,
'refresh_token' => $refreshToken->plainTextToken,
'token_type' => 'Bearer',
'expires_in' => $accessMinutes * 60,
];
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\BookingConflictException;
use App\Exceptions\WalletInsufficientFundsException;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Requests\Api\V1\Booking\MoveBookingRequest;
use App\Http\Requests\Api\V1\Booking\StoreBookingRequest;
use App\Http\Resources\BookingResource;
use App\Models\Booking;
use App\Models\Machine;
use App\Services\BookingService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BookingController extends Controller
{
use RespondsWithJson;
public function __construct(
private readonly BookingService $bookingService,
) {}
public function store(StoreBookingRequest $request): JsonResponse
{
$machine = Machine::query()
->where('uuid', $request->string('machine_uuid')->toString())
->firstOrFail();
try {
$booking = $this->bookingService->createBooking(
$request->user(),
$machine,
Carbon::parse($request->input('slot_start')),
Carbon::parse($request->input('slot_end')),
$request->input('idempotency_key'),
);
} catch (BookingConflictException $e) {
return $this->error($e->getMessage(), 409);
} catch (WalletInsufficientFundsException $e) {
return $this->error($e->getMessage(), 402);
} catch (\RuntimeException|\InvalidArgumentException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->created([
'booking' => new BookingResource($booking->load('machine.establishment')),
], 'Réservation créée.');
}
public function index(Request $request): JsonResponse
{
$bookings = Booking::query()
->where('user_id', $request->user()->id)
->with(['machine.establishment'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
->orderByDesc('slot_start')
->paginate($request->integer('per_page', 20));
return $this->success([
'bookings' => BookingResource::collection($bookings),
'meta' => [
'current_page' => $bookings->currentPage(),
'last_page' => $bookings->lastPage(),
'per_page' => $bookings->perPage(),
'total' => $bookings->total(),
],
]);
}
public function show(Request $request, string $uuid): JsonResponse
{
$booking = Booking::query()
->where('uuid', $uuid)
->where('user_id', $request->user()->id)
->with(['machine.establishment'])
->firstOrFail();
return $this->success([
'booking' => new BookingResource($booking),
]);
}
public function cancel(Request $request, string $uuid): JsonResponse
{
$booking = Booking::query()
->where('uuid', $uuid)
->where('user_id', $request->user()->id)
->firstOrFail();
try {
$booking = $this->bookingService->cancelBooking($booking, $request->user());
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success([
'booking' => new BookingResource($booking->load('machine.establishment')),
], 'Réservation annulée.');
}
public function move(MoveBookingRequest $request, string $uuid): JsonResponse
{
$booking = Booking::query()
->where('uuid', $uuid)
->where('user_id', $request->user()->id)
->firstOrFail();
try {
$booking = $this->bookingService->moveBooking(
$booking,
$request->user(),
Carbon::parse($request->input('slot_start')),
Carbon::parse($request->input('slot_end')),
);
} catch (BookingConflictException $e) {
return $this->error($e->getMessage(), 409);
} catch (WalletInsufficientFundsException $e) {
return $this->error($e->getMessage(), 402);
} catch (\RuntimeException|\InvalidArgumentException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success([
'booking' => new BookingResource($booking->load('machine.establishment')),
], 'Réservation déplacée.');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Resources\EstablishmentResource;
use App\Models\Establishment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EstablishmentController extends Controller
{
use RespondsWithJson;
public function index(Request $request): JsonResponse
{
$establishments = Establishment::query()
->where('is_active', true)
->when($request->filled('city'), fn ($q) => $q->where('city', $request->string('city')->toString()))
->orderBy('name')
->get();
return $this->success([
'establishments' => EstablishmentResource::collection($establishments),
]);
}
public function show(string $uuid): JsonResponse
{
$establishment = Establishment::query()
->where('uuid', $uuid)
->where('is_active', true)
->with(['machines' => fn ($q) => $q->orderBy('name')])
->firstOrFail();
return $this->success([
'establishment' => new EstablishmentResource($establishment),
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api\V1\Integration;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Requests\Api\V1\Integration\AckCommandRequest;
use App\Http\Requests\Api\V1\Integration\HeartbeatRequest;
use App\Http\Requests\Api\V1\Integration\MachineEventRequest;
use App\Models\MachineCommand;
use App\Models\MachineIntegration;
use App\Services\MachineIntegrationService;
use Illuminate\Http\JsonResponse;
class MachineEventController extends Controller
{
use RespondsWithJson;
public function __construct(
private readonly MachineIntegrationService $machineIntegrationService,
) {}
public function store(MachineEventRequest $request): JsonResponse
{
$payload = $request->validated();
$this->machineIntegrationService->handleInboundEvent(
$payload['provider'],
$payload,
);
return $this->created(['received' => true], 'Événement enregistré.');
}
public function heartbeat(HeartbeatRequest $request): JsonResponse
{
$payload = $request->validated();
$payload['event_type'] = 'heartbeat';
$payload['occurred_at'] = $payload['occurred_at'] ?? now()->toIso8601String();
$integration = MachineIntegration::query()
->where('provider', $payload['provider'])
->where('external_machine_id', $payload['machine_external_id'])
->where('is_active', true)
->with('machine')
->first();
if ($integration?->machine !== null) {
$integration->machine->update(['last_heartbeat_at' => now()]);
}
$this->machineIntegrationService->handleInboundEvent(
$payload['provider'],
$payload,
);
return $this->success(['received' => true], 'Heartbeat enregistré.');
}
public function ackCommand(AckCommandRequest $request, string $uuid): JsonResponse
{
$command = MachineCommand::query()->where('uuid', $uuid)->firstOrFail();
$command->update([
'status' => $request->string('status')->toString(),
'external_reference' => $request->input('external_reference'),
'responded_at' => now(),
'payload' => array_merge($command->payload ?? [], [
'ack' => $request->input('data', []),
]),
]);
return $this->success([
'command_uuid' => $command->uuid,
'status' => $command->status,
], 'Accusé de réception enregistré.');
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Resources\MachineResource;
use App\Models\Booking;
use App\Models\Machine;
use App\Services\PricingService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MachineController extends Controller
{
use RespondsWithJson;
public function __construct(
private readonly PricingService $pricingService,
) {}
public function show(string $uuid): JsonResponse
{
$machine = Machine::query()
->where('uuid', $uuid)
->with('establishment')
->firstOrFail();
return $this->success([
'machine' => new MachineResource($machine),
]);
}
public function availability(Request $request, string $uuid): JsonResponse
{
$machine = Machine::query()
->where('uuid', $uuid)
->with('establishment')
->firstOrFail();
$date = $request->filled('date')
? Carbon::parse($request->string('date')->toString())->startOfDay()
: now()->startOfDay();
$slotDuration = $request->integer('slot_minutes', 60);
$dayStart = $date->copy()->setTime(7, 0);
$dayEnd = $date->copy()->setTime(22, 0);
$bookings = Booking::query()
->where('machine_id', $machine->id)
->whereIn('status', ['pending', 'confirmed', 'active'])
->where('slot_start', '<', $dayEnd)
->where('slot_end', '>', $dayStart)
->get(['slot_start', 'slot_end']);
$slots = [];
$cursor = $dayStart->copy();
while ($cursor->copy()->addMinutes($slotDuration)->lte($dayEnd)) {
$slotEnd = $cursor->copy()->addMinutes($slotDuration);
$isAvailable = $cursor->isFuture() && ! in_array($machine->status, ['offline', 'maintenance', 'out_of_order'], true);
if ($isAvailable) {
foreach ($bookings as $booking) {
if ($booking->slot_start < $slotEnd && $booking->slot_end > $cursor) {
$isAvailable = false;
break;
}
}
}
if ($isAvailable) {
$slots[] = [
'start' => $cursor->toIso8601String(),
'end' => $slotEnd->toIso8601String(),
];
}
$cursor->addMinutes($slotDuration);
}
return $this->success([
'machine_uuid' => $machine->uuid,
'date' => $date->toDateString(),
'slots' => $slots,
]);
}
public function pricing(string $uuid): JsonResponse
{
$machine = Machine::query()
->where('uuid', $uuid)
->with('establishment')
->firstOrFail();
$price = $this->pricingService->getActivePrice($machine);
return $this->success([
'machine_uuid' => $machine->uuid,
'price' => $price,
'currency' => 'EUR',
]);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\V1\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Requests\Api\V1\Supervisor\LoginRequest;
use App\Http\Resources\SupervisorResource;
use App\Models\Supervisor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
use RespondsWithJson;
public function login(LoginRequest $request): JsonResponse
{
$supervisor = Supervisor::query()
->where('email', $request->string('email')->toString())
->first();
if ($supervisor === null || ! Hash::check($request->string('password')->toString(), $supervisor->password)) {
return $this->error('Identifiants invalides.', 401);
}
if (! $supervisor->is_active) {
return $this->error('Compte superviseur désactivé.', 403);
}
$supervisor->update(['last_login_at' => now()]);
$deviceName = $request->input('device_name', 'backoffice');
$accessMinutes = (int) config('laverie.auth.access_token_ttl_minutes', 60);
$refreshDays = (int) config('laverie.auth.refresh_token_ttl_days', 30);
$accessToken = $supervisor->createToken(
"{$deviceName}-access",
['supervisor', 'access'],
now()->addMinutes($accessMinutes),
);
$refreshToken = $supervisor->createToken(
"{$deviceName}-refresh",
['supervisor', 'refresh'],
now()->addDays($refreshDays),
);
Auth::guard('supervisor')->login($supervisor);
return $this->success([
'supervisor' => new SupervisorResource($supervisor),
'access_token' => $accessToken->plainTextToken,
'refresh_token' => $refreshToken->plainTextToken,
'token_type' => 'Bearer',
'expires_in' => $accessMinutes * 60,
], 'Connexion superviseur réussie.');
}
public function logout(Request $request): JsonResponse
{
$user = $request->user();
if ($user instanceof Supervisor) {
$user->currentAccessToken()?->delete();
}
Auth::guard('supervisor')->logout();
return $this->success(message: 'Déconnexion superviseur réussie.');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Middleware\EnsureSupervisorScope;
use App\Http\Resources\BookingResource;
use App\Models\Supervisor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BookingController extends Controller
{
use RespondsWithJson;
public function index(Request $request): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$bookings = EnsureSupervisorScope::bookingsQuery($supervisor)
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
->when($request->filled('date'), fn ($q) => $q->whereDate('slot_start', $request->string('date')->toString()))
->orderByDesc('slot_start')
->paginate($request->integer('per_page', 50));
return $this->success([
'bookings' => BookingResource::collection($bookings),
'meta' => [
'current_page' => $bookings->currentPage(),
'last_page' => $bookings->lastPage(),
'per_page' => $bookings->perPage(),
'total' => $bookings->total(),
],
]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\V1\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Middleware\EnsureSupervisorScope;
use App\Models\Supervisor;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
use RespondsWithJson;
public function index(Request $request): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$today = Carbon::today();
$statsQuery = EnsureSupervisorScope::dailyStatsQuery($supervisor)
->where('stat_date', $today);
$todayStats = $statsQuery->get();
$machinesQuery = EnsureSupervisorScope::machinesQuery($supervisor);
$offlineThreshold = now()->subMinutes((int) config('laverie.machine.offline_threshold_minutes', 10));
return $this->success([
'summary' => [
'machines_total' => (clone $machinesQuery)->count(),
'machines_available' => (clone $machinesQuery)->where('status', 'available')->count(),
'machines_running' => (clone $machinesQuery)->where('status', 'running')->count(),
'machines_offline' => (clone $machinesQuery)->where(function ($q) use ($offlineThreshold) {
$q->where('status', 'offline')
->orWhere(fn ($q) => $q->where('last_heartbeat_at', '<', $offlineThreshold));
})->count(),
'bookings_today' => EnsureSupervisorScope::bookingsQuery($supervisor)
->whereDate('slot_start', $today)
->count(),
'washes_today' => EnsureSupervisorScope::washesQuery($supervisor)
->whereDate('started_at', $today)
->count(),
'revenue_today' => (float) $todayStats->sum('total_revenue'),
'top_up_today' => (float) $todayStats->sum('top_up_amount'),
],
'establishments' => $todayStats->map(fn ($stat) => [
'establishment_id' => $stat->establishment_id,
'establishment_name' => $stat->establishment?->name,
'total_washes' => $stat->total_washes,
'total_revenue' => (float) $stat->total_revenue,
'bookings_count' => $stat->bookings_count,
'no_show_count' => $stat->no_show_count,
'occupancy_rate' => (float) $stat->occupancy_rate,
]),
]);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Middleware\EnsureSupervisorScope;
use App\Http\Resources\MachineResource;
use App\Models\Supervisor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MachineController extends Controller
{
use RespondsWithJson;
public function index(Request $request): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$machines = EnsureSupervisorScope::machinesQuery($supervisor)
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
->when($request->filled('establishment_id'), fn ($q) => $q->where('establishment_id', $request->integer('establishment_id')))
->orderBy('name')
->paginate($request->integer('per_page', 50));
return $this->success([
'machines' => MachineResource::collection($machines),
'meta' => [
'current_page' => $machines->currentPage(),
'last_page' => $machines->lastPage(),
'per_page' => $machines->perPage(),
'total' => $machines->total(),
],
]);
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api\V1\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Middleware\EnsureSupervisorScope;
use App\Http\Requests\Api\V1\Supervisor\StorePricingRequest;
use App\Http\Requests\Api\V1\Supervisor\UpdatePricingRequest;
use App\Http\Resources\PricingRuleResource;
use App\Models\Establishment;
use App\Models\PricingRule;
use App\Models\Supervisor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PricingController extends Controller
{
use RespondsWithJson;
public function index(Request $request): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$rules = EnsureSupervisorScope::pricingRulesQuery($supervisor)
->when($request->filled('establishment_id'), fn ($q) => $q->where('establishment_id', $request->integer('establishment_id')))
->orderBy('priority')
->paginate($request->integer('per_page', 50));
return $this->success([
'pricing_rules' => PricingRuleResource::collection($rules),
'meta' => [
'current_page' => $rules->currentPage(),
'last_page' => $rules->lastPage(),
'per_page' => $rules->perPage(),
'total' => $rules->total(),
],
]);
}
public function store(StorePricingRequest $request): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$establishment = Establishment::query()->findOrFail($request->integer('establishment_id'));
if ($establishment->organization_id !== $supervisor->organization_id) {
return $this->error('Établissement hors périmètre.', 403);
}
if ($supervisor->establishment_id !== null && $supervisor->establishment_id !== $establishment->id) {
return $this->error('Établissement hors périmètre.', 403);
}
$rule = PricingRule::query()->create([
'establishment_id' => $establishment->id,
'machine_id' => $request->input('machine_id'),
'machine_type' => $request->input('machine_type'),
'day_type' => $request->string('day_type')->toString(),
'slot_start' => $request->string('slot_start')->toString(),
'slot_end' => $request->string('slot_end')->toString(),
'price' => $request->input('price'),
'label' => $request->input('label'),
'requires_app' => $request->boolean('requires_app'),
'priority' => $request->integer('priority', 100),
'is_active' => $request->boolean('is_active', true),
]);
return $this->created([
'pricing_rule' => new PricingRuleResource($rule->load(['establishment', 'machine'])),
], 'Règle tarifaire créée.');
}
public function update(UpdatePricingRequest $request, int $id): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$rule = EnsureSupervisorScope::pricingRulesQuery($supervisor)
->whereKey($id)
->firstOrFail();
$rule->update($request->validated());
return $this->success([
'pricing_rule' => new PricingRuleResource($rule->fresh(['establishment', 'machine'])),
], 'Règle tarifaire mise à jour.');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Middleware\EnsureSupervisorScope;
use App\Http\Resources\PromotionResource;
use App\Models\Supervisor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PromotionController extends Controller
{
use RespondsWithJson;
public function index(Request $request): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$promotions = EnsureSupervisorScope::promotionsQuery($supervisor)
->when($request->filled('establishment_id'), fn ($q) => $q->where('establishment_id', $request->integer('establishment_id')))
->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true))
->orderByDesc('starts_at')
->paginate($request->integer('per_page', 50));
return $this->success([
'promotions' => PromotionResource::collection($promotions),
'meta' => [
'current_page' => $promotions->currentPage(),
'last_page' => $promotions->lastPage(),
'per_page' => $promotions->perPage(),
'total' => $promotions->total(),
],
]);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Middleware\EnsureSupervisorScope;
use App\Http\Resources\WashResource;
use App\Models\Supervisor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WashController extends Controller
{
use RespondsWithJson;
public function index(Request $request): JsonResponse
{
/** @var Supervisor $supervisor */
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
$washes = EnsureSupervisorScope::washesQuery($supervisor)
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
->when($request->filled('date'), fn ($q) => $q->whereDate('started_at', $request->string('date')->toString()))
->orderByDesc('started_at')
->paginate($request->integer('per_page', 50));
return $this->success([
'washes' => WashResource::collection($washes),
'meta' => [
'current_page' => $washes->currentPage(),
'last_page' => $washes->lastPage(),
'per_page' => $washes->perPage(),
'total' => $washes->total(),
],
]);
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Requests\Api\V1\Wallet\ConfirmTopUpRequest;
use App\Http\Requests\Api\V1\Wallet\InitiateTopUpRequest;
use App\Http\Resources\PaymentTransactionResource;
use App\Http\Resources\WalletResource;
use App\Http\Resources\WalletTransactionResource;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Services\PaymentService;
use App\Services\WalletService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WalletController extends Controller
{
use RespondsWithJson;
public function __construct(
private readonly WalletService $walletService,
private readonly PaymentService $paymentService,
) {}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$wallet = Wallet::query()->firstOrCreate(
['user_id' => $user->id],
['currency' => 'EUR', 'current_balance' => 0, 'status' => 'active'],
);
return $this->success([
'balance' => $this->walletService->getBalance($user),
'wallet' => new WalletResource($wallet->fresh()),
]);
}
public function transactions(Request $request): JsonResponse
{
$wallet = Wallet::query()->where('user_id', $request->user()->id)->first();
if ($wallet === null) {
return $this->success(['transactions' => []]);
}
$transactions = WalletTransaction::query()
->where('wallet_id', $wallet->id)
->orderByDesc('created_at')
->paginate($request->integer('per_page', 20));
return $this->success([
'transactions' => WalletTransactionResource::collection($transactions),
'meta' => [
'current_page' => $transactions->currentPage(),
'last_page' => $transactions->lastPage(),
'per_page' => $transactions->perPage(),
'total' => $transactions->total(),
],
]);
}
public function initiateTopUp(InitiateTopUpRequest $request): JsonResponse
{
$payment = $this->paymentService->initiateTopUp(
$request->user(),
(float) $request->input('amount'),
$request->string('idempotency_key')->toString(),
$request->input('return_url'),
);
return $this->created([
'payment' => new PaymentTransactionResource($payment),
], 'Rechargement initié.');
}
public function confirmTopUp(ConfirmTopUpRequest $request): JsonResponse
{
$payment = $this->paymentService->confirmTopUp(
$request->string('payment_uuid')->toString(),
);
if ($payment->user_id !== $request->user()->id) {
return $this->error('Paiement non autorisé.', 403);
}
return $this->success([
'payment' => new PaymentTransactionResource($payment),
'balance' => $this->walletService->getBalance($request->user()),
], 'Rechargement confirmé.');
}
public function webhook(Request $request, string $provider): JsonResponse
{
$this->paymentService->handleWebhook($provider, $request->all());
return $this->success(message: 'Webhook traité.');
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\MachineNotAvailableException;
use App\Exceptions\WalletInsufficientFundsException;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Concerns\RespondsWithJson;
use App\Http\Requests\Api\V1\Wash\StartWashRequest;
use App\Http\Resources\WashResource;
use App\Models\Booking;
use App\Models\Machine;
use App\Models\Wash;
use App\Services\WashService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WashController extends Controller
{
use RespondsWithJson;
public function __construct(
private readonly WashService $washService,
) {}
public function start(StartWashRequest $request): JsonResponse
{
$machine = Machine::query()
->where('uuid', $request->string('machine_uuid')->toString())
->firstOrFail();
$bookingId = null;
if ($request->filled('booking_uuid')) {
$bookingId = Booking::query()
->where('uuid', $request->string('booking_uuid')->toString())
->where('user_id', $request->user()->id)
->value('id');
}
try {
$wash = $this->washService->startWash(
$request->user(),
$machine,
$request->string('trigger_method')->toString(),
$bookingId,
$request->input('program'),
);
} catch (MachineNotAvailableException $e) {
return $this->error($e->getMessage(), 409);
} catch (WalletInsufficientFundsException $e) {
return $this->error($e->getMessage(), 402);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->created([
'wash' => new WashResource($wash),
], 'Lavage démarré.');
}
public function index(Request $request): JsonResponse
{
$washes = Wash::query()
->where('user_id', $request->user()->id)
->with(['machine.establishment', 'booking'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
->orderByDesc('created_at')
->paginate($request->integer('per_page', 20));
return $this->success([
'washes' => WashResource::collection($washes),
'meta' => [
'current_page' => $washes->currentPage(),
'last_page' => $washes->lastPage(),
'per_page' => $washes->perPage(),
'total' => $washes->total(),
],
]);
}
public function show(Request $request, string $uuid): JsonResponse
{
$wash = Wash::query()
->where('uuid', $uuid)
->where('user_id', $request->user()->id)
->with(['machine.establishment', 'booking'])
->firstOrFail();
return $this->success([
'wash' => new WashResource($wash),
]);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): Response
{
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): Response
{
return Inertia::render('Auth/ConfirmPassword');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|Response
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): Response
{
return Inertia::render('Auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
return redirect()->route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): Response
{
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
if ($status == Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): Response
{
return Inertia::render('Auth/Register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\JsonResponse;
trait RespondsWithJson
{
protected function success(mixed $data = null, ?string $message = null, int $status = 200): JsonResponse
{
$payload = ['success' => true];
if ($message !== null) {
$payload['message'] = $message;
}
if ($data !== null) {
$payload['data'] = $data;
}
return response()->json($payload, $status);
}
protected function created(mixed $data, ?string $message = null): JsonResponse
{
return $this->success($data, $message, 201);
}
protected function error(string $message, int $status = 400, ?array $errors = null): JsonResponse
{
$payload = [
'success' => false,
'message' => $message,
];
if ($errors !== null) {
$payload['errors'] = $errors;
}
return response()->json($payload, $status);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): Response
{
return Inertia::render('Profile/Edit', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => session('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Requests\Supervisor\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class AuthController extends Controller
{
public function create(): Response
{
return Inertia::render('Supervisor/Auth/Login', [
'status' => session('status'),
]);
}
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
$supervisor = Auth::guard('supervisor')->user();
$supervisor?->update(['last_login_at' => now()]);
return redirect()->intended(route('supervisor.dashboard'));
}
public function destroy(Request $request): RedirectResponse
{
Auth::guard('supervisor')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Booking;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class BookingPageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = Booking::query()
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
$this->scopeMachineRelationQuery($query);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('date')) {
$query->whereDate('slot_start', $request->string('date'));
}
$bookings = $query
->orderByDesc('slot_start')
->paginate(20)
->withQueryString()
->through(fn (Booking $booking) => [
'uuid' => $booking->uuid,
'status' => $booking->status,
'status_label' => $this->statusLabel($booking->status),
'slot_start' => $booking->slot_start?->toIso8601String(),
'slot_end' => $booking->slot_end?->toIso8601String(),
'booking_fee' => (float) $booking->booking_fee,
'reserved_amount' => (float) $booking->reserved_amount,
'penalty_amount' => (float) $booking->penalty_amount,
'user' => $booking->user ? [
'name' => trim($booking->user->first_name.' '.$booking->user->last_name),
'email' => $booking->user->email,
] : null,
'machine' => $booking->machine ? [
'uuid' => $booking->machine->uuid,
'name' => $booking->machine->name,
'establishment_name' => $booking->machine->establishment?->name,
] : null,
]);
return Inertia::render('Supervisor/Bookings/Index', [
'bookings' => $bookings,
'filters' => [
'status' => $request->string('status')->toString() ?: null,
'date' => $request->string('date')->toString() ?: null,
],
'statusOptions' => $this->statusOptions(),
]);
}
/**
* @return array<string, string>
*/
private function statusOptions(): array
{
return [
'pending' => 'En attente',
'confirmed' => 'Confirmée',
'cancelled' => 'Annulée',
'expired' => 'Expirée',
'active' => 'Active',
'completed' => 'Terminée',
'no_show' => 'Absent',
];
}
private function statusLabel(string $status): string
{
return $this->statusOptions()[$status] ?? $status;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Supervisor\Concerns;
use App\Models\Supervisor;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
trait ScopesSupervisorResources
{
protected function supervisor(): Supervisor
{
/** @var Supervisor $supervisor */
$supervisor = Auth::guard('supervisor')->user();
return $supervisor;
}
protected function scopeEstablishmentQuery(Builder $query, string $establishmentColumn = 'establishment_id'): Builder
{
$supervisor = $this->supervisor();
$query->whereHas('establishment', function (Builder $query) use ($supervisor) {
$query->where('organization_id', $supervisor->organization_id);
if ($supervisor->establishment_id !== null) {
$query->where('id', $supervisor->establishment_id);
}
});
if ($supervisor->establishment_id !== null && $establishmentColumn !== '') {
$query->where($establishmentColumn, $supervisor->establishment_id);
}
return $query;
}
protected function scopeMachineRelationQuery(Builder $query): Builder
{
$supervisor = $this->supervisor();
return $query->whereHas('machine.establishment', function (Builder $query) use ($supervisor) {
$query->where('organization_id', $supervisor->organization_id);
if ($supervisor->establishment_id !== null) {
$query->where('id', $supervisor->establishment_id);
}
});
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Services\DashboardService;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
use ScopesSupervisorResources;
public function index(DashboardService $dashboardService): Response
{
$supervisor = $this->supervisor();
return Inertia::render('Supervisor/Dashboard', [
'kpis' => $dashboardService->getKpis($supervisor),
'alerts' => $dashboardService->getAlerts($supervisor),
'machinesOverview' => $dashboardService->getMachinesOverview($supervisor),
]);
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Machine;
use App\Models\MachineEvent;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class MachinePageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = Machine::query()
->with('establishment:id,name,uuid');
$this->scopeEstablishmentQuery($query);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('type')) {
$query->where('type', $request->string('type'));
}
if ($request->filled('search')) {
$search = $request->string('search');
$query->where(function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%")
->orWhere('qr_code', 'like', "%{$search}%");
});
}
$machines = $query
->orderBy('name')
->paginate(20)
->withQueryString()
->through(fn (Machine $machine) => $this->formatMachineListItem($machine));
return Inertia::render('Supervisor/Machines/Index', [
'machines' => $machines,
'filters' => [
'status' => $request->string('status')->toString() ?: null,
'type' => $request->string('type')->toString() ?: null,
'search' => $request->string('search')->toString() ?: null,
],
'statusOptions' => $this->statusOptions(),
'typeOptions' => $this->typeOptions(),
]);
}
public function show(string $uuid): Response
{
$query = Machine::query()->where('uuid', $uuid);
$this->scopeEstablishmentQuery($query);
$machine = $query
->with(['establishment:id,name,uuid,address,city', 'machineIntegration', 'currentUser:id,first_name,last_name,email'])
->firstOrFail();
$recentEvents = MachineEvent::query()
->where('machine_id', $machine->id)
->orderByDesc('occurred_at')
->limit(20)
->get()
->map(fn (MachineEvent $event) => [
'event_type' => $event->event_type,
'event_type_label' => $this->eventTypeLabel($event->event_type),
'occurred_at' => $event->occurred_at?->toIso8601String(),
'processing_status' => $event->processing_status,
]);
return Inertia::render('Supervisor/Machines/Show', [
'machine' => [
'uuid' => $machine->uuid,
'name' => $machine->name,
'type' => $machine->type,
'type_label' => $this->typeLabel($machine->type),
'qr_code' => $machine->qr_code,
'status' => $machine->status,
'status_label' => $this->statusLabel($machine->status),
'cycle_started_at' => $machine->cycle_started_at?->toIso8601String(),
'cycle_ends_at' => $machine->cycle_ends_at?->toIso8601String(),
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
'establishment' => $machine->establishment ? [
'uuid' => $machine->establishment->uuid,
'name' => $machine->establishment->name,
'address' => $machine->establishment->address,
'city' => $machine->establishment->city,
] : null,
'current_user' => $machine->currentUser ? [
'name' => trim($machine->currentUser->first_name.' '.$machine->currentUser->last_name),
'email' => $machine->currentUser->email,
] : null,
'integration' => $machine->machineIntegration ? [
'provider' => $machine->machineIntegration->provider,
'mode' => $machine->machineIntegration->mode,
'is_active' => $machine->machineIntegration->is_active,
] : null,
],
'recentEvents' => $recentEvents,
]);
}
/**
* @return array<string, mixed>
*/
private function formatMachineListItem(Machine $machine): array
{
return [
'uuid' => $machine->uuid,
'name' => $machine->name,
'type' => $machine->type,
'type_label' => $this->typeLabel($machine->type),
'status' => $machine->status,
'status_label' => $this->statusLabel($machine->status),
'establishment_name' => $machine->establishment?->name,
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
];
}
/**
* @return array<string, string>
*/
private function statusOptions(): array
{
return [
'available' => 'Disponible',
'reserved' => 'Réservée',
'running' => 'En cours',
'maintenance' => 'Maintenance',
'offline' => 'Hors ligne',
'error' => 'Erreur',
];
}
/**
* @return array<string, string>
*/
private function typeOptions(): array
{
return [
'washer_small' => 'Lave-linge petit',
'washer_large' => 'Lave-linge grand',
'dryer_small' => 'Sèche-linge petit',
'dryer_large' => 'Sèche-linge grand',
];
}
private function statusLabel(string $status): string
{
return $this->statusOptions()[$status] ?? $status;
}
private function typeLabel(string $type): string
{
return $this->typeOptions()[$type] ?? $type;
}
private function eventTypeLabel(string $type): string
{
return match ($type) {
'heartbeat' => 'Signal de vie',
'machine_online' => 'En ligne',
'machine_offline' => 'Hors ligne',
'cycle_started' => 'Cycle démarré',
'cycle_completed' => 'Cycle terminé',
'cycle_failed' => 'Cycle échoué',
'error_reported' => 'Erreur signalée',
'status_changed' => 'Changement de statut',
default => $type,
};
}
}
@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Establishment;
use App\Models\PricingRule;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PricingPageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = PricingRule::query()
->with(['establishment:id,name', 'machine:id,uuid,name']);
$this->scopeEstablishmentQuery($query);
$rules = $query
->orderBy('priority')
->orderBy('slot_start')
->get()
->map(fn (PricingRule $rule) => $this->formatRule($rule));
return Inertia::render('Supervisor/Pricing/Index', [
'rules' => $rules,
'establishments' => $this->establishmentOptions(),
'machineTypes' => $this->machineTypeOptions(),
'dayTypes' => $this->dayTypeOptions(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateRule($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
PricingRule::query()->create($validated);
return redirect()->route('supervisor.pricing.index')
->with('success', 'Règle tarifaire créée.');
}
public function update(Request $request, int $id): RedirectResponse
{
$rule = $this->findAccessibleRule($id);
$validated = $this->validateRule($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
$rule->update($validated);
return redirect()->route('supervisor.pricing.index')
->with('success', 'Règle tarifaire mise à jour.');
}
public function destroy(int $id): RedirectResponse
{
$rule = $this->findAccessibleRule($id);
$rule->delete();
return redirect()->route('supervisor.pricing.index')
->with('success', 'Règle tarifaire supprimée.');
}
/**
* @return array<string, mixed>
*/
private function validateRule(Request $request): array
{
return $request->validate([
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
'machine_id' => ['nullable', 'integer', 'exists:machines,id'],
'machine_type' => ['nullable', 'in:washer_small,washer_large,dryer_small,dryer_large'],
'day_type' => ['required', 'in:weekday,weekend,holiday,all'],
'slot_start' => ['required', 'date_format:H:i'],
'slot_end' => ['required', 'date_format:H:i', 'after:slot_start'],
'price' => ['required', 'numeric', 'min:0'],
'label' => ['nullable', 'string', 'max:100'],
'requires_app' => ['boolean'],
'priority' => ['integer', 'min:0'],
'is_active' => ['boolean'],
]);
}
private function findAccessibleRule(int $id): PricingRule
{
$query = PricingRule::query()->where('id', $id);
$this->scopeEstablishmentQuery($query);
return $query->firstOrFail();
}
private function assertEstablishmentAccessible(int $establishmentId): void
{
$supervisor = $this->supervisor();
$exists = Establishment::query()
->where('id', $establishmentId)
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->exists();
abort_unless($exists, 403);
}
/**
* @return array<string, mixed>
*/
private function formatRule(PricingRule $rule): array
{
return [
'id' => $rule->id,
'establishment_id' => $rule->establishment_id,
'establishment_name' => $rule->establishment?->name,
'machine_id' => $rule->machine_id,
'machine_name' => $rule->machine?->name,
'machine_type' => $rule->machine_type,
'machine_type_label' => $rule->machine_type ? ($this->machineTypeOptions()[$rule->machine_type] ?? $rule->machine_type) : null,
'day_type' => $rule->day_type,
'day_type_label' => $this->dayTypeOptions()[$rule->day_type] ?? $rule->day_type,
'slot_start' => $rule->slot_start ? substr((string) $rule->slot_start, 0, 5) : null,
'slot_end' => $rule->slot_end ? substr((string) $rule->slot_end, 0, 5) : null,
'price' => (float) $rule->price,
'label' => $rule->label,
'requires_app' => $rule->requires_app,
'priority' => $rule->priority,
'is_active' => $rule->is_active,
];
}
/**
* @return array<int, array{id: int, name: string}>
*/
private function establishmentOptions(): array
{
$supervisor = $this->supervisor();
return Establishment::query()
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->orderBy('name')
->get(['id', 'name'])
->map(fn (Establishment $e) => ['id' => $e->id, 'name' => $e->name])
->all();
}
/**
* @return array<string, string>
*/
private function machineTypeOptions(): array
{
return [
'washer_small' => 'Lave-linge petit',
'washer_large' => 'Lave-linge grand',
'dryer_small' => 'Sèche-linge petit',
'dryer_large' => 'Sèche-linge grand',
];
}
/**
* @return array<string, string>
*/
private function dayTypeOptions(): array
{
return [
'all' => 'Tous les jours',
'weekday' => 'Semaine',
'weekend' => 'Week-end',
'holiday' => 'Jours fériés',
];
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Establishment;
use App\Models\Promotion;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PromotionPageController extends Controller
{
use ScopesSupervisorResources;
public function index(): Response
{
$query = Promotion::query()->with('establishment:id,name');
$this->scopeEstablishmentQuery($query);
$promotions = $query
->orderByDesc('starts_at')
->get()
->map(fn (Promotion $promotion) => $this->formatPromotion($promotion));
return Inertia::render('Supervisor/Promotions/Index', [
'promotions' => $promotions,
'establishments' => $this->establishmentOptions(),
'machineTypes' => $this->machineTypeOptions(),
'discountTypes' => $this->discountTypeOptions(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validatePromotion($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
Promotion::query()->create($validated);
return redirect()->route('supervisor.promotions.index')
->with('success', 'Promotion créée.');
}
public function update(Request $request, int $id): RedirectResponse
{
$promotion = $this->findAccessiblePromotion($id);
$validated = $this->validatePromotion($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
$promotion->update($validated);
return redirect()->route('supervisor.promotions.index')
->with('success', 'Promotion mise à jour.');
}
public function destroy(int $id): RedirectResponse
{
$promotion = $this->findAccessiblePromotion($id);
$promotion->delete();
return redirect()->route('supervisor.promotions.index')
->with('success', 'Promotion supprimée.');
}
/**
* @return array<string, mixed>
*/
private function validatePromotion(Request $request): array
{
return $request->validate([
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
'machine_type' => ['required', 'in:washer_small,washer_large,dryer_small,dryer_large,all'],
'discount_type' => ['required', 'in:percent,fixed'],
'discount_value' => ['required', 'numeric', 'min:0'],
'starts_at' => ['required', 'date'],
'ends_at' => ['required', 'date', 'after:starts_at'],
'description' => ['nullable', 'string', 'max:500'],
'is_active' => ['boolean'],
]);
}
private function findAccessiblePromotion(int $id): Promotion
{
$query = Promotion::query()->where('id', $id);
$this->scopeEstablishmentQuery($query);
return $query->firstOrFail();
}
private function assertEstablishmentAccessible(int $establishmentId): void
{
$supervisor = $this->supervisor();
$exists = Establishment::query()
->where('id', $establishmentId)
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->exists();
abort_unless($exists, 403);
}
/**
* @return array<string, mixed>
*/
private function formatPromotion(Promotion $promotion): array
{
return [
'id' => $promotion->id,
'establishment_id' => $promotion->establishment_id,
'establishment_name' => $promotion->establishment?->name,
'machine_type' => $promotion->machine_type,
'machine_type_label' => $this->machineTypeOptions()[$promotion->machine_type] ?? $promotion->machine_type,
'discount_type' => $promotion->discount_type,
'discount_type_label' => $this->discountTypeOptions()[$promotion->discount_type] ?? $promotion->discount_type,
'discount_value' => (float) $promotion->discount_value,
'starts_at' => $promotion->starts_at?->format('Y-m-d\TH:i'),
'ends_at' => $promotion->ends_at?->format('Y-m-d\TH:i'),
'description' => $promotion->description,
'is_active' => $promotion->is_active,
];
}
/**
* @return array<int, array{id: int, name: string}>
*/
private function establishmentOptions(): array
{
$supervisor = $this->supervisor();
return Establishment::query()
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->orderBy('name')
->get(['id', 'name'])
->map(fn (Establishment $e) => ['id' => $e->id, 'name' => $e->name])
->all();
}
/**
* @return array<string, string>
*/
private function machineTypeOptions(): array
{
return [
'all' => 'Toutes les machines',
'washer_small' => 'Lave-linge petit',
'washer_large' => 'Lave-linge grand',
'dryer_small' => 'Sèche-linge petit',
'dryer_large' => 'Sèche-linge grand',
];
}
/**
* @return array<string, string>
*/
private function discountTypeOptions(): array
{
return [
'percent' => 'Pourcentage',
'fixed' => 'Montant fixe',
];
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Wash;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class WashPageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = Wash::query()
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
$this->scopeMachineRelationQuery($query);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('date')) {
$query->whereDate('started_at', $request->string('date'));
}
$washes = $query
->orderByDesc('started_at')
->paginate(20)
->withQueryString()
->through(fn (Wash $wash) => [
'uuid' => $wash->uuid,
'status' => $wash->status,
'status_label' => $this->statusLabel($wash->status),
'program' => $wash->program,
'trigger_method' => $wash->trigger_method,
'trigger_method_label' => $this->triggerMethodLabel($wash->trigger_method),
'started_at' => $wash->started_at?->toIso8601String(),
'ended_at' => $wash->ended_at?->toIso8601String(),
'duration_minutes' => $wash->duration_minutes,
'cost' => (float) $wash->cost,
'user' => $wash->user ? [
'name' => trim($wash->user->first_name.' '.$wash->user->last_name),
'email' => $wash->user->email,
] : null,
'machine' => $wash->machine ? [
'uuid' => $wash->machine->uuid,
'name' => $wash->machine->name,
'establishment_name' => $wash->machine->establishment?->name,
] : null,
]);
return Inertia::render('Supervisor/Washes/Index', [
'washes' => $washes,
'filters' => [
'status' => $request->string('status')->toString() ?: null,
'date' => $request->string('date')->toString() ?: null,
],
'statusOptions' => $this->statusOptions(),
]);
}
/**
* @return array<string, string>
*/
private function statusOptions(): array
{
return [
'pending_start' => 'En attente',
'running' => 'En cours',
'completed' => 'Terminé',
'failed' => 'Échoué',
'cancelled' => 'Annulé',
];
}
private function statusLabel(string $status): string
{
return $this->statusOptions()[$status] ?? $status;
}
private function triggerMethodLabel(string $method): string
{
return match ($method) {
'qr_code' => 'QR code',
'booking' => 'Réservation',
'supervisor' => 'Superviseur',
'system' => 'Système',
default => $method,
};
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateMachineIntegration
{
public function handle(Request $request, Closure $next): Response
{
$apiKey = config('laverie.machine_api_key');
if ($apiKey === null || $apiKey === '') {
return response()->json([
'message' => 'Intégration machine non configurée.',
], 503);
}
$providedKey = $request->header('X-API-Key');
if (! is_string($providedKey) || ! hash_equals($apiKey, $providedKey)) {
return response()->json([
'message' => 'Clé API machine invalide.',
], 401);
}
return $next($request);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Middleware;
use App\Models\Supervisor;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateSupervisor
{
public function handle(Request $request, Closure $next): Response
{
if ($request->bearerToken() !== null) {
$token = PersonalAccessToken::findToken($request->bearerToken());
if ($token !== null
&& $token->tokenable instanceof Supervisor
&& $token->can('supervisor')
&& $token->tokenable->is_active
) {
Auth::guard('supervisor')->setUser($token->tokenable);
$request->setUserResolver(fn () => $token->tokenable);
return $next($request);
}
return response()->json([
'message' => 'Token superviseur invalide.',
], 401);
}
if (Auth::guard('supervisor')->check()) {
$supervisor = Auth::guard('supervisor')->user();
if ($supervisor instanceof Supervisor && $supervisor->is_active) {
return $next($request);
}
}
return response()->json([
'message' => 'Superviseur non authentifié.',
], 401);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureApiUser
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof User) {
return response()->json([
'message' => 'Authentification utilisateur requise.',
], 401);
}
if (! $user->is_active) {
return response()->json([
'message' => 'Compte utilisateur désactivé.',
], 403);
}
return $next($request);
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Http\Middleware;
use App\Models\Booking;
use App\Models\DailyEstablishmentStat;
use App\Models\Machine;
use App\Models\PricingRule;
use App\Models\Promotion;
use App\Models\Supervisor;
use App\Models\Wash;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSupervisorScope
{
public function handle(Request $request, Closure $next): Response
{
$supervisor = $this->resolveSupervisor($request);
if ($supervisor === null) {
return response()->json([
'message' => 'Superviseur non authentifié.',
], 401);
}
if (! $supervisor->is_active) {
return response()->json([
'message' => 'Compte superviseur désactivé.',
], 403);
}
$request->attributes->set('supervisor', $supervisor);
return $next($request);
}
public static function resolveSupervisor(Request $request): ?Supervisor
{
$user = $request->user();
return $user instanceof Supervisor ? $user : null;
}
public static function machinesQuery(Supervisor $supervisor): Builder
{
$query = Machine::query()->with(['establishment', 'machineIntegration']);
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
}
public static function bookingsQuery(Supervisor $supervisor): Builder
{
$query = Booking::query()
->with(['user', 'machine.establishment'])
->whereHas('machine.establishment', function (Builder $query) use ($supervisor) {
self::applyOrganizationFilter($query, $supervisor);
});
if ($supervisor->establishment_id !== null) {
$query->whereHas('machine', fn (Builder $q) => $q->where('establishment_id', $supervisor->establishment_id));
}
return $query;
}
public static function washesQuery(Supervisor $supervisor): Builder
{
$query = Wash::query()
->with(['user', 'machine.establishment', 'booking'])
->whereHas('machine.establishment', function (Builder $query) use ($supervisor) {
self::applyOrganizationFilter($query, $supervisor);
});
if ($supervisor->establishment_id !== null) {
$query->whereHas('machine', fn (Builder $q) => $q->where('establishment_id', $supervisor->establishment_id));
}
return $query;
}
public static function pricingRulesQuery(Supervisor $supervisor): Builder
{
$query = PricingRule::query()->with(['establishment', 'machine']);
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
}
public static function promotionsQuery(Supervisor $supervisor): Builder
{
$query = Promotion::query()->with('establishment');
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
}
public static function dailyStatsQuery(Supervisor $supervisor): Builder
{
$query = DailyEstablishmentStat::query()->with('establishment');
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
}
private static function applyEstablishmentScope(Builder $query, Supervisor $supervisor, string $column): Builder
{
if ($supervisor->establishment_id !== null) {
return $query->where($column, $supervisor->establishment_id);
}
return $query->whereHas('establishment', function (Builder $query) use ($supervisor) {
self::applyOrganizationFilter($query, $supervisor);
});
}
private static function applyOrganizationFilter(Builder $query, Supervisor $supervisor): void
{
$query->where('organization_id', $supervisor->organization_id);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that is loaded on the first page visit.
*
* @var string
*/
protected $rootView = 'app';
/**
* Determine the current asset version.
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
$supervisor = $request->user('supervisor');
return [
...parent::share($request),
'auth' => [
'supervisor' => $supervisor ? [
'uuid' => $supervisor->uuid,
'name' => trim($supervisor->first_name.' '.$supervisor->last_name),
'email' => $supervisor->email,
'role' => $supervisor->role,
'organization_id' => $supervisor->organization_id,
'establishment_id' => $supervisor->establishment_id,
] : null,
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Api\V1\Auth;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
'device_name' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Api\V1\Auth;
use Illuminate\Foundation\Http\FormRequest;
class RefreshRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'refresh_token' => ['required', 'string'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Api\V1\Auth;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class RegisterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'phone' => ['nullable', 'string', 'max:30'],
'birthdate' => ['nullable', 'date', 'before:today'],
'password' => ['required', 'confirmed', Password::defaults()],
'locale' => ['nullable', 'string', 'max:10'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Api\V1\Booking;
use Illuminate\Foundation\Http\FormRequest;
class MoveBookingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'slot_start' => ['required', 'date', 'after:now'],
'slot_end' => ['required', 'date', 'after:slot_start'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Api\V1\Booking;
use Illuminate\Foundation\Http\FormRequest;
class StoreBookingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'machine_uuid' => ['required', 'uuid', 'exists:machines,uuid'],
'slot_start' => ['required', 'date', 'after:now'],
'slot_end' => ['required', 'date', 'after:slot_start'],
'idempotency_key' => ['nullable', 'string', 'max:100'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Api\V1\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AckCommandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'status' => ['required', Rule::in(['acknowledged', 'rejected', 'completed', 'failed'])],
'external_reference' => ['nullable', 'string', 'max:255'],
'data' => ['nullable', 'array'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Api\V1\Integration;
use Illuminate\Foundation\Http\FormRequest;
class HeartbeatRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'provider' => ['required', 'string', 'max:100'],
'machine_external_id' => ['required', 'string', 'max:255'],
'occurred_at' => ['nullable', 'date'],
'data' => ['nullable', 'array'],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Api\V1\Integration;
use Illuminate\Foundation\Http\FormRequest;
class MachineEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'provider' => ['required', 'string', 'max:100'],
'event_type' => ['required', 'string', 'max:100'],
'occurred_at' => ['required', 'date'],
'machine_external_id' => ['required', 'string', 'max:255'],
'correlation_id' => ['nullable', 'string', 'max:255'],
'data' => ['nullable', 'array'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Api\V1\Supervisor;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
'device_name' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Api\V1\Supervisor;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePricingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
'machine_id' => ['nullable', 'integer', 'exists:machines,id'],
'machine_type' => ['nullable', Rule::in(['washer_small', 'washer_large', 'dryer_small', 'dryer_large'])],
'day_type' => ['required', Rule::in(['weekday', 'weekend', 'holiday', 'all'])],
'slot_start' => ['required', 'date_format:H:i'],
'slot_end' => ['required', 'date_format:H:i', 'after:slot_start'],
'price' => ['required', 'numeric', 'min:0'],
'label' => ['nullable', 'string', 'max:100'],
'requires_app' => ['sometimes', 'boolean'],
'priority' => ['sometimes', 'integer', 'min:1', 'max:999'],
'is_active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Api\V1\Supervisor;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePricingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'machine_id' => ['nullable', 'integer', 'exists:machines,id'],
'machine_type' => ['nullable', Rule::in(['washer_small', 'washer_large', 'dryer_small', 'dryer_large'])],
'day_type' => ['sometimes', Rule::in(['weekday', 'weekend', 'holiday', 'all'])],
'slot_start' => ['sometimes', 'date_format:H:i'],
'slot_end' => ['sometimes', 'date_format:H:i'],
'price' => ['sometimes', 'numeric', 'min:0'],
'label' => ['nullable', 'string', 'max:100'],
'requires_app' => ['sometimes', 'boolean'],
'priority' => ['sometimes', 'integer', 'min:1', 'max:999'],
'is_active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Api\V1\Wallet;
use Illuminate\Foundation\Http\FormRequest;
class ConfirmTopUpRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'payment_uuid' => ['required', 'uuid'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Api\V1\Wallet;
use Illuminate\Foundation\Http\FormRequest;
class InitiateTopUpRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'numeric', 'min:1', 'max:500'],
'idempotency_key' => ['required', 'string', 'max:100'],
'return_url' => ['nullable', 'url', 'max:255'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Api\V1\Wash;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StartWashRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'machine_uuid' => ['required', 'uuid', 'exists:machines,uuid'],
'booking_uuid' => ['nullable', 'uuid', 'exists:bookings,uuid'],
'trigger_method' => ['required', Rule::in(['qr_code', 'booking', 'supervisor', 'system'])],
'program' => ['nullable', 'string', 'max:100'],
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Requests\Supervisor;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::guard('supervisor')->attempt(
$this->only('email', 'password'),
$this->boolean('remember')
)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
$supervisor = Auth::guard('supervisor')->user();
if ($supervisor !== null && ! $supervisor->is_active) {
Auth::guard('supervisor')->logout();
throw ValidationException::withMessages([
'email' => 'Ce compte superviseur est désactivé.',
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|supervisor|'.$this->ip());
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BookingResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'slot_start' => $this->slot_start?->toIso8601String(),
'slot_end' => $this->slot_end?->toIso8601String(),
'booking_fee' => (float) $this->booking_fee,
'reserved_amount' => (float) $this->reserved_amount,
'penalty_amount' => (float) $this->penalty_amount,
'status' => $this->status,
'cancelled_at' => $this->cancelled_at?->toIso8601String(),
'penalty_applied_at' => $this->penalty_applied_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'machine' => new MachineResource($this->whenLoaded('machine')),
'user' => new UserResource($this->whenLoaded('user')),
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EstablishmentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'address' => $this->address,
'city' => $this->city,
'zip_code' => $this->zip_code,
'latitude' => $this->latitude !== null ? (float) $this->latitude : null,
'longitude' => $this->longitude !== null ? (float) $this->longitude : null,
'timezone' => $this->timezone,
'is_active' => $this->is_active,
'machines' => MachineResource::collection($this->whenLoaded('machines')),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MachineResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'type' => $this->type,
'status' => $this->status,
'qr_code' => $this->qr_code,
'cycle_started_at' => $this->cycle_started_at?->toIso8601String(),
'cycle_ends_at' => $this->cycle_ends_at?->toIso8601String(),
'last_heartbeat_at' => $this->last_heartbeat_at?->toIso8601String(),
'establishment' => new EstablishmentResource($this->whenLoaded('establishment')),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PaymentTransactionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'provider' => $this->provider,
'provider_payment_id' => $this->provider_payment_id,
'amount' => (float) $this->amount,
'currency' => $this->currency,
'status' => $this->status,
'return_url' => $this->return_url,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PricingRuleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'establishment_id' => $this->establishment_id,
'machine_id' => $this->machine_id,
'machine_type' => $this->machine_type,
'day_type' => $this->day_type,
'slot_start' => $this->slot_start,
'slot_end' => $this->slot_end,
'price' => (float) $this->price,
'label' => $this->label,
'requires_app' => $this->requires_app,
'priority' => $this->priority,
'is_active' => $this->is_active,
'establishment' => new EstablishmentResource($this->whenLoaded('establishment')),
'machine' => new MachineResource($this->whenLoaded('machine')),
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PromotionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'establishment_id' => $this->establishment_id,
'machine_type' => $this->machine_type,
'discount_type' => $this->discount_type,
'discount_value' => (float) $this->discount_value,
'starts_at' => $this->starts_at?->toIso8601String(),
'ends_at' => $this->ends_at?->toIso8601String(),
'description' => $this->description,
'is_active' => $this->is_active,
'establishment' => new EstablishmentResource($this->whenLoaded('establishment')),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SupervisorResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'role' => $this->role,
'is_active' => $this->is_active,
'organization_id' => $this->organization_id,
'establishment_id' => $this->establishment_id,
'last_login_at' => $this->last_login_at?->toIso8601String(),
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
'email' => $this->email,
'phone' => $this->phone,
'birthdate' => $this->birthdate?->toDateString(),
'locale' => $this->locale,
'is_active' => $this->is_active,
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WalletResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'currency' => $this->currency,
'current_balance' => (float) $this->current_balance,
'status' => $this->status,
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WalletTransactionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'type' => $this->type,
'amount' => (float) $this->amount,
'balance_before' => (float) $this->balance_before,
'balance_after' => (float) $this->balance_after,
'source_type' => $this->source_type,
'source_id' => $this->source_id,
'metadata' => $this->metadata,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WashResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'trigger_method' => $this->trigger_method,
'status' => $this->status,
'program' => $this->program,
'started_at' => $this->started_at?->toIso8601String(),
'ended_at' => $this->ended_at?->toIso8601String(),
'duration_minutes' => $this->duration_minutes,
'cost' => (float) $this->cost,
'created_at' => $this->created_at?->toIso8601String(),
'machine' => new MachineResource($this->whenLoaded('machine')),
'booking' => new BookingResource($this->whenLoaded('booking')),
'user' => new UserResource($this->whenLoaded('user')),
];
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Jobs;
use App\Services\BookingService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class CheckNoShowBookings implements ShouldQueue
{
use Queueable;
public function handle(BookingService $bookingService): void
{
$bookingService->checkNoShows();
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Jobs;
use App\Models\Booking;
use App\Models\DailyEstablishmentStat;
use App\Models\Establishment;
use App\Models\PaymentTransaction;
use App\Models\Wash;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GenerateDailyStats implements ShouldQueue
{
use Queueable;
public function __construct(
public ?string $statDate = null,
) {}
public function handle(): void
{
$date = $this->statDate !== null
? Carbon::parse($this->statDate)->startOfDay()
: now()->subDay()->startOfDay();
$dayStart = $date->copy();
$dayEnd = $date->copy()->endOfDay();
Establishment::query()->where('is_active', true)->each(function (Establishment $establishment) use ($dayStart, $dayEnd, $date) {
DB::transaction(function () use ($establishment, $dayStart, $dayEnd, $date) {
$machineIds = $establishment->machines()->pluck('id');
if ($machineIds->isEmpty()) {
return;
}
$totalWashes = Wash::query()
->whereIn('machine_id', $machineIds)
->where('status', 'completed')
->whereBetween('ended_at', [$dayStart, $dayEnd])
->count();
$totalRevenue = (float) Wash::query()
->whereIn('machine_id', $machineIds)
->where('status', 'completed')
->whereBetween('ended_at', [$dayStart, $dayEnd])
->sum('cost');
$bookingsCount = Booking::query()
->whereIn('machine_id', $machineIds)
->whereBetween('created_at', [$dayStart, $dayEnd])
->whereNotIn('status', ['cancelled'])
->count();
$noShowCount = Booking::query()
->whereIn('machine_id', $machineIds)
->where('status', 'no_show')
->whereBetween('slot_start', [$dayStart, $dayEnd])
->count();
$userIds = Wash::query()
->whereIn('machine_id', $machineIds)
->whereBetween('started_at', [$dayStart, $dayEnd])
->pluck('user_id')
->unique();
$topUpAmount = $userIds->isEmpty()
? 0.0
: (float) PaymentTransaction::query()
->whereIn('user_id', $userIds)
->where('status', 'succeeded')
->whereBetween('updated_at', [$dayStart, $dayEnd])
->sum('amount');
$machineCount = max(1, $machineIds->count());
$minutesInDay = 24 * 60;
$runningMinutes = Wash::query()
->whereIn('machine_id', $machineIds)
->where('status', 'completed')
->whereBetween('ended_at', [$dayStart, $dayEnd])
->get(['duration_minutes'])
->sum(fn (Wash $wash) => $wash->duration_minutes ?? 0);
$occupancyRate = round(min(100, ($runningMinutes / ($machineCount * $minutesInDay)) * 100), 2);
DailyEstablishmentStat::query()->updateOrCreate(
[
'establishment_id' => $establishment->id,
'stat_date' => $date->toDateString(),
],
[
'total_washes' => $totalWashes,
'total_revenue' => $totalRevenue,
'bookings_count' => $bookingsCount,
'no_show_count' => $noShowCount,
'top_up_amount' => $topUpAmount,
'occupancy_rate' => $occupancyRate,
],
);
});
});
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Jobs;
use App\Models\Machine;
use App\Models\MachineEvent;
use App\Models\MachineStatusHistory;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class RefreshOfflineMachines implements ShouldQueue
{
use Queueable;
public function handle(): void
{
$thresholdMinutes = (int) config('laverie.machine.offline_threshold_minutes', 10);
$cutoff = now()->subMinutes($thresholdMinutes);
$machines = Machine::query()
->whereNotIn('status', ['offline', 'maintenance'])
->where(function ($query) use ($cutoff) {
$query->whereNull('last_heartbeat_at')
->orWhere('last_heartbeat_at', '<', $cutoff);
})
->get();
foreach ($machines as $machine) {
DB::transaction(function () use ($machine) {
$locked = Machine::query()->whereKey($machine->id)->lockForUpdate()->firstOrFail();
if (in_array($locked->status, ['offline', 'maintenance', 'running'], true)) {
return;
}
$thresholdMinutes = (int) config('laverie.machine.offline_threshold_minutes', 10);
$cutoff = now()->subMinutes($thresholdMinutes);
if ($locked->last_heartbeat_at !== null && $locked->last_heartbeat_at->gte($cutoff)) {
return;
}
$previousStatus = $locked->status;
$provider = config('laverie.simulation.provider_name', 'simulated');
$locked->update(['status' => 'offline']);
MachineStatusHistory::query()->create([
'machine_id' => $locked->id,
'previous_status' => $previousStatus,
'new_status' => 'offline',
'source' => 'RefreshOfflineMachines',
'reason' => 'heartbeat_timeout',
'created_at' => now(),
]);
MachineEvent::query()->create([
'machine_id' => $locked->id,
'provider' => $provider,
'event_type' => 'machine_offline',
'payload' => ['reason' => 'heartbeat_timeout'],
'occurred_at' => now(),
'received_at' => now(),
'processed_at' => now(),
'processing_status' => 'processed',
]);
});
}
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace App\Jobs;
use App\Models\Machine;
use App\Models\MachineEvent;
use App\Models\MachineStatusHistory;
use App\Models\Wash;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class SimulateCycleCompletion implements ShouldQueue
{
use Queueable;
public function __construct(
public int $machineId,
public int $machineCommandId,
public ?int $washId = null,
) {}
public function handle(): void
{
DB::transaction(function () {
$machine = Machine::query()->whereKey($this->machineId)->lockForUpdate()->first();
if ($machine === null || $machine->status !== 'running') {
return;
}
$provider = config('laverie.simulation.provider_name', 'simulated');
$previousStatus = $machine->status;
$machine->update([
'status' => 'available',
'current_user_id' => null,
'cycle_started_at' => null,
'cycle_ends_at' => null,
'last_heartbeat_at' => now(),
]);
MachineStatusHistory::query()->create([
'machine_id' => $machine->id,
'previous_status' => $previousStatus,
'new_status' => 'available',
'source' => $provider,
'reason' => 'simulated_cycle_completion',
'created_at' => now(),
]);
MachineEvent::query()->create([
'machine_id' => $machine->id,
'provider' => $provider,
'event_type' => 'cycle_completed',
'payload' => [
'machine_command_id' => $this->machineCommandId,
'wash_id' => $this->washId,
'simulated' => true,
],
'occurred_at' => now(),
'received_at' => now(),
'processed_at' => now(),
'processing_status' => 'processed',
]);
if ($this->washId !== null) {
$wash = Wash::query()->whereKey($this->washId)->lockForUpdate()->first();
if ($wash !== null && $wash->status === 'running') {
$startedAt = $wash->started_at ?? now();
$endedAt = now();
$wash->update([
'status' => 'completed',
'ended_at' => $endedAt,
'duration_minutes' => max(1, (int) $startedAt->diffInMinutes($endedAt)),
]);
if ($wash->booking_id !== null) {
\App\Models\Booking::query()
->whereKey($wash->booking_id)
->update(['status' => 'completed']);
}
}
}
});
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable(['actor_type', 'actor_id', 'organization_id', 'establishment_id', 'action', 'target_type', 'target_id', 'before_data', 'after_data', 'ip_address'])]
class AuditLog extends Model
{
public const UPDATED_AT = null;
protected function casts(): array
{
return [
'before_data' => 'array',
'after_data' => 'array',
'created_at' => 'datetime',
];
}
public function actor(): MorphTo
{
return $this->morphTo();
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
}
public function establishment(): BelongsTo
{
return $this->belongsTo(Establishment::class);
}
public function target(): MorphTo
{
return $this->morphTo();
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'machine_id', 'slot_start', 'slot_end', 'booking_fee', 'reserved_amount', 'penalty_amount', 'status', 'cancelled_at', 'penalty_applied_at'])]
class Booking extends Model
{
use HasUuid;
protected function casts(): array
{
return [
'slot_start' => 'datetime',
'slot_end' => 'datetime',
'booking_fee' => 'decimal:2',
'reserved_amount' => 'decimal:2',
'penalty_amount' => 'decimal:2',
'status' => 'string',
'cancelled_at' => 'datetime',
'penalty_applied_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
public function washes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Wash::class);
}
public function isCancellable(): bool
{
return in_array($this->status, ['pending', 'confirmed'], true)
&& $this->slot_start->isFuture();
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Models\Concerns;
use App\Models\Organization;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToOrganization
{
public function scopeForOrganization(Builder $query, Organization|int $organization): Builder
{
$organizationId = $organization instanceof Organization ? $organization->id : $organization;
return $query->whereHas('establishment', function (Builder $query) use ($organizationId) {
$query->where('organization_id', $organizationId);
});
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
trait HasUuid
{
protected static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOrganization;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['establishment_id', 'stat_date', 'total_washes', 'total_revenue', 'bookings_count', 'no_show_count', 'top_up_amount', 'occupancy_rate'])]
class DailyEstablishmentStat extends Model
{
use BelongsToOrganization;
protected function casts(): array
{
return [
'stat_date' => 'date',
'total_washes' => 'integer',
'total_revenue' => 'decimal:2',
'bookings_count' => 'integer',
'no_show_count' => 'integer',
'top_up_amount' => 'decimal:2',
'occupancy_rate' => 'decimal:2',
];
}
public function establishment(): BelongsTo
{
return $this->belongsTo(Establishment::class);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['organization_id', 'name', 'address', 'city', 'zip_code', 'latitude', 'longitude', 'timezone', 'is_active'])]
class Establishment extends Model
{
use HasUuid;
protected function casts(): array
{
return [
'latitude' => 'decimal:8',
'longitude' => 'decimal:8',
'is_active' => 'boolean',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
}
public function machines(): HasMany
{
return $this->hasMany(Machine::class);
}
public function pricingRules(): HasMany
{
return $this->hasMany(PricingRule::class);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'type', 'accepted', 'policy_version', 'policy_text_hash', 'source', 'consent_language', 'ip_address', 'user_agent', 'accepted_at', 'revoked_at'])]
class GdprConsent extends Model
{
public $timestamps = false;
protected function casts(): array
{
return [
'type' => 'string',
'accepted' => 'boolean',
'source' => 'string',
'accepted_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOrganization;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable(['establishment_id', 'name', 'type', 'qr_code', 'status', 'current_user_id', 'cycle_started_at', 'cycle_ends_at', 'last_heartbeat_at'])]
class Machine extends Model
{
use BelongsToOrganization, HasUuid;
protected function casts(): array
{
return [
'type' => 'string',
'status' => 'string',
'cycle_started_at' => 'datetime',
'cycle_ends_at' => 'datetime',
'last_heartbeat_at' => 'datetime',
];
}
public function establishment(): BelongsTo
{
return $this->belongsTo(Establishment::class);
}
public function currentUser(): BelongsTo
{
return $this->belongsTo(User::class, 'current_user_id');
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function washes(): HasMany
{
return $this->hasMany(Wash::class);
}
public function machineIntegration(): HasOne
{
return $this->hasOne(MachineIntegration::class);
}
public function integration(): HasOne
{
return $this->machineIntegration();
}
public function commands(): HasMany
{
return $this->hasMany(MachineCommand::class);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['machine_id', 'provider', 'command_type', 'payload', 'status', 'external_reference', 'correlation_id', 'requested_by_user_id', 'requested_by_supervisor_id', 'sent_at', 'responded_at'])]
class MachineCommand extends Model
{
use HasUuid;
protected function casts(): array
{
return [
'command_type' => 'string',
'payload' => 'array',
'status' => 'string',
'sent_at' => 'datetime',
'responded_at' => 'datetime',
];
}
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
public function requestedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by_user_id');
}
public function requestedBySupervisor(): BelongsTo
{
return $this->belongsTo(Supervisor::class, 'requested_by_supervisor_id');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['machine_id', 'provider', 'external_event_id', 'event_type', 'payload', 'occurred_at', 'received_at', 'processed_at', 'processing_status'])]
class MachineEvent extends Model
{
protected function casts(): array
{
return [
'event_type' => 'string',
'payload' => 'array',
'occurred_at' => 'datetime',
'received_at' => 'datetime',
'processed_at' => 'datetime',
'processing_status' => 'string',
];
}
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['machine_id', 'provider', 'external_machine_id', 'external_site_id', 'mode', 'config', 'is_active'])]
class MachineIntegration extends Model
{
protected function casts(): array
{
return [
'mode' => 'string',
'config' => 'array',
'is_active' => 'boolean',
];
}
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['machine_id', 'previous_status', 'new_status', 'source', 'reason'])]
class MachineStatusHistory extends Model
{
public const UPDATED_AT = null;
protected function casts(): array
{
return [
'created_at' => 'datetime',
];
}
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'transaction_enabled', 'reminder_enabled', 'marketing_enabled', 'system_enabled'])]
class NotificationPreference extends Model
{
protected function casts(): array
{
return [
'transaction_enabled' => 'boolean',
'reminder_enabled' => 'boolean',
'marketing_enabled' => 'boolean',
'system_enabled' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'code', 'is_active'])]
class Organization extends Model
{
use HasUuid;
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function establishments(): HasMany
{
return $this->hasMany(Establishment::class);
}
public function supervisors(): HasMany
{
return $this->hasMany(Supervisor::class);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'provider', 'provider_payment_id', 'amount', 'currency', 'status', 'idempotency_key', 'return_url', 'raw_payload'])]
class PaymentTransaction extends Model
{
use HasUuid;
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'status' => 'string',
'raw_payload' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOrganization;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['establishment_id', 'machine_id', 'machine_type', 'day_type', 'slot_start', 'slot_end', 'price', 'label', 'requires_app', 'priority', 'is_active'])]
class PricingRule extends Model
{
use BelongsToOrganization;
protected function casts(): array
{
return [
'machine_type' => 'string',
'day_type' => 'string',
'slot_start' => 'datetime:H:i:s',
'slot_end' => 'datetime:H:i:s',
'price' => 'decimal:2',
'requires_app' => 'boolean',
'priority' => 'integer',
'is_active' => 'boolean',
];
}
public function establishment(): BelongsTo
{
return $this->belongsTo(Establishment::class);
}
public function machine(): BelongsTo
{
return $this->belongsTo(Machine::class);
}
}

Some files were not shown because too many files have changed in this diff Show More