diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..698a927 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +*.log +.DS_Store +src/.env +src/.env.backup +src/.env.production +src/.phpactor.json +src/.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +src/auth.json +src/node_modules +src/public/build +src/public/fonts-manifest.dev.json +src/public/hot +src/public/storage +src/storage/*.key +src/storage/pail +src/vendor +src/package-lock.json +src/composer.lock +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..9df13eb --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1 @@ +vitePipeline(name: 'laundry-backend', phpVersion: '8.3') diff --git a/README.md b/README.md index d483ec4..18b1af0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ -# backend +

Laravel Logo

+

+Build Status +Total Downloads +Latest Stable Version +License +

+ +```cmd +/c/wamp64/bin/php/php8.3.28/php.exe artisan serve +npm run dev +/c/wamp64/bin/php/php8.3.28/php.exe artisan queue:work +``` + +## 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). diff --git a/documentation/CDC_API_Backend.md b/documentation/CDC_API_Backend.md new file mode 100644 index 0000000..1e3c844 --- /dev/null +++ b/documentation/CDC_API_Backend.md @@ -0,0 +1,667 @@ +# Cahier des Charges — API & Base de Données +## Projet : Laverie Connectée — Backend +**Version :** 1.0 +**Date :** 2026-06-27 +**Stack principale :** Laravel 11, MySQL 8, Redis, Docker + +--- + +## 1. Contexte & Objectifs + +### 1.1 Contexte +Développement d'une API REST centrale servant : +- Une application mobile multi-plateforme (Flutter : Android / iOS / Web) +- Un back-office d'administration (Vue.js) +- Des automates de laverie connectés via passerelle LLDP + +L'API est le **seul point d'entrée** pour toute la logique métier. Aucun client ne communique directement avec la base de données. + +### 1.2 Objectifs +- Exposer des endpoints REST sécurisés (JWT) pour tous les clients +- Gérer les utilisateurs, l'authentification, les porte-monnaie et les paiements +- Orchestrer les communications avec les automates (commandes machines) +- Automatiser les notifications (cron, push, événementiel) +- Garantir la conformité RGPD sur toutes les données personnelles + +--- + +## 2. Stack Technique + +| Composant | Technologie | Version | Justification | +|-----------|-------------|---------|---------------| +| Framework | Laravel | 11.x | Robustesse, écosystème, ORM Eloquent | +| Base de données principale | MySQL | 8.0 | Transactions ACID, maturité | +| Cache & Queues | Redis | 7.x | Queues asynchrones, cache sessions | +| Auth | Laravel Sanctum | 4.x | JWT stateless multi-guard | +| ORM | Eloquent | (Laravel) | Relations, scopes, casting | +| Queues | Laravel Horizon | 5.x | Monitoring des workers Redis | +| Scheduler | Laravel Scheduler | (Laravel) | Cron jobs natifs | +| Notifications Push | Firebase FCM | HTTP v1 | Android + Web | +| Notifications Push | APNs (Apple) | HTTP/2 | iOS | +| Paiement | Abstraction PaymentProvider | — | Multi-partenaire | +| Tests | PHPUnit + Pest | — | TDD sur logique métier critique | +| Documentation API | Scramble (L5-Swagger) | — | OpenAPI 3.0 auto-générée | +| Conteneurisation | Docker + Docker Compose | — | Dev/staging/prod reproductibles | +| CI/CD | GitHub Actions | — | Tests + déploiement automatisé | + +--- + +## 3. Architecture + +### 3.1 Structure du projet Laravel + +``` +app/ +├── Http/ +│ ├── Controllers/ +│ │ ├── Auth/ +│ │ │ ├── UserAuthController.php +│ │ │ └── SupervisorAuthController.php +│ │ ├── User/ +│ │ │ ├── WalletController.php +│ │ │ ├── BookingController.php +│ │ │ └── WashController.php +│ │ ├── Supervisor/ +│ │ │ ├── DashboardController.php +│ │ │ ├── MachineController.php +│ │ │ └── PricingController.php +│ │ └── Webhook/ +│ │ └── PaymentWebhookController.php +│ ├── Middleware/ +│ │ ├── EnsureEmailVerified.php +│ │ ├── CheckWalletBalance.php +│ │ └── RateLimitPayment.php +│ └── Requests/ # Form Requests (validation) +├── Models/ +│ ├── User.php +│ ├── Supervisor.php +│ ├── Establishment.php +│ ├── Machine.php +│ ├── Booking.php +│ ├── Wash.php +│ ├── WalletTransaction.php +│ ├── PricingRule.php +│ ├── Promotion.php +│ ├── Notification.php +│ └── GdprConsent.php +├── Services/ +│ ├── WalletService.php # Logique porte-monnaie (atomique) +│ ├── BookingService.php # Réservation + débit auto no-show +│ ├── MachineCommandService.php # Abstraction commandes LLDP +│ ├── NotificationService.php # FCM + APNs +│ ├── Payment/ +│ │ ├── PaymentProviderInterface.php +│ │ ├── StripeProvider.php +│ │ └── LydiProvider.php # Exemple partenaire alternatif +│ └── PricingService.php # Calcul tarif dynamique +├── Jobs/ +│ ├── SendPushNotificationJob.php +│ ├── DebitNoShowBookingJob.php +│ └── SyncMachineStatusJob.php +├── Events/ & Listeners/ +├── Policies/ # Autorisation par modèle +└── Console/Commands/ # Artisan custom +``` + +### 3.2 Guards d'authentification + +``` +sanctum guards : +├── user → token scope [user] → app mobile +├── supervisor → token scope [supervisor] → back-office +└── machine → token scope [machine] → automates (optionnel) +``` + +### 3.3 Flux de données principal + +``` +App Flutter → HTTPS → API Laravel → MySQL + ↓ + Redis Queue → Job Workers + ↓ + FCM / APNs / LLDP Gateway +``` + +--- + +## 4. Base de Données — Schéma Complet + +### 4.1 Table `users` + +```sql +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, -- Identifiant public exposé dans l'API + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + phone VARCHAR(20) UNIQUE, + birthdate DATE, + email_verified_at TIMESTAMP NULL, + password VARCHAR(255) NOT NULL, + wallet_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00, + referral_code VARCHAR(20) UNIQUE, + referred_by BIGINT UNSIGNED NULL REFERENCES users(id), + loyalty_points INT UNSIGNED NOT NULL DEFAULT 0, + fcm_token VARCHAR(255) NULL, -- Token push Android/Web + apns_token VARCHAR(255) NULL, -- Token push iOS + locale VARCHAR(10) DEFAULT 'fr', + is_active BOOLEAN DEFAULT TRUE, + anonymized_at TIMESTAMP NULL, -- RGPD : suppression logique + created_at TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP NULL -- Soft delete +); +``` + +### 4.2 Table `gdpr_consents` + +```sql +CREATE TABLE gdpr_consents ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id), + type ENUM('data_processing','marketing','analytics') NOT NULL, + accepted BOOLEAN NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + accepted_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL +); +``` + +### 4.3 Table `supervisors` + +```sql +CREATE TABLE supervisors ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id), + name VARCHAR(200) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + role ENUM('admin','manager','viewer') DEFAULT 'manager', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### 4.4 Table `establishments` + +```sql +CREATE TABLE establishments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + address TEXT NOT NULL, + city VARCHAR(100), + zip_code VARCHAR(10), + latitude DECIMAL(10,8), + longitude DECIMAL(11,8), + timezone VARCHAR(50) DEFAULT 'Europe/Paris', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### 4.5 Table `machines` + +```sql +CREATE TABLE machines ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id), + name VARCHAR(100) NOT NULL, + type ENUM('washer_small','washer_large','dryer_small','dryer_large') NOT NULL, + qr_code VARCHAR(255) UNIQUE NOT NULL, + lldp_device_id VARCHAR(100), -- Identifiant côté passerelle LLDP + status ENUM('available','running','reserved','maintenance','offline') DEFAULT 'available', + current_user_id BIGINT UNSIGNED NULL REFERENCES users(id), + cycle_started_at TIMESTAMP NULL, + cycle_ends_at TIMESTAMP NULL, + last_heartbeat TIMESTAMP NULL, -- Dernier ping de l'automate + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### 4.6 Table `pricing_rules` + +```sql +CREATE TABLE pricing_rules ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id), + day_type ENUM('weekday','weekend','holiday','all') DEFAULT 'all', + slot_start TIME NOT NULL, + slot_end TIME NOT NULL, + price DECIMAL(6,2) NOT NULL, + label VARCHAR(100), -- Ex: "Heure creuse", "Heure pleine" + requires_app BOOLEAN DEFAULT FALSE, -- Tarif exclusif app + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### 4.7 Table `promotions` + +```sql +CREATE TABLE promotions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id), + machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large','all') DEFAULT 'all', + discount_type ENUM('percent','fixed') NOT NULL, + discount_value DECIMAL(6,2) NOT NULL, + starts_at TIMESTAMP NOT NULL, + ends_at TIMESTAMP NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### 4.8 Table `bookings` + +```sql +CREATE TABLE bookings ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id), + machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id), + slot_start TIMESTAMP NOT NULL, + slot_end TIMESTAMP NOT NULL, + amount_reserved DECIMAL(6,2) NOT NULL, -- Montant débité au moment de la réservation + amount_penalty DECIMAL(6,2) DEFAULT 0.00, -- Pénalité no-show + status ENUM('pending','confirmed','active','completed','cancelled','no_show') DEFAULT 'pending', + cancelled_at TIMESTAMP NULL, + penalty_applied_at TIMESTAMP NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### 4.9 Table `washes` + +```sql +CREATE TABLE washes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id), + machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id), + booking_id BIGINT UNSIGNED NULL REFERENCES bookings(id), + trigger_method ENUM('qr_code','booking','terminal') NOT NULL, + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NULL, + duration_minutes INT UNSIGNED, + cost DECIMAL(6,2) NOT NULL, + pricing_rule_id BIGINT UNSIGNED NULL REFERENCES pricing_rules(id), + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +### 4.10 Table `wallet_transactions` + +```sql +CREATE TABLE wallet_transactions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id), + type ENUM('credit','debit') NOT NULL, + amount DECIMAL(10,2) NOT NULL, + balance_after DECIMAL(10,2) NOT NULL, + source ENUM('top_up','wash','booking','penalty','refund','referral_bonus') NOT NULL, + reference_id BIGINT UNSIGNED NULL, -- ID du wash ou booking associé + reference_type VARCHAR(100) NULL, -- Morph polymorphique + payment_provider VARCHAR(50) NULL, + payment_ref VARCHAR(255) NULL, -- Référence transaction externe + created_at TIMESTAMP +); +``` + +### 4.11 Table `push_notifications` + +```sql +CREATE TABLE push_notifications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NULL REFERENCES users(id), -- NULL = broadcast + type VARCHAR(100) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + data JSON NULL, + channel ENUM('fcm','apns','both') DEFAULT 'both', + status ENUM('pending','sent','failed') DEFAULT 'pending', + sent_at TIMESTAMP NULL, + created_at TIMESTAMP +); +``` + +--- + +## 5. Endpoints API — Spécification Complète + +### 5.1 Authentification utilisateur + +| Méthode | Endpoint | Description | Auth | +|---------|----------|-------------|------| +| POST | `/api/v1/auth/register` | Inscription + collecte consentements RGPD | Public | +| POST | `/api/v1/auth/login` | Connexion, retourne access_token + refresh_token | Public | +| POST | `/api/v1/auth/refresh` | Renouvelle le token | Public | +| POST | `/api/v1/auth/logout` | Révoque le token | User | +| POST | `/api/v1/auth/forgot-password` | Envoi email reset | Public | +| POST | `/api/v1/auth/reset-password` | Réinitialisation | Public | +| GET | `/api/v1/auth/verify-email/{token}` | Vérification email | Public | + +**Payload register :** +```json +{ + "first_name": "Jean", + "last_name": "Dupont", + "email": "jean@example.com", + "phone": "+33612345678", + "password": "••••••••", + "birthdate": "1990-01-15", + "referral_code": "ABC123", + "consents": { + "data_processing": true, + "marketing": false, + "analytics": true + } +} +``` + +**Réponse login :** +```json +{ + "access_token": "eyJ...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "def50200...", + "user": { + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "first_name": "Jean", + "wallet_balance": "12.50" + } +} +``` + +### 5.2 Porte-monnaie + +| Méthode | Endpoint | Description | Auth | +|---------|----------|-------------|------| +| GET | `/api/v1/wallet` | Solde actuel | User | +| GET | `/api/v1/wallet/transactions` | Historique paginé | User | +| POST | `/api/v1/wallet/top-up/initiate` | Initier rechargement | User | +| POST | `/api/v1/wallet/top-up/confirm` | Confirmer après paiement | User | +| GET | `/api/v1/wallet/top-up/providers` | Liste partenaires paiement disponibles | User | + +**Payload top-up initiate :** +```json +{ + "amount": 20.00, + "provider": "stripe", + "return_url": "laverie://wallet/topup-result" +} +``` + +**Réponse :** +```json +{ + "payment_intent_id": "pi_xxx", + "redirect_url": "https://checkout.stripe.com/...", + "expires_at": "2026-06-27T15:30:00Z" +} +``` + +### 5.3 Établissements & Machines + +| Méthode | Endpoint | Description | Auth | +|---------|----------|-------------|------| +| GET | `/api/v1/establishments` | Liste avec coordonnées GPS | User | +| GET | `/api/v1/establishments/{uuid}` | Détail + machines + disponibilités | User | +| GET | `/api/v1/machines/{uuid}` | Détail machine + tarif actuel | User | +| GET | `/api/v1/machines/{uuid}/availability` | Créneaux disponibles sur N jours | User | +| GET | `/api/v1/machines/{uuid}/pricing` | Grille tarifaire active | User | +| POST | `/api/v1/machines/{uuid}/heartbeat` | Ping de l'automate (machine scope) | Machine | + +### 5.4 Réservations + +| Méthode | Endpoint | Description | Auth | +|---------|----------|-------------|------| +| POST | `/api/v1/bookings` | Créer une réservation | User | +| GET | `/api/v1/bookings` | Mes réservations | User | +| GET | `/api/v1/bookings/{uuid}` | Détail réservation | User | +| PATCH | `/api/v1/bookings/{uuid}/cancel` | Annuler | User | +| PATCH | `/api/v1/bookings/{uuid}/move` | Déplacer le créneau | User | + +**Payload POST /bookings :** +```json +{ + "machine_uuid": "550e8400-...", + "slot_start": "2026-06-28T09:00:00Z", + "slot_end": "2026-06-28T10:00:00Z" +} +``` + +**Règles métier :** +- Vérification atomique du solde (mutex Redis sur `wallet:user:{id}`) +- Débit immédiat du montant + supplément réservation (1€) +- Si annulation > 2h avant : remboursement intégral +- Si annulation < 2h ou no-show : pénalité définie par `machine.no_show_penalty` +- Cron toutes les 5 minutes : détection no-show → `DebitNoShowBookingJob` + +### 5.5 Lavages + +| Méthode | Endpoint | Description | Auth | +|---------|----------|-------------|------| +| POST | `/api/v1/washes/start` | Démarrer via QR code | User | +| GET | `/api/v1/washes/{uuid}` | Statut en cours | User | +| GET | `/api/v1/washes` | Historique | User | + +**Payload POST /washes/start :** +```json +{ + "qr_code": "MACHINE_QR_ABC123", + "program": "60C_standard" +} +``` + +**Flux :** +1. Valider le QR → identifier la machine +2. Vérifier statut machine (`available`) +3. Vérifier solde suffisant +4. Calculer prix (tarif actuel + promo éventuelle) +5. Débit atomique du porte-monnaie +6. Envoyer commande à la passerelle LLDP via `MachineCommandService` +7. Mettre à jour le statut machine (`running`) +8. Retourner confirmation + durée estimée + +### 5.6 Authentification superviseur + +| Méthode | Endpoint | Description | Auth | +|---------|----------|-------------|------| +| POST | `/api/v1/supervisor/auth/login` | Connexion superviseur | Public | +| POST | `/api/v1/supervisor/auth/logout` | Déconnexion | Supervisor | +| GET | `/api/v1/supervisor/dashboard` | Statistiques + alertes | Supervisor | +| GET | `/api/v1/supervisor/machines` | Parc machines de l'établissement | Supervisor | +| PATCH | `/api/v1/supervisor/machines/{uuid}` | Mise à jour machine | Supervisor | +| GET | `/api/v1/supervisor/pricing` | Grille tarifaire | Supervisor | +| POST | `/api/v1/supervisor/pricing` | Créer règle tarifaire | Supervisor | +| PUT | `/api/v1/supervisor/pricing/{id}` | Modifier règle | Supervisor | +| DELETE | `/api/v1/supervisor/pricing/{id}` | Supprimer règle | Supervisor | +| GET | `/api/v1/supervisor/promotions` | Promotions actives | Supervisor | +| POST | `/api/v1/supervisor/promotions` | Créer promotion | Supervisor | + +--- + +## 6. Services Métier Critiques + +### 6.1 WalletService — Opérations atomiques + +Toute opération sur le porte-monnaie passe par ce service. Les débits/crédits sont encapsulés dans des transactions MySQL + verrou Redis pour éviter les race conditions. + +``` +WalletService::credit(user, amount, source, referenceId) +WalletService::debit(user, amount, source, referenceId) ← lance WalletInsufficientFundsException si insuffisant +WalletService::reserve(user, amount, bookingId) ← débit préventif réservation +WalletService::refund(user, walletTransactionId) +``` + +**Règle absolue :** Aucun controller ne doit modifier `users.wallet_balance` directement. Tout passe par `WalletService`. + +### 6.2 PricingService — Tarif dynamique + +``` +PricingService::getCurrentPrice(machine, datetime) + → Cherche la PricingRule active pour (machine_id, day_type, heure) + → Applique Promotion active si existante + → Retourne { base_price, discount, final_price, label, requires_app } +``` + +**Priorité des règles :** Règle spécifique machine > Règle par type machine > Tarif défaut établissement. + +### 6.3 MachineCommandService — Abstraction LLDP + +``` +MachineCommandService::startWash(machine, program) +MachineCommandService::stopWash(machine) +MachineCommandService::getStatus(machine) +``` + +L'implémentation concrète (`LldpGatewayAdapter`) communique avec la passerelle LLDP via HTTP interne. En cas d'indisponibilité de la passerelle, la commande est mise en queue Redis et retentée 3 fois (backoff exponentiel). + +### 6.4 PaymentProviderInterface + +```php +interface PaymentProviderInterface { + public function initiateTopUp(User $user, float $amount, string $returnUrl): TopUpIntent; + public function confirmTopUp(string $paymentRef): TopUpConfirmation; + public function handleWebhook(Request $request): void; +} +``` + +Implémentations : `StripeProvider`, `LydiProvider`, `SumUpProvider` (extensible). + +--- + +## 7. Tâches Planifiées (Cron) + +| Fréquence | Job | Description | +|-----------|-----|-------------| +| Toutes les 5 min | `CheckNoShowBookings` | Détecter et débiter les no-shows | +| Toutes les 5 min | `SyncMachineStatuses` | Interroger la passerelle LLDP | +| Toutes les heures | `SendScheduledNotifications` | Notifications planifiées en attente | +| Chaque jour 02:00 | `GenerateDailyStats` | Agrégation statistiques pour dashboard | +| Chaque jour 03:00 | `CleanExpiredTokens` | Purge tokens révoqués | +| Chaque semaine | `AnonymizeInactiveUsers` | RGPD : users inactifs > 3 ans | + +--- + +## 8. Notifications — Architecture + +### 8.1 Déclencheurs +| Événement | Canal | Timing | +|-----------|-------|--------| +| Rechargement confirmé | Push | Immédiat | +| Réservation confirmée | Push | Immédiat | +| Rappel créneau | Push | 30 min avant | +| Lavage terminé | Push | Immédiat | +| Solde faible (< 5€) | Push | À chaque débit | +| Promotion disponible | Push | Planifié par superviseur | +| No-show détecté | Push | À la détection | + +### 8.2 Implémentation +- Les notifications sont toujours créées en base (`push_notifications`) avant envoi +- L'envoi passe par un Job Redis (`SendPushNotificationJob`) → résilience +- FCM HTTP v1 pour Android/Web, APNs HTTP/2 pour iOS +- En cas d'échec, 3 retries avec backoff (1min, 5min, 15min) + +--- + +## 9. Sécurité + +### 9.1 Authentification +- Tokens JWT via Laravel Sanctum (stateless) +- Refresh token rotation (invalidation à chaque renouvellement) +- Rate limiting : 5 tentatives de login / 10 minutes / IP +- Rate limiting paiements : 3 initiations / minute / user + +### 9.2 Données +- Mots de passe hashés : `bcrypt` (cost 12) +- Données sensibles (IBAN, tokens paiement) : chiffrées `AES-256-CBC` en base +- HTTPS obligatoire (TLS 1.3 minimum) +- UUID exposés dans l'API (jamais les auto-increment IDs) +- Soft deletes sur `users` — anonymisation RGPD séparée + +### 9.3 API +- Validation stricte de tous les inputs (`FormRequest`) +- Politique CORS restrictive (origines whitelist) +- Headers sécurité : `Strict-Transport-Security`, `X-Frame-Options`, `Content-Security-Policy` + +--- + +## 10. Tests + +| Type | Outil | Couverture cible | +|------|-------|-----------------| +| Tests unitaires | Pest | WalletService, PricingService, BookingService | +| Tests d'intégration | Pest + SQLite in-memory | Tous les endpoints API | +| Tests de contrats | — | Webhooks paiement | +| Tests de charge | k6 | Endpoints critiques (start wash, top-up) | + +**Cas critiques à tester :** +- Race condition débit simultané du porte-monnaie +- Timeout passerelle LLDP → comportement de la queue +- Webhook paiement reçu en doublon (idempotence) +- No-show avec annulation simultanée + +--- + +## 11. Variables d'environnement requises + +```env +APP_ENV=production +APP_KEY=base64:... + +DB_HOST= +DB_DATABASE=laverie +DB_USERNAME= +DB_PASSWORD= + +REDIS_HOST= +REDIS_PASSWORD= + +# Paiement +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= + +# Notifications +FIREBASE_PROJECT_ID= +FIREBASE_PRIVATE_KEY= +FIREBASE_CLIENT_EMAIL= +APNS_KEY_ID= +APNS_TEAM_ID= +APNS_PRIVATE_KEY_PATH= + +# LLDP Gateway +LLDP_GATEWAY_URL= +LLDP_GATEWAY_TOKEN= + +# URLs +FRONTEND_URL= +APP_URL= +``` + +--- + +## 12. Livrables attendus + +- [ ] Code source Laravel complet (GitHub) +- [ ] Migrations SQL versionnées +- [ ] Seeders (données de démo) +- [ ] Documentation OpenAPI 3.0 auto-générée (`/api/documentation`) +- [ ] Collection Postman exportée +- [ ] `docker-compose.yml` pour dev local +- [ ] `.env.example` documenté +- [ ] README avec instructions d'installation +- [ ] Suite de tests (couverture > 80% sur les services critiques) diff --git a/documentation/CDC_API_Backend_v2.md b/documentation/CDC_API_Backend_v2.md new file mode 100644 index 0000000..d859c1f --- /dev/null +++ b/documentation/CDC_API_Backend_v2.md @@ -0,0 +1,843 @@ +# Cahier des Charges — API & Base de Données +## Projet : Laverie Connectée — Backend +**Version :** 2.0 +**Stack principale :** Laravel 11, MySQL 8, Redis, Docker +**Positionnement :** V1 démontrable, extensible vers V2 sans refonte majeure + +--- + +## 1. Contexte & Objectifs + +### 1.1 Contexte +Développement d'une API centrale servant : +- une application utilisateur multi-plateforme, +- un back-office exploitant, +- une couche d'intégration avec les systèmes machines fournis par le client ou ses partenaires techniques. + +L'API est le **point d'entrée unique de la logique métier**. Les clients applicatifs ne communiquent jamais directement avec la base de données. + +### 1.2 Objectifs V1 +- authentifier les utilisateurs finaux et les exploitants, +- gérer plusieurs laveries et plusieurs exploitants dans une même plateforme, +- exposer les données établissements / machines / disponibilités, +- gérer un porte-monnaie électronique avec traçabilité, +- permettre la réservation d'un créneau, +- permettre le démarrage d'un lavage via intégration machine, +- historiser les événements machines, +- fournir un socle d'audit, de sécurité et de statistiques simples. + +### 1.3 Principes de conception +- **MVP strict** : seules les fonctionnalités nécessaires à la démonstration et à la première mise en service sont incluses en V1. +- **Extensibilité** : fidélité, abonnements, parrainage, campagnes marketing et analytics avancées sont prévues mais non implémentées en V1. +- **Traçabilité** : tout flux critique doit être rejouable et auditable. +- **Isolation métier** : chaque exploitant ne voit que ses établissements, ses machines et ses chiffres. +- **Intégration externe souple** : la couche machine doit supporter aussi bien un modèle où notre système appelle une API partenaire qu'un modèle où le partenaire pousse des événements vers notre API. + +--- + +## 2. Périmètre fonctionnel + +### 2.1 MVP V1 + +#### Utilisateur final +- inscription / connexion, +- consultation des laveries, +- consultation des machines et de leur statut, +- consultation du solde wallet, +- historique simple des transactions, +- rechargement du wallet, +- réservation d'un créneau, +- démarrage d'un lavage, +- historique des lavages, +- notifications transactionnelles. + +#### Exploitant +- connexion superviseur, +- consultation du parc machines, +- consultation d'un tableau de bord simple, +- consultation des réservations et lavages, +- gestion simple de la tarification, +- visualisation des alertes techniques de base. + +#### Plateforme +- multi-exploitants, +- audit logs, +- agrégats journaliers simples, +- intégration machine simulable pour démonstration. + +### 2.2 V2 prévue +- fidélité, +- parrainage, +- abonnements, +- campagnes marketing, +- promotions avancées, +- exports RGPD complets, +- analytics détaillées, +- règles tarifaires complexes, +- exports métier avancés. + +### 2.3 Hors périmètre V1 +- chat, +- avis, +- FAQ dynamique, +- recommandation de cycle par photo, +- moteur prédictif / deep learning, +- orchestration matérielle bas niveau. + +--- + +## 3. Stack Technique + +| Composant | Technologie | Version | Commentaire | +|-----------|-------------|---------|-------------| +| Framework API | Laravel | 11.x | Coeur métier principal | +| Base de données | MySQL | 8.0 | Référentiel transactionnel | +| Cache / queues | Redis | 7.x | Queues, verrous applicatifs, cache court | +| Workers | Laravel Horizon | 5.x | Supervision des jobs | +| Scheduler | Laravel Scheduler | natif | Cron applicatif | +| Documentation API | Scramble ou L5-Swagger | — | OpenAPI | +| Conteneurisation | Docker | — | Environnements reproductibles | +| CI/CD | GitHub Actions | — | Tests + build | +| Monitoring erreurs | Sentry | — | Backend | +| Logs | JSON structurés | — | Corrélation et exploitation | + +### 3.1 Authentification recommandée +- **App mobile / web utilisateur** : access token court + refresh token. +- **Back-office exploitant** : session cookie Laravel. +- **Intégrations machines / partenaires** : API key technique ou signature HMAC. + +Cette séparation évite de mélanger les contraintes des apps utilisateurs, du back-office et des systèmes techniques. + +--- + +## 4. Architecture logique + +### 4.1 Domaines métier principaux +- Authentification +- Organisations / exploitants +- Établissements +- Machines +- Tarification +- Wallet & paiements +- Réservations +- Lavages +- Notifications +- Intégrations machines +- Audit & statistiques + +### 4.2 Multi-tenant logique +Le système n'est pas multi-base ni multi-schémas. L'isolation est assurée au niveau applicatif par : +- `organization_id`, +- policies Laravel, +- query scopes explicites, +- ressources d'API filtrées selon l'utilisateur connecté. + +### 4.3 Structure recommandée Laravel + +``` +app/ +├── Domain/ +│ ├── Auth/ +│ ├── Organization/ +│ ├── Machine/ +│ ├── Wallet/ +│ ├── Booking/ +│ ├── Wash/ +│ ├── Pricing/ +│ ├── Notification/ +│ └── Integration/ +├── Http/ +│ ├── Controllers/ +│ ├── Middleware/ +│ └── Requests/ +├── Jobs/ +├── Events/ +├── Listeners/ +├── Policies/ +├── Models/ +└── Support/ +``` + +--- + +## 5. Base de Données — Schéma révisé + +## 5.0 Connexion bdd de test + +hostname: localhost +port: 3306 +user: root +password: +shema: laverie + +## 5.1 Exploitants et périmètre + +### Table `organizations` +```sql +CREATE TABLE organizations ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + code VARCHAR(50) UNIQUE NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); +``` + +### Table `establishments` +```sql +CREATE TABLE establishments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NOT NULL, + uuid CHAR(36) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + address TEXT NOT NULL, + city VARCHAR(100) NULL, + zip_code VARCHAR(10) NULL, + latitude DECIMAL(10,8) NULL, + longitude DECIMAL(11,8) NULL, + timezone VARCHAR(50) NOT NULL DEFAULT 'Europe/Paris', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_establishments_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) +); +``` + +### Table `supervisors` +```sql +CREATE TABLE supervisors ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NOT NULL, + establishment_id BIGINT UNSIGNED NULL, + uuid CHAR(36) UNIQUE NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + role ENUM('platform_admin','owner','manager','viewer') NOT NULL DEFAULT 'manager', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_login_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_supervisors_organization FOREIGN KEY (organization_id) REFERENCES organizations(id), + CONSTRAINT fk_supervisors_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id) +); +``` + +## 5.2 Utilisateurs finaux + +### Table `users` +```sql +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + phone VARCHAR(20) UNIQUE NULL, + birthdate DATE NULL, + email_verified_at TIMESTAMP NULL, + password VARCHAR(255) NOT NULL, + locale VARCHAR(10) NOT NULL DEFAULT 'fr', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + anonymized_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL +); +``` + +### Table `gdpr_consents` +```sql +CREATE TABLE gdpr_consents ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + type ENUM('data_processing','marketing','analytics') NOT NULL, + accepted BOOLEAN NOT NULL, + policy_version VARCHAR(50) NOT NULL, + policy_text_hash VARCHAR(255) NOT NULL, + source ENUM('mobile','web','backoffice') NOT NULL, + consent_language VARCHAR(10) NOT NULL DEFAULT 'fr', + ip_address VARCHAR(45) NULL, + user_agent TEXT NULL, + accepted_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + CONSTRAINT fk_gdpr_consents_user FOREIGN KEY (user_id) REFERENCES users(id) +); +``` + +### Table `user_devices` +```sql +CREATE TABLE user_devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + platform ENUM('android','ios','web') NOT NULL, + push_token VARCHAR(255) NOT NULL, + app_version VARCHAR(50) NULL, + device_name VARCHAR(100) NULL, + last_seen_at TIMESTAMP NULL, + revoked_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE KEY uniq_user_device_token (push_token), + CONSTRAINT fk_user_devices_user FOREIGN KEY (user_id) REFERENCES users(id) +); +``` + +### Table `notification_preferences` +```sql +CREATE TABLE notification_preferences ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + transaction_enabled BOOLEAN NOT NULL DEFAULT TRUE, + reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE, + marketing_enabled BOOLEAN NOT NULL DEFAULT FALSE, + system_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE KEY uniq_notification_preferences_user (user_id), + CONSTRAINT fk_notification_preferences_user FOREIGN KEY (user_id) REFERENCES users(id) +); +``` + +## 5.3 Machines et intégrations + +### Table `machines` +```sql +CREATE TABLE machines ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + establishment_id BIGINT UNSIGNED NOT NULL, + uuid CHAR(36) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + type ENUM('washer_small','washer_large','dryer_small','dryer_large') NOT NULL, + qr_code VARCHAR(255) UNIQUE NOT NULL, + status ENUM('available','reserved','running','maintenance','offline','error') NOT NULL DEFAULT 'available', + current_user_id BIGINT UNSIGNED NULL, + cycle_started_at TIMESTAMP NULL, + cycle_ends_at TIMESTAMP NULL, + last_heartbeat_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_machines_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id), + CONSTRAINT fk_machines_current_user FOREIGN KEY (current_user_id) REFERENCES users(id) +); +``` + +### Table `machine_integrations` +```sql +CREATE TABLE machine_integrations ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + machine_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(100) NOT NULL, + external_machine_id VARCHAR(100) NOT NULL, + external_site_id VARCHAR(100) NULL, + mode ENUM('pull','push','hybrid','simulated') NOT NULL DEFAULT 'simulated', + config JSON NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE KEY uniq_machine_provider_external (provider, external_machine_id), + CONSTRAINT fk_machine_integrations_machine FOREIGN KEY (machine_id) REFERENCES machines(id) +); +``` + +### Table `machine_commands` +```sql +CREATE TABLE machine_commands ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + machine_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(100) NOT NULL, + command_type ENUM('start_cycle','stop_cycle','refresh_status') NOT NULL, + payload JSON NULL, + status ENUM('pending','sent','acknowledged','failed','timeout','cancelled') NOT NULL DEFAULT 'pending', + external_reference VARCHAR(255) NULL, + correlation_id VARCHAR(255) NULL, + requested_by_user_id BIGINT UNSIGNED NULL, + requested_by_supervisor_id BIGINT UNSIGNED NULL, + sent_at TIMESTAMP NULL, + responded_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_machine_commands_machine FOREIGN KEY (machine_id) REFERENCES machines(id), + CONSTRAINT fk_machine_commands_user FOREIGN KEY (requested_by_user_id) REFERENCES users(id), + CONSTRAINT fk_machine_commands_supervisor FOREIGN KEY (requested_by_supervisor_id) REFERENCES supervisors(id) +); +``` + +### Table `machine_events` +```sql +CREATE TABLE machine_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + machine_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(100) NOT NULL, + external_event_id VARCHAR(255) NULL, + event_type ENUM('heartbeat','machine_online','machine_offline','cycle_started','cycle_completed','cycle_failed','error_reported','status_changed') NOT NULL, + payload JSON NULL, + occurred_at TIMESTAMP NOT NULL, + received_at TIMESTAMP NOT NULL, + processed_at TIMESTAMP NULL, + processing_status ENUM('pending','processed','failed','ignored') NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_machine_events_machine FOREIGN KEY (machine_id) REFERENCES machines(id) +); +``` + +### Table `machine_status_history` +```sql +CREATE TABLE machine_status_history ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + machine_id BIGINT UNSIGNED NOT NULL, + previous_status VARCHAR(50) NULL, + new_status VARCHAR(50) NOT NULL, + source VARCHAR(100) NOT NULL, + reason VARCHAR(255) NULL, + created_at TIMESTAMP NULL, + CONSTRAINT fk_machine_status_history_machine FOREIGN KEY (machine_id) REFERENCES machines(id) +); +``` + +## 5.4 Tarification + +### Table `pricing_rules` +```sql +CREATE TABLE pricing_rules ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + establishment_id BIGINT UNSIGNED NOT NULL, + machine_id BIGINT UNSIGNED NULL, + machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large') NULL, + day_type ENUM('weekday','weekend','holiday','all') NOT NULL DEFAULT 'all', + slot_start TIME NOT NULL, + slot_end TIME NOT NULL, + price DECIMAL(8,2) NOT NULL, + label VARCHAR(100) NULL, + requires_app BOOLEAN NOT NULL DEFAULT FALSE, + priority SMALLINT NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_pricing_rules_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id), + CONSTRAINT fk_pricing_rules_machine FOREIGN KEY (machine_id) REFERENCES machines(id) +); +``` + +### Table `promotions` +```sql +CREATE TABLE promotions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + establishment_id BIGINT UNSIGNED NOT NULL, + machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large','all') NOT NULL DEFAULT 'all', + discount_type ENUM('percent','fixed') NOT NULL, + discount_value DECIMAL(8,2) NOT NULL, + starts_at TIMESTAMP NOT NULL, + ends_at TIMESTAMP NOT NULL, + description TEXT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_promotions_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id) +); +``` + +## 5.5 Wallet et paiements + +### Table `wallets` +```sql +CREATE TABLE wallets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'EUR', + current_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00, + status ENUM('active','blocked','closed') NOT NULL DEFAULT 'active', + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE KEY uniq_wallets_user (user_id), + CONSTRAINT fk_wallets_user FOREIGN KEY (user_id) REFERENCES users(id) +); +``` + +### Table `payment_transactions` +```sql +CREATE TABLE payment_transactions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + provider VARCHAR(50) NOT NULL, + provider_payment_id VARCHAR(255) NULL, + amount DECIMAL(10,2) NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'EUR', + status ENUM('initiated','pending','succeeded','failed','cancelled','refunded') NOT NULL DEFAULT 'initiated', + idempotency_key VARCHAR(100) NOT NULL, + return_url VARCHAR(255) NULL, + raw_payload JSON NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE KEY uniq_payment_idempotency (idempotency_key), + CONSTRAINT fk_payment_transactions_user FOREIGN KEY (user_id) REFERENCES users(id) +); +``` + +### Table `wallet_transactions` +```sql +CREATE TABLE wallet_transactions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + wallet_id BIGINT UNSIGNED NOT NULL, + type ENUM('credit','debit','hold','release','refund','adjustment') NOT NULL, + amount DECIMAL(10,2) NOT NULL, + balance_before DECIMAL(10,2) NOT NULL, + balance_after DECIMAL(10,2) NOT NULL, + source_type VARCHAR(100) NOT NULL, + source_id BIGINT UNSIGNED NULL, + idempotency_key VARCHAR(100) NULL, + metadata JSON NULL, + created_at TIMESTAMP NULL, + CONSTRAINT fk_wallet_transactions_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id) +); +``` + +## 5.6 Réservations et lavages + +### Table `bookings` +```sql +CREATE TABLE bookings ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + machine_id BIGINT UNSIGNED NOT NULL, + slot_start TIMESTAMP NOT NULL, + slot_end TIMESTAMP NOT NULL, + booking_fee DECIMAL(8,2) NOT NULL DEFAULT 0.00, + reserved_amount DECIMAL(8,2) NOT NULL DEFAULT 0.00, + penalty_amount DECIMAL(8,2) NOT NULL DEFAULT 0.00, + status ENUM('pending','confirmed','cancelled','expired','active','completed','no_show') NOT NULL DEFAULT 'pending', + cancelled_at TIMESTAMP NULL, + penalty_applied_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_bookings_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_bookings_machine FOREIGN KEY (machine_id) REFERENCES machines(id) +); +``` + +### Table `washes` +```sql +CREATE TABLE washes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) UNIQUE NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + machine_id BIGINT UNSIGNED NOT NULL, + booking_id BIGINT UNSIGNED NULL, + machine_command_id BIGINT UNSIGNED NULL, + trigger_method ENUM('qr_code','booking','supervisor','system') NOT NULL, + status ENUM('pending_start','running','completed','failed','cancelled') NOT NULL DEFAULT 'pending_start', + program VARCHAR(100) NULL, + started_at TIMESTAMP NULL, + ended_at TIMESTAMP NULL, + duration_minutes INT UNSIGNED NULL, + cost DECIMAL(8,2) NOT NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_washes_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_washes_machine FOREIGN KEY (machine_id) REFERENCES machines(id), + CONSTRAINT fk_washes_booking FOREIGN KEY (booking_id) REFERENCES bookings(id), + CONSTRAINT fk_washes_command FOREIGN KEY (machine_command_id) REFERENCES machine_commands(id) +); +``` + +## 5.7 Notifications, audit et statistiques + +### Table `push_notifications` +```sql +CREATE TABLE push_notifications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NULL, + type VARCHAR(100) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + data JSON NULL, + status ENUM('pending','sent','failed') NOT NULL DEFAULT 'pending', + sent_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + CONSTRAINT fk_push_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) +); +``` + +### Table `audit_logs` +```sql +CREATE TABLE audit_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + actor_type VARCHAR(50) NOT NULL, + actor_id BIGINT UNSIGNED NOT NULL, + organization_id BIGINT UNSIGNED NULL, + establishment_id BIGINT UNSIGNED NULL, + action VARCHAR(100) NOT NULL, + target_type VARCHAR(100) NOT NULL, + target_id BIGINT UNSIGNED NULL, + before_data JSON NULL, + after_data JSON NULL, + ip_address VARCHAR(45) NULL, + created_at TIMESTAMP NULL +); +``` + +### Table `daily_establishment_stats` +```sql +CREATE TABLE daily_establishment_stats ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + establishment_id BIGINT UNSIGNED NOT NULL, + stat_date DATE NOT NULL, + total_washes INT UNSIGNED NOT NULL DEFAULT 0, + total_revenue DECIMAL(10,2) NOT NULL DEFAULT 0.00, + bookings_count INT UNSIGNED NOT NULL DEFAULT 0, + no_show_count INT UNSIGNED NOT NULL DEFAULT 0, + top_up_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + occupancy_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE KEY uniq_daily_establishment_stats (establishment_id, stat_date), + CONSTRAINT fk_daily_establishment_stats_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id) +); +``` + +--- + +## 6. Contrat d'intégration machine + +### 6.1 Principe +Le backend doit supporter deux scénarios : +- **pull** : notre système appelle une API partenaire pour envoyer une commande ou lire un statut, +- **push** : le partenaire pousse des événements vers notre système, +- **hybrid** : combinaison des deux, +- **simulated** : mode démonstration / sandbox. + +### 6.2 Abstraction applicative + +```php +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; +} +``` + +### 6.3 Événements entrants attendus +- `heartbeat` +- `machine_online` +- `machine_offline` +- `cycle_started` +- `cycle_completed` +- `cycle_failed` +- `error_reported` +- `status_changed` + +### 6.4 Endpoints d'intégration proposés + +| Méthode | Endpoint | Usage | +|---------|----------|-------| +| POST | `/api/v1/integrations/machines/events` | Réception d'événements machine | +| POST | `/api/v1/integrations/machines/heartbeat` | Heartbeat passerelle / machine | +| POST | `/api/v1/integrations/machines/commands/{uuid}/ack` | Accusé de réception optionnel | + +### 6.5 Payload minimal d'événement +```json +{ + "provider": "client_gateway", + "event_type": "cycle_completed", + "occurred_at": "2026-06-27T10:15:00Z", + "machine_external_id": "MACH-001", + "correlation_id": "cmd_12345", + "data": { + "status": "available", + "duration_minutes": 42, + "error_code": null + } +} +``` + +### 6.6 Exigences minimales côté partenaire +- identifiant machine externe stable, +- horodatage fiable, +- type d'événement, +- état courant de la machine, +- identifiant de corrélation si une commande a été envoyée par notre système, +- stratégie d'authentification technique, +- documentation des erreurs métiers. + +### 6.7 Mode démonstration +Un provider simulé doit permettre : +- démarrage immédiat d'un cycle, +- passage en statut `running`, +- émission d'un faux événement `cycle_completed` après un délai configurable, +- retour en statut `available`. + +Ce mode permet une démo sans matériel réel. + +--- + +## 7. Endpoints API — V1 + +### 7.1 Auth utilisateur +| Méthode | Endpoint | Description | +|---------|----------|-------------| +| POST | `/api/v1/auth/register` | Inscription | +| POST | `/api/v1/auth/login` | Connexion utilisateur | +| POST | `/api/v1/auth/refresh` | Renouvellement token | +| POST | `/api/v1/auth/logout` | Déconnexion | +| GET | `/api/v1/auth/me` | Profil courant | + +### 7.2 Wallet +| Méthode | Endpoint | Description | +|---------|----------|-------------| +| GET | `/api/v1/wallet` | Solde | +| GET | `/api/v1/wallet/transactions` | Historique | +| POST | `/api/v1/wallet/top-up/initiate` | Démarrer un rechargement | +| POST | `/api/v1/wallet/top-up/confirm` | Confirmation de rechargement | +| POST | `/api/v1/wallet/top-up/webhook/{provider}` | Webhook PSP | + +### 7.3 Établissements & machines +| Méthode | Endpoint | Description | +|---------|----------|-------------| +| GET | `/api/v1/establishments` | Liste établissements | +| GET | `/api/v1/establishments/{uuid}` | Détail établissement | +| GET | `/api/v1/machines/{uuid}` | Détail machine | +| GET | `/api/v1/machines/{uuid}/availability` | Créneaux disponibles | +| GET | `/api/v1/machines/{uuid}/pricing` | Tarif actif | + +### 7.4 Réservations +| Méthode | Endpoint | Description | +|---------|----------|-------------| +| POST | `/api/v1/bookings` | Créer une réservation | +| GET | `/api/v1/bookings` | Mes réservations | +| GET | `/api/v1/bookings/{uuid}` | Détail réservation | +| PATCH | `/api/v1/bookings/{uuid}/cancel` | Annuler | +| PATCH | `/api/v1/bookings/{uuid}/move` | Déplacer | + +### 7.5 Lavages +| Méthode | Endpoint | Description | +|---------|----------|-------------| +| POST | `/api/v1/washes/start` | Démarrer un lavage | +| GET | `/api/v1/washes` | Historique | +| GET | `/api/v1/washes/{uuid}` | Détail | + +### 7.6 Superviseur +| Méthode | Endpoint | Description | +|---------|----------|-------------| +| POST | `/api/v1/supervisor/auth/login` | Connexion superviseur | +| POST | `/api/v1/supervisor/auth/logout` | Déconnexion | +| GET | `/api/v1/supervisor/dashboard` | Dashboard | +| GET | `/api/v1/supervisor/machines` | Parc machines | +| GET | `/api/v1/supervisor/bookings` | Réservations | +| GET | `/api/v1/supervisor/washes` | Lavages | +| GET | `/api/v1/supervisor/pricing` | Tarifs | +| POST | `/api/v1/supervisor/pricing` | Création tarif | +| PUT | `/api/v1/supervisor/pricing/{id}` | Modification tarif | +| GET | `/api/v1/supervisor/promotions` | Promotions | + +--- + +## 8. Règles métier critiques + +### 8.1 Wallet +- aucune écriture directe de solde hors `WalletService`, +- toute opération critique porte une clé d'idempotence, +- séparation stricte entre transaction de paiement externe et mouvement de wallet interne. + +### 8.2 Réservations +- un créneau machine ne peut jamais être réservé deux fois, +- création de réservation sous transaction avec verrou logique, +- pénalité et remboursement centralisés dans `BookingService`. + +### 8.3 Lavages +- un lavage ne peut démarrer que si la machine est éligible, +- toute commande machine doit être historisée, +- toute fin de cycle doit être confirmée par événement ou simulation contrôlée. + +### 8.4 Scoping exploitant +- toute requête superviseur doit être filtrée par `organization_id`, +- si `establishment_id` est renseigné sur le superviseur, la visibilité est limitée à cet établissement. + +--- + +## 9. Tâches planifiées + +| Fréquence | Job | Description | +|-----------|-----|-------------| +| Toutes les 5 min | `CheckNoShowBookings` | Détection des no-shows | +| Toutes les 5 min | `RefreshOfflineMachines` | Contrôle heartbeat / statuts | +| Toutes les heures | `SendScheduledNotifications` | Notifications en attente | +| Chaque nuit | `GenerateDailyStats` | Agrégats journaliers | +| Chaque nuit | `PurgeRevokedTokens` | Nettoyage technique | +| Chaque semaine | `AnonymizeInactiveUsers` | Politique RGPD | + +--- + +## 10. Sécurité + +### 10.1 Général +- HTTPS obligatoire, +- validation stricte des entrées, +- rate limiting sur auth, paiements et endpoints techniques, +- headers de sécurité côté web, +- UUID publics dans l'API. + +### 10.2 Paiements +- vérification systématique des webhooks, +- aucune confiance dans le retour client seul, +- conservation du payload brut du PSP. + +### 10.3 Intégrations machines +- API key dédiée ou signature HMAC, +- journalisation des appels, +- horodatage et contrôle anti-rejeu si possible. + +--- + +## 11. Observabilité + +- Sentry backend, +- logs structurés JSON, +- suivi des jobs Laravel Horizon, +- métriques minimales : + - taux d'échec commandes machines, + - taux d'échec paiements, + - machines offline, + - jobs en échec, + - no-shows. + +--- + +## 12. Tests + +| Type | Outil | Portée | +|------|-------|--------| +| Unitaires | Pest | Wallet, Booking, Pricing, Machine provider | +| Intégration | Pest + MySQL réel | Endpoints critiques | +| Contrat | Payloads partenaires | Intégration machine / paiement | +| Charge | k6 | Wallet, booking, start wash | + +### Cas critiques obligatoires +- double webhook paiement, +- double réservation même créneau, +- timeout commande machine, +- événement machine reçu en doublon, +- annulation et no-show simultanés, +- scoping superviseur inter-organisation. + +--- + +## 13. Livrables attendus + +- code source Laravel, +- migrations et seeders, +- documentation OpenAPI, +- `.env.example`, +- Docker pour dev, +- suite de tests critique, +- mode simulation machine, +- données de démonstration multi-laveries. diff --git a/documentation/CDC_BackOffice.md b/documentation/CDC_BackOffice.md new file mode 100644 index 0000000..afe5249 --- /dev/null +++ b/documentation/CDC_BackOffice.md @@ -0,0 +1,460 @@ +# Cahier des Charges — Back-Office Superviseur +## Projet : Laverie Connectée — Interface d'Administration +**Version :** 1.0 +**Date :** 2026-06-27 +**Stack principale :** Laravel 11 (API déjà existante) + Vue.js 3 + Inertia.js + +--- + +## 1. Contexte & Objectifs + +### 1.1 Contexte +Interface web d'administration destinée aux **superviseurs** (exploitants de laveries). Elle s'appuie sur les endpoints `/api/v1/supervisor/*` de l'API Laravel et s'authentifie avec le guard `supervisor`. + +Le back-office est une **SPA (Single Page Application)** rendue côté serveur via Inertia.js pour simplifier le déploiement (pas d'API séparée pour le back-office, utilisation directe de Laravel comme backend Inertia). + +### 1.2 Utilisateurs cibles +| Rôle | Droits | +|------|--------| +| `admin` | Accès complet multi-établissements, gestion des superviseurs | +| `manager` | Accès complet à son établissement | +| `viewer` | Lecture seule (statistiques, tableau de bord) | + +### 1.3 Objectifs fonctionnels +- Visualiser l'occupation en temps réel du parc de machines +- Consulter les statistiques d'utilisation et le chiffre d'affaires +- Gérer la tarification et les promotions +- Recevoir et gérer les alertes machines (pannes, hors ligne) +- (v2) Envoyer des campagnes de notifications aux utilisateurs + +--- + +## 2. Stack Technique + +| Composant | Technologie | Version | Justification | +|-----------|-------------|---------|---------------| +| Backend | Laravel 11 | (partagé avec l'API) | Inertia server-side rendering | +| Intégration SPA | Inertia.js | 2.x | SSR Laravel → Vue.js sans API REST dédiée | +| Framework UI | Vue.js | 3.x (Composition API) | Réactivité, écosystème riche | +| Build tool | Vite | 5.x | HMR rapide, bundling optimisé | +| UI Components | PrimeVue | 4.x | DataTable, Chart, Calendar, etc. | +| CSS | Tailwind CSS | 3.x | Utility-first, cohérence design | +| Graphiques | Chart.js via PrimeVue | 4.x | Courbes CA, camembert machines | +| State management | Pinia | 2.x | Store réactif Vue 3 | +| Temps réel | Laravel Echo + Pusher (ou Soketi self-hosted) | — | Mise à jour statut machines | +| Validation forms | VeeValidate + Zod | — | Validation côté client | +| Tables | PrimeVue DataTable | — | Tri, filtre, pagination, export | +| Auth | Laravel Sanctum (session cookie) | — | SPA same-domain | +| Tests back | Pest | — | Tests controllers superviseur | +| Tests front | Vitest + Vue Test Utils | — | Composants Vue | +| E2E | Playwright | — | Parcours critiques superviseur | + +--- + +## 3. Architecture + +### 3.1 Structure des fichiers + +``` +resources/ +├── js/ +│ ├── app.js # Point d'entrée Inertia +│ ├── bootstrap.js # Echo, Axios +│ ├── Components/ +│ │ ├── Layout/ +│ │ │ ├── AppLayout.vue # Layout principal (sidebar + topbar) +│ │ │ ├── Sidebar.vue +│ │ │ └── Topbar.vue +│ │ ├── Charts/ +│ │ │ ├── RevenueChart.vue +│ │ │ ├── OccupancyChart.vue +│ │ │ └── MachineStatusChart.vue +│ │ ├── Machines/ +│ │ │ ├── MachineCard.vue # Carte statut en temps réel +│ │ │ └── MachineGrid.vue +│ │ └── UI/ +│ │ ├── StatCard.vue +│ │ ├── AlertBadge.vue +│ │ └── ConfirmDialog.vue +│ │ +│ └── Pages/ +│ ├── Auth/ +│ │ └── Login.vue +│ ├── Dashboard/ +│ │ └── Index.vue +│ ├── Machines/ +│ │ ├── Index.vue +│ │ └── Show.vue +│ ├── Pricing/ +│ │ ├── Index.vue +│ │ └── Edit.vue +│ ├── Promotions/ +│ │ ├── Index.vue +│ │ └── Create.vue +│ ├── Stats/ +│ │ └── Index.vue +│ └── Settings/ +│ └── Index.vue +│ +app/ +├── Http/Controllers/Supervisor/ # (déjà définis dans CDC API) +│ ├── DashboardController.php +│ ├── MachineController.php +│ ├── PricingController.php +│ ├── PromotionController.php +│ └── StatsController.php +└── Events/ + └── MachineStatusUpdated.php # Broadcast temps réel +``` + +### 3.2 Routage Laravel (Inertia) + +```php +// routes/supervisor.php (middleware: auth:supervisor, inertia) +Route::prefix('supervisor')->name('supervisor.')->group(function () { + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + Route::resource('/machines', MachineController::class)->only(['index','show','update']); + Route::resource('/pricing', PricingController::class); + Route::resource('/promotions', PromotionController::class); + Route::get('/stats', StatsController::class)->name('stats'); + Route::get('/settings', SettingsController::class)->name('settings'); +}); +``` + +--- + +## 4. Écrans & Fonctionnalités + +### 4.1 Connexion (`/supervisor/login`) + +**Formulaire :** +- Email + Mot de passe +- Bouton "Se connecter" +- Lien "Mot de passe oublié" + +**Comportement :** +- Session cookie Sanctum (SPA same-domain) +- Redirection automatique vers `/supervisor/dashboard` si déjà connecté +- Rate limiting : 5 tentatives / 10 min + +**Données Inertia passées :** +```php +// Aucune — page statique +``` + +--- + +### 4.2 Tableau de Bord (`/supervisor/dashboard`) + +C'est l'écran central. Il se rafraîchit partiellement en temps réel via Laravel Echo. + +#### 4.2.1 Bandeau de KPIs (haut de page) + +| KPI | Description | Période | +|-----|-------------|---------| +| CA du jour | Somme des `washes.cost` du jour | Aujourd'hui | +| Lavages du jour | Nombre de `washes` terminés | Aujourd'hui | +| Taux d'occupation | % machines actives / total | Temps réel | +| Solde moyen rechargé | Moyenne des top-ups | Cette semaine | + +Chaque KPI affiche la variation vs. la veille (flèche + couleur). + +#### 4.2.2 Grille des Machines (temps réel) + +- Grille de cartes, une carte par machine +- Mise à jour via **WebSocket** (Laravel Echo, channel `establishment.{id}`, event `MachineStatusUpdated`) +- Chaque carte affiche : + - Nom et type de machine (icône) + - Statut (badge coloré) + - Temps restant si en cours (countdown) + - Utilisateur en cours (avatar anonymisé : initiales) + - Dernière activité + +**Couleurs statut :** +| Statut | Couleur | +|--------|---------| +| `available` | 🟢 Vert | +| `running` | 🔵 Bleu + animation pulse | +| `reserved` | 🟡 Jaune | +| `maintenance` | 🟠 Orange | +| `offline` | 🔴 Rouge | + +**Actions rapides (rôle manager/admin) :** +- Mettre en maintenance +- Forcer disponible +- Voir le détail + +#### 4.2.3 Alertes Actives + +Panel latéral ou section dédié listant : +- Machines hors ligne (dernier heartbeat > 5 min) +- Machines en erreur signalée +- Réservations no-show récentes + +Chaque alerte : icône, machine concernée, heure, bouton "Traité". + +#### 4.2.4 Graphique d'occupation journalière + +Courbe Chart.js (via PrimeVue) : +- Axe X : heures (00h → 23h) +- Axe Y : % d'occupation +- Deux courbes : Aujourd'hui vs Moyenne 30 jours + +**Données Inertia passées :** +```php +Inertia::render('Dashboard/Index', [ + 'machines' => MachineResource::collection($machines), + 'kpis' => $dashboardService->getTodayKpis($establishment), + 'alerts' => AlertResource::collection($activeAlerts), + 'occupancy_chart' => $statsService->getHourlyOccupancy($establishment, today()), +]); +``` + +--- + +### 4.3 Statistiques (`/supervisor/stats`) + +Écran dédié à l'analyse de données avec filtres temporels. + +#### 4.3.1 Filtres + +- Période : Aujourd'hui / 7 jours / 30 jours / 3 mois / Personnalisée (date picker) +- Machine : Toutes / Par type / Par machine spécifique + +#### 4.3.2 Blocs de statistiques + +**Chiffre d'affaires** +- Graphique en barres : CA par jour sur la période +- Ligne de tendance (moyenne mobile 7 jours) +- Total période, meilleur jour, pire jour + +**Utilisation des machines** +- Graphique camembert : Répartition par type de machine +- Nombre de cycles par machine (tableau trié) +- Taux d'utilisation horaire moyen + +**Utilisateurs** +- Nouveaux inscrits sur la période +- Utilisateurs actifs (au moins 1 lavage) +- Top 10 utilisateurs (anonymisés : "Utilisateur #XXX") + +**Réservations** +- Nombre de réservations vs. lavages directs +- Taux de no-show +- Taux d'annulation + +**Rechargements** +- CA par source de paiement (Stripe, etc.) +- Montant moyen rechargé +- Fréquence de rechargement + +#### 4.3.3 Export + +- Export CSV des données brutes de la période sélectionnée +- Export PDF du rapport (via impression navigateur, layout print-optimized) + +--- + +### 4.4 Gestion de la Tarification (`/supervisor/pricing`) + +#### 4.4.1 Vue Liste + +Tableau (PrimeVue DataTable) listant toutes les règles tarifaires : + +| Machine | Jours | Horaire | Prix | Exclusive App | Actions | +|---------|-------|---------|------|---------------|---------| +| Lave-linge 7kg | Semaine | 08h-12h | 3.50€ | Non | ✏️ 🗑 | +| Lave-linge 7kg | Semaine | 18h-22h | 4.50€ | Oui | ✏️ 🗑 | + +- Filtrable par machine, type de jour +- Tri par colonne +- Badge "Actif" / "Inactif" si les horaires sont hors plage + +#### 4.4.2 Formulaire Création / Édition + +**Champs :** +- Machine (select, avec recherche) +- Jours applicables (checkboxes : Lun–Ven / Sam–Dim / Jours fériés / Tous) +- Heure de début / fin (time pickers, validation : début < fin) +- Prix en euros (input numérique, 2 décimales, min 0.50€) +- Label (ex: "Heure creuse", "Heure pleine") +- Exclusive app ✓ (si coché, ce tarif n'est visible que via l'application) + +**Validation :** +- Vérification de non-chevauchement avec les règles existantes sur la même machine +- Avertissement si la plage couvre toute la journée sans tarif de base restant + +**Règle de priorité affichée :** +> "Une règle spécifique à une machine a priorité sur une règle par type de machine." + +--- + +### 4.5 Gestion des Promotions (`/supervisor/promotions`) + +#### 4.5.1 Vue Liste + +Tableau avec onglets : **Actives** / **À venir** / **Terminées** + +Colonnes : Type machine, Réduction, Début, Fin, Description, Statut, Actions. + +#### 4.5.2 Formulaire Création + +**Champs :** +- Type de machine concernée (Lave-linge petit / grand / Sèche-linge / Tous) +- Type de réduction : + - Pourcentage (ex: -20%) + - Montant fixe (ex: -0.50€) +- Valeur de la réduction +- Date/heure de début (datetime picker) +- Date/heure de fin (datetime picker) +- Description interne (texte libre, visible uniquement dans le back-office) + +**Validation :** +- Date de fin > Date de début +- Réduction % : 1–90% +- Réduction fixe : ne peut pas dépasser le prix minimum (0.50€ minimum après remise) +- Avertissement si chevauchement avec une promotion existante du même type + +**Impact affiché en temps réel :** +Aperçu du nouveau prix après promotion pour chaque règle tarifaire concernée. + +--- + +### 4.6 Détail Machine (`/supervisor/machines/{uuid}`) + +- Informations générales (nom, type, QR code, ID LLDP) +- Statut actuel avec boutons d'action (Maintenance / Disponible) +- Historique des 30 derniers cycles (tableau paginé) +- Grille tarifaire appliquée +- Uptime sur 30 jours (%) +- Historique des alertes / pannes (30 derniers jours) + +--- + +## 5. Temps Réel — Laravel Echo + +### 5.1 Configuration + +```javascript +// bootstrap.js +import Echo from 'laravel-echo'; +import Pusher from 'pusher-js'; + +window.Echo = new Echo({ + broadcaster: 'pusher', + key: import.meta.env.VITE_PUSHER_APP_KEY, + cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER, + wsHost: import.meta.env.VITE_PUSHER_HOST, // Soketi en self-hosted + wsPort: 6001, + forceTLS: false, + enabledTransports: ['ws', 'wss'], +}); +``` + +### 5.2 Channels & Events + +| Channel | Event | Payload | Déclencheur | +|---------|-------|---------|-------------| +| `private-establishment.{id}` | `MachineStatusUpdated` | `{ machine_uuid, status, current_user_initials, cycle_ends_at }` | Heartbeat LLDP + fin de cycle | +| `private-establishment.{id}` | `NewAlert` | `{ type, machine_uuid, message, created_at }` | Offline détecté, erreur machine | +| `private-establishment.{id}` | `KpiUpdated` | `{ ca_today, washes_today, occupancy_rate }` | Fin de chaque lavage | + +### 5.3 Implémentation Vue + +```javascript +// DashboardController.vue (Composition API) +onMounted(() => { + window.Echo + .private(`establishment.${props.establishment.id}`) + .listen('MachineStatusUpdated', (event) => { + machineStore.updateMachineStatus(event.machine_uuid, event); + }) + .listen('NewAlert', (event) => { + alertStore.addAlert(event); + }); +}); + +onUnmounted(() => { + window.Echo.leave(`establishment.${props.establishment.id}`); +}); +``` + +--- + +## 6. Sécurité + +### 6.1 Authentification +- Session cookie Sanctum (SameSite=Lax, Secure en prod) +- CSRF token sur toutes les mutations +- Timeout de session : 8 heures d'inactivité + +### 6.2 Autorisation (Policies Laravel) +``` +MachinePolicy::update() → role manager ou admin ET même établissement +PricingPolicy::create() → role manager ou admin +PromotionPolicy::delete() → role admin seulement +StatsPolicy::view() → tous les rôles +``` + +### 6.3 Données +- Les données utilisateurs affichées sont **anonymisées** (initiales, jamais nom complet ou email) +- Logs d'audit sur toutes les mutations (qui a modifié quoi et quand) + +--- + +## 7. Responsive & Accessibilité + +- Layout responsive : sidebar collapsible sur tablette, menu bottom sur mobile +- Taille minimale cible : tablette 768px (utilisation en mobilité sur le terrain) +- Contraste WCAG AA sur tous les textes +- Navigation clavier complète (focus visible) +- Attributs ARIA sur les graphiques (descriptions alternatives) + +--- + +## 8. Tests + +| Type | Outil | Portée | +|------|-------|--------| +| Tests unitaires | Pest | Services stats, services tarification | +| Tests controllers | Pest | Tous les controllers superviseur (mocks auth) | +| Tests composants | Vitest + VTU | StatCard, MachineCard, PricingForm | +| Tests E2E | Playwright | Login, modifier un tarif, créer une promo, consulter stats | + +**Scénarios Playwright critiques :** +1. Login échoué (mauvais mot de passe) → message d'erreur +2. Login réussi → redirection dashboard +3. Créer une règle tarifaire → vérification dans la liste +4. Créer une promotion avec chevauchement → message d'avertissement +5. Mise en maintenance d'une machine → badge mis à jour en temps réel + +--- + +## 9. Variables d'environnement spécifiques + +```env +# Pusher / Soketi (temps réel) +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= +PUSHER_HOST=127.0.0.1 +PUSHER_PORT=6001 +PUSHER_SCHEME=http +PUSHER_APP_CLUSTER=mt1 + +VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" +VITE_PUSHER_HOST="${PUSHER_HOST}" +VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" +``` + +--- + +## 10. Livrables attendus + +- [ ] Code source Vue.js (dans le même dépôt Laravel ou dépôt séparé) +- [ ] Migrations et seeders pour les superviseurs de démo +- [ ] Documentation des rôles et permissions +- [ ] Tests Pest controllers (couverture > 80%) +- [ ] Tests Playwright (5 parcours critiques) +- [ ] Guide d'utilisation superviseur (PDF ou page dans le back-office) +- [ ] `README.md` avec instructions de déploiement front (Vite build + assets) diff --git a/documentation/CDC_BackOffice_v2.md b/documentation/CDC_BackOffice_v2.md new file mode 100644 index 0000000..eadd378 --- /dev/null +++ b/documentation/CDC_BackOffice_v2.md @@ -0,0 +1,396 @@ +# Cahier des Charges — Back-Office Exploitant +## Projet : Laverie Connectée — Interface d'Administration +**Version :** 2.0 +**Stack principale :** Laravel 11 + Vue.js 3 + Inertia.js +**Positionnement :** V1 simple, cloisonnée par exploitant, démontrable rapidement + +--- + +## 1. Contexte & Objectifs + +### 1.1 Objectif +Le back-office permet à un exploitant de piloter ses laveries sans exposer les données des autres exploitants présents sur la plateforme. + +### 1.2 Objectifs V1 +- authentifier les exploitants, +- afficher les machines et leur état, +- afficher les indicateurs métier de base, +- consulter réservations et lavages, +- gérer les tarifs, +- consulter les alertes techniques simples, +- garantir un cloisonnement strict par exploitant. + +### 1.3 Principes +- une organisation ne voit que ses établissements, +- un manager peut être restreint à un établissement, +- la V1 privilégie la lisibilité et la fiabilité plutôt que la richesse fonctionnelle, +- le temps réel peut être simulé ou remplacé par du polling si nécessaire. + +--- + +## 2. Périmètre fonctionnel + +### 2.1 MVP V1 +- connexion superviseur, +- tableau de bord simple, +- liste des machines, +- détail machine, +- liste des réservations, +- liste des lavages, +- gestion simple des tarifs, +- consultation simple des promotions si activées en V1, +- affichage des alertes de base, +- audit minimal des actions sensibles. + +### 2.2 V2 prévue +- campagnes de notifications, +- exports avancés, +- analytics détaillées, +- gestion multi-utilisateurs plus riche, +- segmentation marketing, +- vue consolidée plus poussée pour groupes de laveries. + +### 2.3 Hors périmètre V1 +- CRM, +- marketing automation, +- édition de rapports complexes, +- configuration technique profonde des intégrations machines. + +--- + +## 3. Utilisateurs et droits + +| Rôle | Portée | Droits | +|------|--------|--------| +| `platform_admin` | plateforme complète | vision globale, administration complète | +| `owner` | organization complète | accès complet à ses laveries | +| `manager` | organization ou établissement | gestion opérationnelle | +| `viewer` | organization ou établissement | lecture seule | + +### 3.1 Règle de cloisonnement +Toute donnée visible dans le back-office doit être filtrée au minimum par `organization_id`. Si le superviseur est attaché à un établissement précis, la visibilité est encore plus restreinte. + +--- + +## 4. Stack Technique + +| Composant | Technologie | Version | Commentaire | +|-----------|-------------|---------|-------------| +| Backend | Laravel 11 | — | Backend partagé avec l'API | +| Rendu SPA | Inertia.js | 2.x | Intégration Laravel / Vue | +| UI | Vue.js | 3.x | Composition API | +| UI kit | PrimeVue | 4.x | Rapide pour dashboard / tables | +| Styles | Tailwind CSS | 3.x | Mise en forme rapide | +| State | Pinia | 2.x | Si besoin de stores front | +| Graphiques | Chart.js | 4.x | KPIs simples | +| Tests front | Vitest + Vue Test Utils | — | Composants critiques | +| Tests E2E | Playwright | — | Flux exploitant | + +### 4.1 Temps réel +Le temps réel n'est pas un prérequis absolu de la V1. Deux stratégies possibles : +- **V1 rapide** : polling 15 à 30 secondes, +- **V1 enrichie** : Laravel Echo / Soketi ou Pusher. + +Si le délai est serré, le polling est acceptable. + +--- + +## 5. Architecture recommandée + +``` +resources/js/ +├── Components/ +│ ├── Layout/ +│ ├── Dashboard/ +│ ├── Machines/ +│ ├── Pricing/ +│ ├── Bookings/ +│ ├── Washes/ +│ └── UI/ +├── Pages/ +│ ├── Auth/ +│ ├── Dashboard/ +│ ├── Machines/ +│ ├── Pricing/ +│ ├── Bookings/ +│ ├── Washes/ +│ └── Settings/ +└── app.js +``` + +### 5.1 Principe de navigation +- menu latéral simple, +- accès rapide au dashboard, +- pages data-centric, +- peu de modales complexes en V1. + +--- + +## 6. Écrans V1 + +## 6.1 Connexion + +### Fonctionnalités +- email, +- mot de passe, +- message d'erreur clair, +- redirection vers dashboard si session active. + +### Sécurité +- session cookie, +- CSRF, +- rate limiting. + +--- + +## 6.2 Tableau de bord + +### Objectif +Donner à l'exploitant une vue immédiate de l'état de ses laveries. + +### KPIs V1 +- chiffre d'affaires du jour, +- nombre de lavages du jour, +- réservations du jour, +- taux d'occupation courant, +- nombre de machines offline / en erreur. + +### Sections recommandées +- bandeau KPI, +- liste des alertes, +- aperçu du parc machines, +- graphique simple de CA ou occupation. + +### Données affichées +Toujours filtrées sur le périmètre du superviseur connecté. + +--- + +## 6.3 Liste des machines + +### Colonnes minimales +- nom, +- établissement, +- type, +- statut, +- dernier heartbeat, +- utilisateur courant anonymisé si pertinent, +- actions. + +### Filtres +- établissement, +- statut, +- type. + +### Actions V1 +- voir détail, +- basculer maintenance si autorisé, +- forcer disponibilité seulement si besoin réel et journalisé. + +--- + +## 6.4 Détail machine + +### Contenu +- informations générales, +- statut actuel, +- historique récent des cycles, +- historique récent des événements machines, +- tarification appliquée, +- alertes récentes, +- uptime simple si disponible. + +Cette page est très utile pour la démo car elle montre la profondeur du produit sans nécessiter trop d'écrans. + +--- + +## 6.5 Réservations + +### Vue liste +- utilisateur anonymisé, +- machine, +- établissement, +- créneau, +- statut, +- montant réservé, +- pénalité éventuelle. + +### Filtres +- période, +- établissement, +- machine, +- statut. + +--- + +## 6.6 Lavages + +### Vue liste +- utilisateur anonymisé, +- machine, +- établissement, +- heure de démarrage, +- heure de fin, +- coût, +- statut. + +### Intérêt +Permet à l'exploitant de relier l'activité terrain au chiffre d'affaires. + +--- + +## 6.7 Tarification + +### Vue liste +- établissement, +- machine ou type de machine, +- plage horaire, +- prix, +- libellé, +- actif / inactif. + +### Formulaire V1 +- machine ou type, +- jours applicables, +- heure début / fin, +- prix, +- libellé, +- tarif exclusif app si retenu. + +### Validations +- pas de plage inversée, +- pas de conflit de règles non géré, +- audit de toute modification. + +--- + +## 6.8 Promotions + +Si activé en V1, les promotions restent simples : +- portée établissement, +- type de machine, +- réduction fixe ou pourcentage, +- date début / fin. + +Si le timing est trop serré, cette page peut être préparée mais non activée en démonstration. + +--- + +## 6.9 Paramètres + +### Contenu minimal +- profil superviseur, +- établissement ou organisation associés, +- informations de session, +- éventuellement préférences simples. + +--- + +## 7. Cloisonnement des données + +### Règles obligatoires +- `platform_admin` : accès global, +- `owner` : toutes les laveries de son organization, +- `manager` : selon son scope, +- `viewer` : lecture seule. + +### Implémentation +- policies Laravel, +- query scopes, +- tests dédiés au cloisonnement. + +### Interdiction +Aucune page ne doit faire remonter des agrégats globaux non filtrés à un exploitant local. + +--- + +## 8. Audit + +### Actions à journaliser +- connexion superviseur, +- modification de tarif, +- création / modification de promotion, +- changement manuel de statut machine, +- toute action d'administration sensible. + +### Utilité +- sécurité, +- compréhension métier, +- support, +- preuve en cas de litige. + +--- + +## 9. Temps réel / polling + +### Option 1 - Polling V1 recommandé si délai serré +- refresh du dashboard toutes les 30 secondes, +- refresh détail machine toutes les 15 à 30 secondes. + +### Option 2 - Temps réel enrichi +- Echo, +- Soketi ou Pusher, +- mise à jour machine / alertes / KPIs. + +### Recommandation +Pour la démo de fin de mois, le polling propre est souvent suffisant. + +--- + +## 10. Sécurité + +### 10.1 Authentification +- session cookie sécurisée, +- CSRF, +- timeout de session, +- rate limiting login. + +### 10.2 Autorisation +- policies explicites par rôle, +- filtre systématique des établissements. + +### 10.3 Données utilisateur +- anonymisation des noms dans les écrans exploitants si non nécessaire, +- pas d'affichage d'email complet côté exploitation terrain. + +--- + +## 11. Dashboard de démo + +Le dashboard de démo doit montrer visuellement : +- plusieurs laveries sur la plateforme, +- un exploitant qui ne voit que les siennes, +- des machines dans plusieurs états, +- un lavage qui démarre puis se termine, +- un KPI qui évolue, +- une tarification modifiable. + +C'est le meilleur compromis entre crédibilité et temps de développement. + +--- + +## 12. Tests + +| Type | Outil | Portée | +|------|-------|--------| +| Controllers | Pest | Accès dashboard, pricing, machines | +| Components | Vitest | KPIs, listes, formulaires | +| E2E | Playwright | Connexion, dashboard, tarif, machine | + +### Cas critiques +1. un exploitant A ne voit pas les données de B, +2. un viewer ne peut pas modifier un tarif, +3. un manager voit les bons KPI, +4. une machine passe de disponible à en cours, +5. une modification de tarif est auditée. + +--- + +## 13. Livrables attendus + +- code source back-office, +- pages Inertia principales, +- dataset de démonstration multi-exploitants, +- audit minimal, +- tests critiques, +- guide court de démonstration. diff --git a/documentation/Cadrage_global_laverie_V1_V2.md b/documentation/Cadrage_global_laverie_V1_V2.md new file mode 100644 index 0000000..c95a12c --- /dev/null +++ b/documentation/Cadrage_global_laverie_V1_V2.md @@ -0,0 +1,191 @@ +# Cadrage global — Laverie Connectée + +## 1. Vision produit + +Le produit vise à centraliser la gestion de plusieurs laveries dans une même plateforme, avec deux populations principales : + +- **les utilisateurs finaux**, qui consultent les laveries, rechargent leur wallet, réservent un créneau et lancent des lavages ; +- **les exploitants**, qui pilotent uniquement leurs propres établissements, leurs machines et leurs indicateurs. + +La première version doit être **démontrable rapidement**, avec une base technique suffisamment propre pour permettre une V2 sans refonte majeure. + +--- + +## 2. Périmètre + +### MVP V1 + +- authentification utilisateur +- authentification exploitant +- multi-laveries +- cloisonnement des exploitants par organization +- consultation des établissements et machines +- wallet + rechargement +- réservation de créneau +- lancement d'un lavage +- historique simple des transactions, réservations et lavages +- dashboard exploitant simple +- tarification simple +- intégration machine simulable +- audit minimal +- statistiques journalières simples + +### V2 + +- fidélité +- parrainage +- abonnements +- promotions avancées +- campagnes marketing +- exports avancés +- analytics détaillées +- RGPD enrichi +- modes d'intégration machine plus complets + +### Hors périmètre V1 + +- chat +- avis +- FAQ dynamique +- IA / lecture photo étiquette +- moteur prédictif +- interfaçage matériel bas niveau + +--- + +## 3. Architecture métier cible + +### Entités principales + +- `organizations` +- `establishments` +- `supervisors` +- `users` +- `machines` +- `machine_integrations` +- `machine_commands` +- `machine_events` +- `wallets` +- `payment_transactions` +- `wallet_transactions` +- `bookings` +- `washes` +- `pricing_rules` +- `promotions` +- `push_notifications` +- `audit_logs` +- `daily_establishment_stats` + +### Cloisonnement + +- un exploitant ne voit que les données de son `organization_id` +- un manager peut être limité à un seul établissement +- un `platform_admin` peut avoir une vue globale + +--- + +## 4. Stratégie d'intégration machine + +Le système doit être pensé comme une **couche d'intégration** entre le métier laverie et un fournisseur technique externe. + +### Modes supportés + +- **push** : le partenaire envoie des événements à notre API +- **pull** : notre backend appelle son API +- **hybrid** : combinaison des deux +- **simulated** : mode démo + +### Principe recommandé + +Pour la V1 et la démo, implémenter d'abord un **provider simulé**. + +Cela permet de démontrer : +- le lancement d'un lavage, +- le passage machine en cours, +- la fin de cycle, +- les mises à jour du dashboard, +- les notifications, + +sans dépendre du matériel réel. + +--- + +## 5. Démo de fin de mois + +### Parcours utilisateur + +1. connexion +2. affichage des laveries +3. consultation des machines +4. rechargement wallet +5. réservation d'un créneau +6. lancement d'un lavage +7. cycle simulé +8. notification de fin +9. historique mis à jour + +### Parcours exploitant + +1. connexion exploitant +2. dashboard limité à ses laveries +3. visualisation d'une machine qui passe en `running` +4. évolution d'un KPI +5. modification d'un tarif + +--- + +## 6. Backlog priorisé + +### Sprint 1 — Fondations + +- modèle organizations / establishments / supervisors +- auth utilisateur +- auth exploitant +- seeders de démo +- policies de cloisonnement + +### Sprint 2 — Coeur métier + +- machines +- wallet +- rechargement +- réservation +- lavages +- provider machine simulé + +### Sprint 3 — Interfaces + +- écrans Flutter MVP +- back-office MVP +- dashboard simple +- historique simple + +### Sprint 4 — Démo et stabilisation + +- audit minimal +- notifications +- agrégats journaliers +- nettoyage UX +- dataset final de démonstration + +--- + +## 7. Risques à surveiller + +- dérive de périmètre côté client +- ambiguïté sur l'intégration machine réelle +- sous-estimation de la partie paiement +- dette technique si trop d'optimisation UI inutile en V1 + +--- + +## 8. Règle de pilotage recommandée + +Toute fonctionnalité V1 doit répondre à au moins un de ces critères : + +- utile à la démonstration, +- nécessaire au fonctionnement métier minimal, +- nécessaire à la sécurité / traçabilité, +- nécessaire pour éviter une refonte en V2. + +Si elle ne remplit aucun de ces critères, elle doit être repoussée. diff --git a/documentation/docker-compose.yml b/documentation/docker-compose.yml new file mode 100644 index 0000000..4d9015a --- /dev/null +++ b/documentation/docker-compose.yml @@ -0,0 +1,70 @@ +# Stack de développement Laverie Connectée +# Usage : docker compose up -d + +services: + mysql: + image: mysql:8.0 + container_name: laverie-mysql + restart: unless-stopped + ports: + - "${MYSQL_PORT:-3306}:3306" + environment: + MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-secret}" + MYSQL_DATABASE: laverie + MYSQL_USER: laverie + MYSQL_PASSWORD: "${MYSQL_PASSWORD:-laverie}" + volumes: + - laverie_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-secret}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: laverie-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - laverie_redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # Service applicatif Laravel (optionnel — décommenter pour lancer l'API dans Docker) + # app: + # build: + # context: ./backend + # dockerfile: Dockerfile + # container_name: laverie-app + # restart: unless-stopped + # ports: + # - "${APP_PORT:-8000}:8000" + # environment: + # APP_ENV: local + # APP_DEBUG: "true" + # DB_CONNECTION: mysql + # DB_HOST: mysql + # DB_PORT: 3306 + # DB_DATABASE: laverie + # DB_USERNAME: laverie + # DB_PASSWORD: "${MYSQL_PASSWORD:-laverie}" + # REDIS_HOST: redis + # REDIS_PORT: 6379 + # LAVERIE_MACHINE_API_KEY: "${LAVERIE_MACHINE_API_KEY:-demo-machine-api-key}" + # volumes: + # - ./backend:/var/www/html + # depends_on: + # mysql: + # condition: service_healthy + # redis: + # condition: service_healthy + # command: php artisan serve --host=0.0.0.0 --port=8000 + +volumes: + laverie_mysql_data: + laverie_redis_data: diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/src/.editorconfig @@ -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 diff --git a/src/.env.example b/src/.env.example new file mode 100644 index 0000000..e470d84 --- /dev/null +++ b/src/.env.example @@ -0,0 +1,97 @@ +APP_NAME=laundry-backend +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 +APP_TIMEZONE=Europe/Paris + +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}" + +# --- CORS (Flutter Web / clients navigateur) --- +# * = toutes origines (dev). En prod : http://localhost:5173,https://app.example.com +CORS_ALLOWED_ORIGINS=* + +# --- 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=stripe +LAVERIE_TOP_UP_MIN=5 +LAVERIE_TOP_UP_MAX=150 + +STRIPE_KEY=pk_test_51TpANRJRUgjTIwfBR9PoU4Lu201yD5R0JzvOv8Nmyva7ISX3GJPJ3IX4lSqnkg13siYwi3B9Qq0tIpEj6VCzeVFB00PSt9o0OA +STRIPE_SECRET=sk_test_51TpANRJRUgjTIwfB2jUa5nr7IpolnWNwwi0MQHbCG6JERu7npCmBrJPm6wbyTfo0lkZUh7PYyuHQ8qeecEfcfSXa0007BAKC7T +STRIPE_WEBHOOK_SECRET= diff --git a/src/.gitattributes b/src/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/src/.gitattributes @@ -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 diff --git a/src/.npmrc b/src/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/src/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/src/app/Domain/Integration/MachineCommandResult.php b/src/app/Domain/Integration/MachineCommandResult.php new file mode 100644 index 0000000..24b1cca --- /dev/null +++ b/src/app/Domain/Integration/MachineCommandResult.php @@ -0,0 +1,29 @@ + $metadata + */ + public function __construct( + public string $status, + public ?CarbonInterface $lastHeartbeat = null, + public array $metadata = [], + ) {} +} diff --git a/src/app/Domain/Integration/SimulatedMachineProvider.php b/src/app/Domain/Integration/SimulatedMachineProvider.php new file mode 100644 index 0000000..ae6dd6e --- /dev/null +++ b/src/app/Domain/Integration/SimulatedMachineProvider.php @@ -0,0 +1,219 @@ +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 $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', + ]); + } +} diff --git a/src/app/Exceptions/BookingConflictException.php b/src/app/Exceptions/BookingConflictException.php new file mode 100644 index 0000000..3b31e33 --- /dev/null +++ b/src/app/Exceptions/BookingConflictException.php @@ -0,0 +1,13 @@ +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, + ]; + } +} diff --git a/src/app/Http/Controllers/Api/V1/BookingController.php b/src/app/Http/Controllers/Api/V1/BookingController.php new file mode 100644 index 0000000..b4c8356 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/BookingController.php @@ -0,0 +1,131 @@ +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.'); + } +} diff --git a/src/app/Http/Controllers/Api/V1/EstablishmentController.php b/src/app/Http/Controllers/Api/V1/EstablishmentController.php new file mode 100644 index 0000000..b595483 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/EstablishmentController.php @@ -0,0 +1,41 @@ +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), + ]); + } +} diff --git a/src/app/Http/Controllers/Api/V1/HealthController.php b/src/app/Http/Controllers/Api/V1/HealthController.php new file mode 100644 index 0000000..2dc8065 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/HealthController.php @@ -0,0 +1,41 @@ +success([ + 'status' => 'ok', + 'service' => 'laverie-api', + 'timezone' => config('app.timezone'), + 'timestamp' => now()->toIso8601String(), + ]); + } + + public function database(): JsonResponse + { + try { + DB::select('SELECT 1'); + + return $this->success([ + 'status' => 'ok', + 'database' => config('database.default'), + 'timestamp' => now()->toIso8601String(), + ]); + } catch (Throwable $e) { + return $this->error('Database connection failed', 503, [ + 'database' => ['Connection unavailable'], + ]); + } + } +} diff --git a/src/app/Http/Controllers/Api/V1/Integration/MachineEventController.php b/src/app/Http/Controllers/Api/V1/Integration/MachineEventController.php new file mode 100644 index 0000000..e374a6c --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Integration/MachineEventController.php @@ -0,0 +1,78 @@ +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é.'); + } +} diff --git a/src/app/Http/Controllers/Api/V1/MachineController.php b/src/app/Http/Controllers/Api/V1/MachineController.php new file mode 100644 index 0000000..234621c --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/MachineController.php @@ -0,0 +1,134 @@ +where('uuid', $uuid) + ->with('establishment') + ->firstOrFail(); + + return $this->success([ + 'machine' => new MachineResource($machine), + 'pricing' => [ + 'price' => $this->pricingService->getActivePrice($machine), + 'currency' => 'EUR', + ], + 'estimated_duration_minutes' => WashCycleProgress::estimateDurationMinutes($machine->type), + ]); + } + + public function lookup(Request $request): JsonResponse + { + $request->validate([ + 'qr_code' => ['required_without:machine_uuid', 'nullable', 'string', 'max:255'], + 'machine_uuid' => ['required_without:qr_code', 'nullable', 'uuid'], + ]); + + $machine = $request->filled('machine_uuid') + ? Machine::query()->where('uuid', $request->string('machine_uuid')->toString())->firstOrFail() + : Machine::query()->where('qr_code', $request->string('qr_code')->toString())->firstOrFail(); + + $machine->load('establishment'); + + return $this->success([ + 'machine' => new MachineResource($machine), + 'pricing' => [ + 'price' => $this->pricingService->getActivePrice($machine), + 'currency' => 'EUR', + ], + 'estimated_duration_minutes' => WashCycleProgress::estimateDurationMinutes($machine->type), + ]); + } + + 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', + ]); + } +} diff --git a/src/app/Http/Controllers/Api/V1/Supervisor/AuthController.php b/src/app/Http/Controllers/Api/V1/Supervisor/AuthController.php new file mode 100644 index 0000000..81a630a --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Supervisor/AuthController.php @@ -0,0 +1,74 @@ +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.'); + } +} diff --git a/src/app/Http/Controllers/Api/V1/Supervisor/BookingController.php b/src/app/Http/Controllers/Api/V1/Supervisor/BookingController.php new file mode 100644 index 0000000..dedf596 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Supervisor/BookingController.php @@ -0,0 +1,38 @@ +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(), + ], + ]); + } +} diff --git a/src/app/Http/Controllers/Api/V1/Supervisor/DashboardController.php b/src/app/Http/Controllers/Api/V1/Supervisor/DashboardController.php new file mode 100644 index 0000000..ae3a62e --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Supervisor/DashboardController.php @@ -0,0 +1,60 @@ +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, + ]), + ]); + } +} diff --git a/src/app/Http/Controllers/Api/V1/Supervisor/MachineController.php b/src/app/Http/Controllers/Api/V1/Supervisor/MachineController.php new file mode 100644 index 0000000..cdde6a4 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Supervisor/MachineController.php @@ -0,0 +1,38 @@ +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(), + ], + ]); + } +} diff --git a/src/app/Http/Controllers/Api/V1/Supervisor/PricingController.php b/src/app/Http/Controllers/Api/V1/Supervisor/PricingController.php new file mode 100644 index 0000000..25857e1 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Supervisor/PricingController.php @@ -0,0 +1,91 @@ +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.'); + } +} diff --git a/src/app/Http/Controllers/Api/V1/Supervisor/PromotionController.php b/src/app/Http/Controllers/Api/V1/Supervisor/PromotionController.php new file mode 100644 index 0000000..8038e3d --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Supervisor/PromotionController.php @@ -0,0 +1,38 @@ +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(), + ], + ]); + } +} diff --git a/src/app/Http/Controllers/Api/V1/Supervisor/WashController.php b/src/app/Http/Controllers/Api/V1/Supervisor/WashController.php new file mode 100644 index 0000000..bbc2291 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/Supervisor/WashController.php @@ -0,0 +1,38 @@ +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(), + ], + ]); + } +} diff --git a/src/app/Http/Controllers/Api/V1/WalletController.php b/src/app/Http/Controllers/Api/V1/WalletController.php new file mode 100644 index 0000000..ccfd7c9 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/WalletController.php @@ -0,0 +1,112 @@ +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, PaymentService $paymentService): JsonResponse + { + $payment = $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, PaymentService $paymentService): JsonResponse + { + $payment = $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, PaymentService $paymentService): JsonResponse + { + if ($provider === 'stripe') { + try { + $paymentService->handleStripeWebhook( + $request->getContent(), + $request->header('Stripe-Signature', ''), + ); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), 400); + } + } else { + $paymentService->handleWebhook($provider, $request->all()); + } + + return $this->success(message: 'Webhook traité.'); + } +} diff --git a/src/app/Http/Controllers/Api/V1/WashController.php b/src/app/Http/Controllers/Api/V1/WashController.php new file mode 100644 index 0000000..f9c0048 --- /dev/null +++ b/src/app/Http/Controllers/Api/V1/WashController.php @@ -0,0 +1,93 @@ +filled('machine_uuid') + ? Machine::query()->where('uuid', $request->string('machine_uuid')->toString())->firstOrFail() + : Machine::query()->where('qr_code', $request->string('qr_code')->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), + ]); + } +} diff --git a/src/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/src/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..d44fe97 --- /dev/null +++ b/src/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,52 @@ + 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('/'); + } +} diff --git a/src/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/src/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 0000000..d2b1f14 --- /dev/null +++ b/src/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,41 @@ +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)); + } +} diff --git a/src/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/src/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..f64fa9b --- /dev/null +++ b/src/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,24 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/src/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/src/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 0000000..b42e0d5 --- /dev/null +++ b/src/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,22 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : Inertia::render('Auth/VerifyEmail', ['status' => session('status')]); + } +} diff --git a/src/app/Http/Controllers/Auth/NewPasswordController.php b/src/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..740cc51 --- /dev/null +++ b/src/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,69 @@ + $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)], + ]); + } +} diff --git a/src/app/Http/Controllers/Auth/PasswordController.php b/src/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 0000000..57a82b5 --- /dev/null +++ b/src/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,29 @@ +validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return back(); + } +} diff --git a/src/app/Http/Controllers/Auth/PasswordResetLinkController.php b/src/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..c8b2b6f --- /dev/null +++ b/src/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,51 @@ + 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)], + ]); + } +} diff --git a/src/app/Http/Controllers/Auth/RegisteredUserController.php b/src/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..3887f1c --- /dev/null +++ b/src/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,52 @@ +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)); + } +} diff --git a/src/app/Http/Controllers/Auth/VerifyEmailController.php b/src/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..784765e --- /dev/null +++ b/src/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/src/app/Http/Controllers/Concerns/RespondsWithJson.php b/src/app/Http/Controllers/Concerns/RespondsWithJson.php new file mode 100644 index 0000000..a173387 --- /dev/null +++ b/src/app/Http/Controllers/Concerns/RespondsWithJson.php @@ -0,0 +1,42 @@ + 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); + } +} diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/src/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ + $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('/'); + } +} diff --git a/src/app/Http/Controllers/Supervisor/AuthController.php b/src/app/Http/Controllers/Supervisor/AuthController.php new file mode 100644 index 0000000..cc506d0 --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/AuthController.php @@ -0,0 +1,43 @@ + 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'); + } +} diff --git a/src/app/Http/Controllers/Supervisor/BookingPageController.php b/src/app/Http/Controllers/Supervisor/BookingPageController.php new file mode 100644 index 0000000..45a7107 --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/BookingPageController.php @@ -0,0 +1,99 @@ +with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']); + + $this->scopeMachineRelationQuery($query); + $this->applyEstablishmentFilter($request, $query, 'machine'); + $this->applyUserSearchFilter($request, $query); + + if ($request->filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('date')) { + $query->whereDate('slot_start', $request->string('date')); + } + + $bookings = $query + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'slot_start' => 'bookings.slot_start', + 'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'bookings', 'user_id', $direction), + 'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'bookings', $direction), + 'status' => 'bookings.status', + 'booking_fee' => 'bookings.booking_fee', + ], 'slot_start', 'desc')) + ->paginate($this->supervisorListPerPage($request)) + ->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, + 'establishments' => $this->establishmentOptions(), + 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'user' => $request->string('user')->toString() ?: null, + 'status' => $request->string('status')->toString() ?: null, + 'date' => $request->string('date')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'slot_start', + 'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc', + 'per_page' => $this->supervisorListPerPage($request), + ], + 'statusOptions' => $this->statusOptions(), + ]); + } + + /** + * @return array + */ + 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; + } +} diff --git a/src/app/Http/Controllers/Supervisor/Concerns/ScopesSupervisorResources.php b/src/app/Http/Controllers/Supervisor/Concerns/ScopesSupervisorResources.php new file mode 100644 index 0000000..91d7c27 --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/Concerns/ScopesSupervisorResources.php @@ -0,0 +1,187 @@ +user(); + + return $supervisor; + } + + protected function supervisorListPerPage(Request $request): int + { + $perPage = $request->integer('per_page', 10); + + return in_array($perPage, $this->supervisorPerPageOptions(), true) ? $perPage : 10; + } + + /** + * @return array + */ + protected function supervisorPerPageOptions(): array + { + return [10, 25, 50]; + } + + 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); + } + }); + } + + protected function applyEstablishmentFilter(Request $request, Builder $query, string $mode = 'direct'): void + { + if (! $request->filled('establishment_id')) { + return; + } + + $establishmentId = (int) $request->input('establishment_id'); + $this->assertEstablishmentAccessible($establishmentId); + + if ($mode === 'machine') { + $query->whereHas('machine', fn (Builder $query) => $query->where('establishment_id', $establishmentId)); + } else { + $query->where('establishment_id', $establishmentId); + } + } + + protected function applyUserSearchFilter(Request $request, Builder $query): void + { + if (! $request->filled('user')) { + return; + } + + $search = $request->string('user'); + + $query->whereHas('user', function (Builder $query) use ($search) { + $query->where(function (Builder $query) use ($search) { + $query->where('first_name', 'like', "%{$search}%") + ->orWhere('last_name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + protected 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 ($query) => $query->where('id', $supervisor->establishment_id)) + ->exists(); + + abort_unless($exists, 403); + } + + /** + * @return array + */ + protected function establishmentOptions(): array + { + $supervisor = $this->supervisor(); + + return Establishment::query() + ->where('organization_id', $supervisor->organization_id) + ->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id)) + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Establishment $establishment) => [ + 'id' => $establishment->id, + 'name' => $establishment->name, + ]) + ->all(); + } + + /** + * @param array $sortableColumns + */ + protected function applySort( + Request $request, + Builder $query, + array $sortableColumns, + string $defaultColumn, + string $defaultDirection = 'asc', + ): void { + $sort = $request->string('sort')->toString(); + $direction = strtolower($request->string('direction')->toString()) === 'desc' ? 'desc' : 'asc'; + + if ($sort === '' || ! array_key_exists($sort, $sortableColumns)) { + $sort = $defaultColumn; + $direction = $defaultDirection; + } + + $handler = $sortableColumns[$sort]; + + if (is_callable($handler)) { + $handler($query, $direction); + + return; + } + + $query->orderBy($handler, $direction); + } + + protected function sortByRelatedUser(Builder $query, string $parentTable, string $foreignKey, string $direction): void + { + $query->leftJoin('users', "{$parentTable}.{$foreignKey}", '=', 'users.id') + ->orderBy('users.last_name', $direction) + ->orderBy('users.first_name', $direction) + ->select("{$parentTable}.*"); + } + + protected function sortByRelatedMachine(Builder $query, string $parentTable, string $direction): void + { + $query->leftJoin('machines', "{$parentTable}.machine_id", '=', 'machines.id') + ->orderBy('machines.name', $direction) + ->select("{$parentTable}.*"); + } + + protected function sortByRelatedEstablishment( + Builder $query, + string $parentTable, + string $foreignKeyColumn, + string $direction, + ): void { + $query->leftJoin('establishments', "{$parentTable}.{$foreignKeyColumn}", '=', 'establishments.id') + ->orderBy('establishments.name', $direction) + ->select("{$parentTable}.*"); + } +} diff --git a/src/app/Http/Controllers/Supervisor/DashboardController.php b/src/app/Http/Controllers/Supervisor/DashboardController.php new file mode 100644 index 0000000..b469320 --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/DashboardController.php @@ -0,0 +1,53 @@ +supervisor(); + $establishmentFilter = $this->resolveDashboardEstablishmentFilter($request, $supervisor); + $establishments = $this->establishmentOptions(); + + return Inertia::render('Supervisor/Dashboard', [ + 'kpis' => $dashboardService->getKpis($supervisor, $establishmentFilter), + 'alerts' => $dashboardService->getAlerts($supervisor, $establishmentFilter), + 'machinesOverview' => $dashboardService->getMachinesOverview($supervisor, $establishmentFilter), + 'machineStatusBreakdown' => $dashboardService->getMachineStatusBreakdown($supervisor, $establishmentFilter), + 'recentWashes' => $dashboardService->getRecentWashes($supervisor, $establishmentFilter), + 'context' => $dashboardService->getContext($supervisor, $establishmentFilter), + 'establishments' => $establishments, + 'showEstablishmentFilter' => $supervisor->establishment_id === null && count($establishments) > 1, + 'filters' => [ + 'establishment_id' => $establishmentFilter, + ], + ]); + } + + private function resolveDashboardEstablishmentFilter(Request $request, Supervisor $supervisor): ?int + { + if ($supervisor->establishment_id !== null) { + return $supervisor->establishment_id; + } + + if (! $request->filled('establishment_id')) { + return null; + } + + $establishmentId = (int) $request->input('establishment_id'); + $this->assertEstablishmentAccessible($establishmentId); + + return $establishmentId; + } +} diff --git a/src/app/Http/Controllers/Supervisor/MachinePageController.php b/src/app/Http/Controllers/Supervisor/MachinePageController.php new file mode 100644 index 0000000..178514c --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/MachinePageController.php @@ -0,0 +1,330 @@ +with('establishment:id,name,uuid'); + + $this->scopeEstablishmentQuery($query); + $this->applyEstablishmentFilter($request, $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 + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'name' => 'machines.name', + 'establishment' => fn ($builder, string $direction) => $builder + ->leftJoin('establishments', 'machines.establishment_id', '=', 'establishments.id') + ->orderBy('establishments.name', $direction) + ->select('machines.*'), + 'type' => 'machines.type', + 'status' => 'machines.status', + 'last_heartbeat_at' => 'machines.last_heartbeat_at', + ], 'name', 'asc')) + ->paginate($this->supervisorListPerPage($request)) + ->withQueryString() + ->through(fn (Machine $machine) => $this->formatMachineListItem($machine)); + + return Inertia::render('Supervisor/Machines/Index', [ + 'machines' => $machines, + 'establishments' => $this->establishmentOptions(), + 'canManageMachines' => $this->canManageMachines(), + 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'status' => $request->string('status')->toString() ?: null, + 'type' => $request->string('type')->toString() ?: null, + 'search' => $request->string('search')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'name', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), + ], + 'statusOptions' => $this->statusOptions(), + 'typeOptions' => $this->typeOptions(), + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($this->canManageMachines(), 403); + + $validated = $this->validateMachine($request); + $this->assertEstablishmentAccessible((int) $validated['establishment_id']); + + $validated['qr_code'] = $this->resolveQrCode($validated['qr_code'] ?? null); + $validated['status'] = $validated['status'] ?? 'available'; + + $machine = Machine::query()->create($validated); + $this->createSimulatedIntegration($machine); + + return redirect()->route('supervisor.machines.index') + ->with('success', 'Machine créée.'); + } + + public function update(Request $request, string $uuid): RedirectResponse + { + abort_unless($this->canManageMachines(), 403); + + $machine = $this->findAccessibleMachine($uuid); + $validated = $this->validateMachine($request, $machine); + $this->assertEstablishmentAccessible((int) $validated['establishment_id']); + + if (array_key_exists('qr_code', $validated) && blank($validated['qr_code'])) { + unset($validated['qr_code']); + } + + $machine->update($validated); + + return redirect()->route('supervisor.machines.index') + ->with('success', 'Machine mise à jour.'); + } + + public function destroy(string $uuid): RedirectResponse + { + abort_unless($this->canManageMachines(), 403); + + $machine = $this->findAccessibleMachine($uuid); + + if (in_array($machine->status, ['running', 'reserved'], true)) { + return redirect()->route('supervisor.machines.index') + ->withErrors(['delete' => 'Impossible de supprimer une machine en cours d\'utilisation.']); + } + + if ($machine->bookings()->exists() || $machine->washes()->exists()) { + return redirect()->route('supervisor.machines.index') + ->withErrors(['delete' => 'Impossible de supprimer une machine avec un historique de réservations ou de lavages.']); + } + + PricingRule::query()->where('machine_id', $machine->id)->update(['machine_id' => null]); + MachineCommand::query()->where('machine_id', $machine->id)->delete(); + MachineEvent::query()->where('machine_id', $machine->id)->delete(); + MachineStatusHistory::query()->where('machine_id', $machine->id)->delete(); + $machine->machineIntegration()?->delete(); + $machine->delete(); + + return redirect()->route('supervisor.machines.index') + ->with('success', 'Machine supprimée.'); + } + + 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 + */ + private function formatMachineListItem(Machine $machine): array + { + return [ + 'uuid' => $machine->uuid, + 'establishment_id' => $machine->establishment_id, + '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), + 'establishment_name' => $machine->establishment?->name, + 'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + private function validateMachine(Request $request, ?Machine $machine = null): array + { + return $request->validate([ + 'establishment_id' => ['required', 'integer', 'exists:establishments,id'], + 'name' => ['required', 'string', 'max:100'], + 'type' => ['required', 'in:washer_small,washer_large,dryer_small,dryer_large'], + 'qr_code' => [ + 'nullable', + 'string', + 'max:255', + Rule::unique('machines', 'qr_code')->ignore($machine?->id), + ], + 'status' => ['required', 'in:available,reserved,running,maintenance,offline,error'], + ]); + } + + private function findAccessibleMachine(string $uuid): Machine + { + $query = Machine::query()->where('uuid', $uuid); + $this->scopeEstablishmentQuery($query); + + return $query->firstOrFail(); + } + + private function resolveQrCode(?string $qrCode): string + { + if (filled($qrCode)) { + return $qrCode; + } + + do { + $candidate = 'LAVERIE-'.strtoupper(Str::random(8)); + } while (Machine::query()->where('qr_code', $candidate)->exists()); + + return $candidate; + } + + private function createSimulatedIntegration(Machine $machine): void + { + $machine->load('establishment'); + + MachineIntegration::query()->create([ + 'machine_id' => $machine->id, + 'provider' => config('laverie.simulation.provider_name', 'simulated'), + 'external_machine_id' => 'SIM-'.$machine->uuid, + 'external_site_id' => 'SITE-'.$machine->establishment?->uuid, + 'mode' => 'simulated', + 'config' => [ + 'cycle_duration_seconds' => config('laverie.simulation.cycle_duration_seconds', 30), + ], + 'is_active' => true, + ]); + } + + private function canManageMachines(): bool + { + return $this->supervisor()->role !== 'viewer'; + } + + /** + * @return array + */ + private function statusOptions(): array + { + return [ + 'available' => 'Disponible', + 'reserved' => 'Réservée', + 'running' => 'En cours', + 'maintenance' => 'Maintenance', + 'offline' => 'Hors ligne', + 'error' => 'Erreur', + ]; + } + + /** + * @return array + */ + 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, + }; + } +} diff --git a/src/app/Http/Controllers/Supervisor/OrganizationPageController.php b/src/app/Http/Controllers/Supervisor/OrganizationPageController.php new file mode 100644 index 0000000..952ec10 --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/OrganizationPageController.php @@ -0,0 +1,276 @@ +supervisor(); + + $baseQuery = Establishment::query() + ->where('organization_id', $supervisor->organization_id); + + if ($supervisor->establishment_id !== null) { + $baseQuery->where('id', $supervisor->establishment_id); + } + + $totalCount = (clone $baseQuery)->count(); + $viewMode = $totalCount === 1 ? 'single' : 'list'; + + $canManageEstablishments = $this->canManageEstablishments(); + $canCreateEstablishments = $this->canCreateEstablishments(); + + if ($viewMode === 'single') { + $establishment = (clone $baseQuery)->withCount('machines')->first(); + + return Inertia::render('Supervisor/Organizations/Index', [ + 'establishments' => [$this->formatEstablishment($establishment)], + 'viewMode' => 'single', + 'canManageEstablishments' => $canManageEstablishments, + 'canCreateEstablishments' => $canCreateEstablishments, + 'filters' => [], + ]); + } + + $query = (clone $baseQuery)->withCount('machines'); + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where(function ($builder) use ($search) { + $builder->where('name', 'like', "%{$search}%") + ->orWhere('address', 'like', "%{$search}%") + ->orWhere('city', 'like', "%{$search}%") + ->orWhere('zip_code', 'like', "%{$search}%"); + }); + } + + if ($request->has('is_active') && $request->string('is_active')->toString() !== '') { + $query->where('is_active', $request->boolean('is_active')); + } + + $establishments = $query + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'name' => 'name', + 'city' => 'city', + 'machines_count' => 'machines_count', + 'is_active' => 'is_active', + ], 'name', 'asc')) + ->paginate($this->supervisorListPerPage($request)) + ->withQueryString() + ->through(fn (Establishment $establishment) => $this->formatEstablishment($establishment)); + + return Inertia::render('Supervisor/Organizations/Index', [ + 'establishments' => $establishments, + 'viewMode' => 'list', + 'canManageEstablishments' => $canManageEstablishments, + 'canCreateEstablishments' => $canCreateEstablishments, + 'filters' => [ + 'search' => $request->string('search')->toString() ?: null, + 'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== '' + ? ($request->boolean('is_active') ? '1' : '0') + : null, + 'sort' => $request->string('sort')->toString() ?: 'name', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), + ], + ]); + } + + public function searchAddresses(Request $request): JsonResponse + { + abort_unless($this->canManageEstablishments(), 403); + + $validated = $request->validate([ + 'q' => ['required', 'string', 'min:3', 'max:200'], + ]); + + return response()->json( + $this->geocoding->search($validated['q']), + ); + } + + public function storeEstablishment(Request $request): RedirectResponse + { + abort_unless($this->canManageEstablishments(), 403); + abort_if($this->supervisor()->establishment_id !== null, 403); + + $validated = $this->prepareEstablishmentData( + $request->validate($this->establishmentRules()), + ); + $validated['organization_id'] = $this->supervisor()->organization_id; + + Establishment::query()->create($validated); + + return redirect()->route('supervisor.organizations.index') + ->with('success', 'Enseigne créée.'); + } + + public function updateEstablishment(Request $request, int $id): RedirectResponse + { + abort_unless($this->canManageEstablishments(), 403); + + $establishment = $this->findAccessibleEstablishment($id); + $validated = $this->prepareEstablishmentData( + $request->validate($this->establishmentRules()), + ); + + if (! empty($validated['is_active']) && ! $establishment->is_active) { + $this->validateEstablishmentReadyForActivation($validated, $establishment); + } + + $establishment->update($validated); + + return redirect()->route('supervisor.organizations.index') + ->with('success', 'Enseigne mise à jour.'); + } + + public function toggleEstablishmentActive(int $id): RedirectResponse + { + abort_unless($this->canManageEstablishments(), 403); + + $establishment = $this->findAccessibleEstablishment($id); + $isActive = ! $establishment->is_active; + + if ($isActive) { + $this->validateEstablishmentReadyForActivation([ + 'name' => $establishment->name, + 'address' => $establishment->address, + 'timezone' => $establishment->timezone, + ], $establishment); + } + + $establishment->update(['is_active' => $isActive]); + + return redirect()->route('supervisor.organizations.index') + ->with('success', $isActive ? 'Enseigne réactivée.' : 'Enseigne désactivée.'); + } + + /** + * @return array + */ + private function formatEstablishment(Establishment $establishment): array + { + return [ + 'id' => $establishment->id, + 'uuid' => $establishment->uuid, + 'name' => $establishment->name, + 'address' => $establishment->address, + 'city' => $establishment->city, + 'zip_code' => $establishment->zip_code, + 'latitude' => $establishment->latitude !== null ? (float) $establishment->latitude : null, + 'longitude' => $establishment->longitude !== null ? (float) $establishment->longitude : null, + 'timezone' => $establishment->timezone, + 'is_active' => $establishment->is_active, + 'machines_count' => $establishment->machines_count, + ]; + } + + /** + * @return array + */ + private function establishmentRules(): array + { + return [ + 'name' => ['required', 'string', 'max:200'], + 'address' => ['required', 'string'], + 'city' => ['nullable', 'string', 'max:100'], + 'zip_code' => ['nullable', 'string', 'max:10'], + 'timezone' => ['required', 'string', 'max:50'], + 'is_active' => ['boolean'], + ]; + } + + /** + * @param array $validated + * @return array + */ + private function prepareEstablishmentData(array $validated): array + { + $coordinates = $this->geocoding->geocode( + $validated['address'], + $validated['city'] ?? null, + $validated['zip_code'] ?? null, + ); + + $validated['latitude'] = $coordinates['latitude'] ?? null; + $validated['longitude'] = $coordinates['longitude'] ?? null; + + return $validated; + } + + private function findAccessibleEstablishment(int $id): Establishment + { + $supervisor = $this->supervisor(); + + return Establishment::query() + ->where('id', $id) + ->where('organization_id', $supervisor->organization_id) + ->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id)) + ->firstOrFail(); + } + + private function canCreateEstablishments(): bool + { + $supervisor = $this->supervisor(); + + if ($supervisor->role === 'viewer') { + return false; + } + + return in_array($supervisor->role, ['owner', 'platform_admin'], true) + && $supervisor->establishment_id === null; + } + + private function canManageEstablishments(): bool + { + return $this->supervisor()->role !== 'viewer'; + } + + /** + * @param array $data + */ + private function validateEstablishmentReadyForActivation(array $data, Establishment $establishment): void + { + $validator = validator( + $data, + [ + 'name' => ['required', 'string', 'max:200'], + 'address' => ['required', 'string'], + 'timezone' => ['required', 'string', 'max:50'], + ], + [ + 'name.required' => 'Le nom est requis pour réactiver l\'enseigne.', + 'address.required' => 'L\'adresse est requise pour réactiver l\'enseigne.', + 'timezone.required' => 'Le fuseau horaire est requis pour réactiver l\'enseigne.', + ], + ); + + if ($validator->fails()) { + throw ValidationException::withMessages($validator->errors()->toArray()); + } + + $establishment->loadMissing('organization'); + + if (! $establishment->organization?->is_active) { + throw ValidationException::withMessages([ + 'is_active' => 'L\'organisation doit être active pour réactiver cette enseigne.', + ]); + } + } +} diff --git a/src/app/Http/Controllers/Supervisor/PricingPageController.php b/src/app/Http/Controllers/Supervisor/PricingPageController.php new file mode 100644 index 0000000..4001163 --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/PricingPageController.php @@ -0,0 +1,204 @@ +with(['establishment:id,name', 'machine:id,uuid,name']); + + $this->scopeEstablishmentQuery($query); + $this->applyEstablishmentFilter($request, $query); + + if ($request->filled('machine_type')) { + $query->where('machine_type', $request->string('machine_type')); + } + + if ($request->filled('day_type')) { + $query->where('day_type', $request->string('day_type')); + } + + if ($request->has('is_active') && $request->string('is_active')->toString() !== '') { + $query->where('is_active', $request->boolean('is_active')); + } + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where('label', 'like', "%{$search}%"); + } + + $rules = $query + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment( + $builder, + 'pricing_rules', + 'establishment_id', + $direction, + ), + 'slot_start' => 'slot_start', + 'machine_type' => 'machine_type', + 'price' => 'price', + 'priority' => 'priority', + 'is_active' => 'is_active', + ], 'priority', 'asc')) + ->paginate($this->supervisorListPerPage($request)) + ->withQueryString() + ->through(fn (PricingRule $rule) => $this->formatRule($rule)); + + return Inertia::render('Supervisor/Pricing/Index', [ + 'rules' => $rules, + 'establishments' => $this->establishmentOptions(), + 'machineTypes' => $this->machineTypeOptions(), + 'dayTypes' => $this->dayTypeOptions(), + 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'machine_type' => $request->string('machine_type')->toString() ?: null, + 'day_type' => $request->string('day_type')->toString() ?: null, + 'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== '' + ? ($request->boolean('is_active') ? '1' : '0') + : null, + 'search' => $request->string('search')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'priority', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), + ], + ]); + } + + 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 + */ + 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 + */ + 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 + */ + 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 + */ + private function dayTypeOptions(): array + { + return [ + 'all' => 'Tous les jours', + 'weekday' => 'Semaine', + 'weekend' => 'Week-end', + 'holiday' => 'Jours fériés', + ]; + } +} diff --git a/src/app/Http/Controllers/Supervisor/PromotionPageController.php b/src/app/Http/Controllers/Supervisor/PromotionPageController.php new file mode 100644 index 0000000..61ff65c --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/PromotionPageController.php @@ -0,0 +1,189 @@ +with('establishment:id,name'); + $this->scopeEstablishmentQuery($query); + $this->applyEstablishmentFilter($request, $query); + + if ($request->filled('machine_type')) { + $query->where('machine_type', $request->string('machine_type')); + } + + if ($request->has('is_active') && $request->string('is_active')->toString() !== '') { + $query->where('is_active', $request->boolean('is_active')); + } + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where('description', 'like', "%{$search}%"); + } + + $promotions = $query + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment( + $builder, + 'promotions', + 'establishment_id', + $direction, + ), + 'discount_value' => 'discount_value', + 'starts_at' => 'starts_at', + 'ends_at' => 'ends_at', + 'machine_type' => 'machine_type', + 'is_active' => 'is_active', + ], 'starts_at', 'desc')) + ->paginate($this->supervisorListPerPage($request)) + ->withQueryString() + ->through(fn (Promotion $promotion) => $this->formatPromotion($promotion)); + + return Inertia::render('Supervisor/Promotions/Index', [ + 'promotions' => $promotions, + 'establishments' => $this->establishmentOptions(), + 'machineTypes' => $this->machineTypeOptions(), + 'discountTypes' => $this->discountTypeOptions(), + 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'machine_type' => $request->string('machine_type')->toString() ?: null, + 'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== '' + ? ($request->boolean('is_active') ? '1' : '0') + : null, + 'search' => $request->string('search')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'starts_at', + 'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc', + 'per_page' => $this->supervisorListPerPage($request), + ], + ]); + } + + 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 + */ + 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 + */ + 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 + */ + 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 + */ + private function discountTypeOptions(): array + { + return [ + 'percent' => 'Pourcentage', + 'fixed' => 'Montant fixe', + ]; + } +} diff --git a/src/app/Http/Controllers/Supervisor/WashPageController.php b/src/app/Http/Controllers/Supervisor/WashPageController.php new file mode 100644 index 0000000..6c66644 --- /dev/null +++ b/src/app/Http/Controllers/Supervisor/WashPageController.php @@ -0,0 +1,111 @@ +with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']); + + $this->scopeMachineRelationQuery($query); + $this->applyEstablishmentFilter($request, $query, 'machine'); + $this->applyUserSearchFilter($request, $query); + + if ($request->filled('status')) { + $query->where('status', $request->string('status')); + } + + if ($request->filled('date')) { + $query->whereDate('started_at', $request->string('date')); + } + + $washes = $query + ->tap(fn ($builder) => $this->applySort($request, $builder, [ + 'started_at' => 'washes.started_at', + 'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'washes', 'user_id', $direction), + 'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'washes', $direction), + 'status' => 'washes.status', + 'duration_minutes' => 'washes.duration_minutes', + 'cost' => 'washes.cost', + ], 'started_at', 'desc')) + ->paginate($this->supervisorListPerPage($request)) + ->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, + 'establishments' => $this->establishmentOptions(), + 'filters' => [ + 'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null, + 'user' => $request->string('user')->toString() ?: null, + 'status' => $request->string('status')->toString() ?: null, + 'date' => $request->string('date')->toString() ?: null, + 'sort' => $request->string('sort')->toString() ?: 'started_at', + 'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc', + 'per_page' => $this->supervisorListPerPage($request), + ], + 'statusOptions' => $this->statusOptions(), + ]); + } + + /** + * @return array + */ + 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, + }; + } +} diff --git a/src/app/Http/Middleware/AuthenticateMachineIntegration.php b/src/app/Http/Middleware/AuthenticateMachineIntegration.php new file mode 100644 index 0000000..6165b2a --- /dev/null +++ b/src/app/Http/Middleware/AuthenticateMachineIntegration.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/src/app/Http/Middleware/AuthenticateSupervisor.php b/src/app/Http/Middleware/AuthenticateSupervisor.php new file mode 100644 index 0000000..5b18278 --- /dev/null +++ b/src/app/Http/Middleware/AuthenticateSupervisor.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/src/app/Http/Middleware/EnsureApiUser.php b/src/app/Http/Middleware/EnsureApiUser.php new file mode 100644 index 0000000..f49e258 --- /dev/null +++ b/src/app/Http/Middleware/EnsureApiUser.php @@ -0,0 +1,30 @@ +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); + } +} diff --git a/src/app/Http/Middleware/EnsureSupervisorScope.php b/src/app/Http/Middleware/EnsureSupervisorScope.php new file mode 100644 index 0000000..d30bf92 --- /dev/null +++ b/src/app/Http/Middleware/EnsureSupervisorScope.php @@ -0,0 +1,120 @@ +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); + } +} diff --git a/src/app/Http/Middleware/HandleInertiaRequests.php b/src/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 0000000..6968a0e --- /dev/null +++ b/src/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,51 @@ + + */ + 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'), + ], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Auth/LoginRequest.php b/src/app/Http/Requests/Api/V1/Auth/LoginRequest.php new file mode 100644 index 0000000..29ebe24 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Auth/LoginRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Auth/RefreshRequest.php b/src/app/Http/Requests/Api/V1/Auth/RefreshRequest.php new file mode 100644 index 0000000..f602181 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Auth/RefreshRequest.php @@ -0,0 +1,20 @@ + ['required', 'string'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Auth/RegisterRequest.php b/src/app/Http/Requests/Api/V1/Auth/RegisterRequest.php new file mode 100644 index 0000000..291d02d --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Auth/RegisterRequest.php @@ -0,0 +1,27 @@ + ['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'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Booking/MoveBookingRequest.php b/src/app/Http/Requests/Api/V1/Booking/MoveBookingRequest.php new file mode 100644 index 0000000..605b89c --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Booking/MoveBookingRequest.php @@ -0,0 +1,21 @@ + ['required', 'date', 'after:now'], + 'slot_end' => ['required', 'date', 'after:slot_start'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Booking/StoreBookingRequest.php b/src/app/Http/Requests/Api/V1/Booking/StoreBookingRequest.php new file mode 100644 index 0000000..f28b619 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Booking/StoreBookingRequest.php @@ -0,0 +1,23 @@ + ['required', 'uuid', 'exists:machines,uuid'], + 'slot_start' => ['required', 'date', 'after:now'], + 'slot_end' => ['required', 'date', 'after:slot_start'], + 'idempotency_key' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Integration/AckCommandRequest.php b/src/app/Http/Requests/Api/V1/Integration/AckCommandRequest.php new file mode 100644 index 0000000..ebdc46e --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Integration/AckCommandRequest.php @@ -0,0 +1,23 @@ + ['required', Rule::in(['acknowledged', 'rejected', 'completed', 'failed'])], + 'external_reference' => ['nullable', 'string', 'max:255'], + 'data' => ['nullable', 'array'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Integration/HeartbeatRequest.php b/src/app/Http/Requests/Api/V1/Integration/HeartbeatRequest.php new file mode 100644 index 0000000..c0f3f43 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Integration/HeartbeatRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:100'], + 'machine_external_id' => ['required', 'string', 'max:255'], + 'occurred_at' => ['nullable', 'date'], + 'data' => ['nullable', 'array'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Integration/MachineEventRequest.php b/src/app/Http/Requests/Api/V1/Integration/MachineEventRequest.php new file mode 100644 index 0000000..4328e17 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Integration/MachineEventRequest.php @@ -0,0 +1,25 @@ + ['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'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Supervisor/LoginRequest.php b/src/app/Http/Requests/Api/V1/Supervisor/LoginRequest.php new file mode 100644 index 0000000..ed6faa3 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Supervisor/LoginRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Supervisor/StorePricingRequest.php b/src/app/Http/Requests/Api/V1/Supervisor/StorePricingRequest.php new file mode 100644 index 0000000..0fd4e1f --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Supervisor/StorePricingRequest.php @@ -0,0 +1,31 @@ + ['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'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Supervisor/UpdatePricingRequest.php b/src/app/Http/Requests/Api/V1/Supervisor/UpdatePricingRequest.php new file mode 100644 index 0000000..86799a4 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Supervisor/UpdatePricingRequest.php @@ -0,0 +1,30 @@ + ['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'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Wallet/ConfirmTopUpRequest.php b/src/app/Http/Requests/Api/V1/Wallet/ConfirmTopUpRequest.php new file mode 100644 index 0000000..e9fc41f --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Wallet/ConfirmTopUpRequest.php @@ -0,0 +1,20 @@ + ['required', 'uuid'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Wallet/InitiateTopUpRequest.php b/src/app/Http/Requests/Api/V1/Wallet/InitiateTopUpRequest.php new file mode 100644 index 0000000..f2ac731 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Wallet/InitiateTopUpRequest.php @@ -0,0 +1,22 @@ + ['required', 'numeric', 'min:5', 'max:150'], + 'idempotency_key' => ['required', 'string', 'max:100'], + 'return_url' => ['nullable', 'url', 'max:255'], + ]; + } +} diff --git a/src/app/Http/Requests/Api/V1/Wash/StartWashRequest.php b/src/app/Http/Requests/Api/V1/Wash/StartWashRequest.php new file mode 100644 index 0000000..eaa7b80 --- /dev/null +++ b/src/app/Http/Requests/Api/V1/Wash/StartWashRequest.php @@ -0,0 +1,25 @@ + ['required_without:qr_code', 'nullable', 'uuid', 'exists:machines,uuid'], + 'qr_code' => ['required_without:machine_uuid', 'nullable', 'string', 'max:255', 'exists:machines,qr_code'], + 'booking_uuid' => ['nullable', 'uuid', 'exists:bookings,uuid'], + 'trigger_method' => ['required', Rule::in(['qr_code', 'booking', 'supervisor', 'system'])], + 'program' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/src/app/Http/Requests/Auth/LoginRequest.php b/src/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..711e0a1 --- /dev/null +++ b/src/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,86 @@ +|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()); + } +} diff --git a/src/app/Http/Requests/ProfileUpdateRequest.php b/src/app/Http/Requests/ProfileUpdateRequest.php new file mode 100644 index 0000000..e2202dd --- /dev/null +++ b/src/app/Http/Requests/ProfileUpdateRequest.php @@ -0,0 +1,31 @@ +|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), + ], + ]; + } +} diff --git a/src/app/Http/Requests/Supervisor/LoginRequest.php b/src/app/Http/Requests/Supervisor/LoginRequest.php new file mode 100644 index 0000000..4566c2d --- /dev/null +++ b/src/app/Http/Requests/Supervisor/LoginRequest.php @@ -0,0 +1,87 @@ +|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()); + } +} diff --git a/src/app/Http/Resources/BookingResource.php b/src/app/Http/Resources/BookingResource.php new file mode 100644 index 0000000..f52e70b --- /dev/null +++ b/src/app/Http/Resources/BookingResource.php @@ -0,0 +1,27 @@ + $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')), + ]; + } +} diff --git a/src/app/Http/Resources/EstablishmentResource.php b/src/app/Http/Resources/EstablishmentResource.php new file mode 100644 index 0000000..31f27b1 --- /dev/null +++ b/src/app/Http/Resources/EstablishmentResource.php @@ -0,0 +1,25 @@ + $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')), + ]; + } +} diff --git a/src/app/Http/Resources/MachineResource.php b/src/app/Http/Resources/MachineResource.php new file mode 100644 index 0000000..dfdc2ce --- /dev/null +++ b/src/app/Http/Resources/MachineResource.php @@ -0,0 +1,24 @@ + $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')), + ]; + } +} diff --git a/src/app/Http/Resources/PaymentTransactionResource.php b/src/app/Http/Resources/PaymentTransactionResource.php new file mode 100644 index 0000000..d23b1b4 --- /dev/null +++ b/src/app/Http/Resources/PaymentTransactionResource.php @@ -0,0 +1,37 @@ + $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(), + ]; + + if ($this->provider === 'stripe' && is_array($this->raw_payload)) { + $clientSecret = $this->raw_payload['client_secret'] ?? null; + + if (is_string($clientSecret) && $clientSecret !== '') { + $data['stripe'] = [ + 'payment_intent_id' => $this->provider_payment_id, + 'client_secret' => $clientSecret, + 'publishable_key' => config('services.stripe.key'), + ]; + } + } + + return $data; + } +} diff --git a/src/app/Http/Resources/PricingRuleResource.php b/src/app/Http/Resources/PricingRuleResource.php new file mode 100644 index 0000000..80ee3ab --- /dev/null +++ b/src/app/Http/Resources/PricingRuleResource.php @@ -0,0 +1,29 @@ + $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')), + ]; + } +} diff --git a/src/app/Http/Resources/PromotionResource.php b/src/app/Http/Resources/PromotionResource.php new file mode 100644 index 0000000..5dc259f --- /dev/null +++ b/src/app/Http/Resources/PromotionResource.php @@ -0,0 +1,25 @@ + $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')), + ]; + } +} diff --git a/src/app/Http/Resources/SupervisorResource.php b/src/app/Http/Resources/SupervisorResource.php new file mode 100644 index 0000000..a8a3e27 --- /dev/null +++ b/src/app/Http/Resources/SupervisorResource.php @@ -0,0 +1,24 @@ + $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(), + ]; + } +} diff --git a/src/app/Http/Resources/UserResource.php b/src/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..eb0d616 --- /dev/null +++ b/src/app/Http/Resources/UserResource.php @@ -0,0 +1,26 @@ + $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(), + ]; + } +} diff --git a/src/app/Http/Resources/WalletResource.php b/src/app/Http/Resources/WalletResource.php new file mode 100644 index 0000000..21d6985 --- /dev/null +++ b/src/app/Http/Resources/WalletResource.php @@ -0,0 +1,18 @@ + $this->currency, + 'current_balance' => (float) $this->current_balance, + 'status' => $this->status, + ]; + } +} diff --git a/src/app/Http/Resources/WalletTransactionResource.php b/src/app/Http/Resources/WalletTransactionResource.php new file mode 100644 index 0000000..d8920c7 --- /dev/null +++ b/src/app/Http/Resources/WalletTransactionResource.php @@ -0,0 +1,24 @@ + $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(), + ]; + } +} diff --git a/src/app/Http/Resources/WashResource.php b/src/app/Http/Resources/WashResource.php new file mode 100644 index 0000000..4feb1aa --- /dev/null +++ b/src/app/Http/Resources/WashResource.php @@ -0,0 +1,29 @@ + $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')), + 'progress' => WashCycleProgress::forWash($this->resource), + ]; + } +} diff --git a/src/app/Jobs/CheckNoShowBookings.php b/src/app/Jobs/CheckNoShowBookings.php new file mode 100644 index 0000000..954b030 --- /dev/null +++ b/src/app/Jobs/CheckNoShowBookings.php @@ -0,0 +1,17 @@ +checkNoShows(); + } +} diff --git a/src/app/Jobs/GenerateDailyStats.php b/src/app/Jobs/GenerateDailyStats.php new file mode 100644 index 0000000..76ccce6 --- /dev/null +++ b/src/app/Jobs/GenerateDailyStats.php @@ -0,0 +1,106 @@ +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, + ], + ); + }); + }); + } +} diff --git a/src/app/Jobs/RefreshOfflineMachines.php b/src/app/Jobs/RefreshOfflineMachines.php new file mode 100644 index 0000000..60736a1 --- /dev/null +++ b/src/app/Jobs/RefreshOfflineMachines.php @@ -0,0 +1,71 @@ +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', + ]); + }); + } + } +} diff --git a/src/app/Jobs/SimulateCycleCompletion.php b/src/app/Jobs/SimulateCycleCompletion.php new file mode 100644 index 0000000..74316c0 --- /dev/null +++ b/src/app/Jobs/SimulateCycleCompletion.php @@ -0,0 +1,89 @@ +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']); + } + } + } + }); + } +} diff --git a/src/app/Models/AuditLog.php b/src/app/Models/AuditLog.php new file mode 100644 index 0000000..d29cfff --- /dev/null +++ b/src/app/Models/AuditLog.php @@ -0,0 +1,43 @@ + '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(); + } +} diff --git a/src/app/Models/Booking.php b/src/app/Models/Booking.php new file mode 100644 index 0000000..2ab8fca --- /dev/null +++ b/src/app/Models/Booking.php @@ -0,0 +1,49 @@ + '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(); + } +} diff --git a/src/app/Models/Concerns/BelongsToOrganization.php b/src/app/Models/Concerns/BelongsToOrganization.php new file mode 100644 index 0000000..1a3e6f9 --- /dev/null +++ b/src/app/Models/Concerns/BelongsToOrganization.php @@ -0,0 +1,18 @@ +id : $organization; + + return $query->whereHas('establishment', function (Builder $query) use ($organizationId) { + $query->where('organization_id', $organizationId); + }); + } +} diff --git a/src/app/Models/Concerns/HasUuid.php b/src/app/Models/Concerns/HasUuid.php new file mode 100644 index 0000000..0df96f0 --- /dev/null +++ b/src/app/Models/Concerns/HasUuid.php @@ -0,0 +1,17 @@ +uuid)) { + $model->uuid = (string) Str::uuid(); + } + }); + } +} diff --git a/src/app/Models/DailyEstablishmentStat.php b/src/app/Models/DailyEstablishmentStat.php new file mode 100644 index 0000000..715ddf7 --- /dev/null +++ b/src/app/Models/DailyEstablishmentStat.php @@ -0,0 +1,32 @@ + '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); + } +} diff --git a/src/app/Models/Establishment.php b/src/app/Models/Establishment.php new file mode 100644 index 0000000..8d2f597 --- /dev/null +++ b/src/app/Models/Establishment.php @@ -0,0 +1,39 @@ + '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); + } +} diff --git a/src/app/Models/GdprConsent.php b/src/app/Models/GdprConsent.php new file mode 100644 index 0000000..c5d7322 --- /dev/null +++ b/src/app/Models/GdprConsent.php @@ -0,0 +1,29 @@ + 'string', + 'accepted' => 'boolean', + 'source' => 'string', + 'accepted_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/app/Models/Machine.php b/src/app/Models/Machine.php new file mode 100644 index 0000000..594b007 --- /dev/null +++ b/src/app/Models/Machine.php @@ -0,0 +1,63 @@ + '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); + } +} diff --git a/src/app/Models/MachineCommand.php b/src/app/Models/MachineCommand.php new file mode 100644 index 0000000..eae9a08 --- /dev/null +++ b/src/app/Models/MachineCommand.php @@ -0,0 +1,40 @@ + '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'); + } +} diff --git a/src/app/Models/MachineEvent.php b/src/app/Models/MachineEvent.php new file mode 100644 index 0000000..33c68a1 --- /dev/null +++ b/src/app/Models/MachineEvent.php @@ -0,0 +1,28 @@ + 'string', + 'payload' => 'array', + 'occurred_at' => 'datetime', + 'received_at' => 'datetime', + 'processed_at' => 'datetime', + 'processing_status' => 'string', + ]; + } + + public function machine(): BelongsTo + { + return $this->belongsTo(Machine::class); + } +} diff --git a/src/app/Models/MachineIntegration.php b/src/app/Models/MachineIntegration.php new file mode 100644 index 0000000..2e2248d --- /dev/null +++ b/src/app/Models/MachineIntegration.php @@ -0,0 +1,25 @@ + 'string', + 'config' => 'array', + 'is_active' => 'boolean', + ]; + } + + public function machine(): BelongsTo + { + return $this->belongsTo(Machine::class); + } +} diff --git a/src/app/Models/MachineStatusHistory.php b/src/app/Models/MachineStatusHistory.php new file mode 100644 index 0000000..107c16e --- /dev/null +++ b/src/app/Models/MachineStatusHistory.php @@ -0,0 +1,27 @@ + 'datetime', + ]; + } + + public function machine(): BelongsTo + { + return $this->belongsTo(Machine::class); + } +} diff --git a/src/app/Models/NotificationPreference.php b/src/app/Models/NotificationPreference.php new file mode 100644 index 0000000..8a50fc4 --- /dev/null +++ b/src/app/Models/NotificationPreference.php @@ -0,0 +1,26 @@ + 'boolean', + 'reminder_enabled' => 'boolean', + 'marketing_enabled' => 'boolean', + 'system_enabled' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/app/Models/Organization.php b/src/app/Models/Organization.php new file mode 100644 index 0000000..993511d --- /dev/null +++ b/src/app/Models/Organization.php @@ -0,0 +1,31 @@ + 'boolean', + ]; + } + + public function establishments(): HasMany + { + return $this->hasMany(Establishment::class); + } + + public function supervisors(): HasMany + { + return $this->hasMany(Supervisor::class); + } +} diff --git a/src/app/Models/PaymentTransaction.php b/src/app/Models/PaymentTransaction.php new file mode 100644 index 0000000..65c98dd --- /dev/null +++ b/src/app/Models/PaymentTransaction.php @@ -0,0 +1,28 @@ + 'decimal:2', + 'status' => 'string', + 'raw_payload' => 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/app/Models/PricingRule.php b/src/app/Models/PricingRule.php new file mode 100644 index 0000000..35c73dd --- /dev/null +++ b/src/app/Models/PricingRule.php @@ -0,0 +1,38 @@ + '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); + } +} diff --git a/src/app/Models/Promotion.php b/src/app/Models/Promotion.php new file mode 100644 index 0000000..7e485a0 --- /dev/null +++ b/src/app/Models/Promotion.php @@ -0,0 +1,31 @@ + 'string', + 'discount_type' => 'string', + 'discount_value' => 'decimal:2', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'is_active' => 'boolean', + ]; + } + + public function establishment(): BelongsTo + { + return $this->belongsTo(Establishment::class); + } +} diff --git a/src/app/Models/PushNotification.php b/src/app/Models/PushNotification.php new file mode 100644 index 0000000..74926fe --- /dev/null +++ b/src/app/Models/PushNotification.php @@ -0,0 +1,25 @@ + 'array', + 'status' => 'string', + 'sent_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/app/Models/Supervisor.php b/src/app/Models/Supervisor.php new file mode 100644 index 0000000..bb11bd0 --- /dev/null +++ b/src/app/Models/Supervisor.php @@ -0,0 +1,38 @@ + 'string', + 'is_active' => 'boolean', + 'last_login_at' => 'datetime', + 'password' => 'hashed', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function establishment(): BelongsTo + { + return $this->belongsTo(Establishment::class); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php new file mode 100644 index 0000000..a8cc637 --- /dev/null +++ b/src/app/Models/User.php @@ -0,0 +1,67 @@ + */ + use HasApiTokens, HasFactory, HasUuid, Notifiable, SoftDeletes; + + protected function casts(): array + { + return [ + 'birthdate' => 'date', + 'email_verified_at' => 'datetime', + 'anonymized_at' => 'datetime', + 'is_active' => 'boolean', + 'password' => 'hashed', + ]; + } + + protected function fullName(): Attribute + { + return Attribute::get( + fn (): string => trim("{$this->first_name} {$this->last_name}") + ); + } + + public function wallet(): HasOne + { + return $this->hasOne(Wallet::class); + } + + public function bookings(): HasMany + { + return $this->hasMany(Booking::class); + } + + public function washes(): HasMany + { + return $this->hasMany(Wash::class); + } + + public function gdprConsents(): HasMany + { + return $this->hasMany(GdprConsent::class); + } + + public function notificationPreference(): HasOne + { + return $this->hasOne(NotificationPreference::class); + } +} diff --git a/src/app/Models/UserDevice.php b/src/app/Models/UserDevice.php new file mode 100644 index 0000000..31e96dd --- /dev/null +++ b/src/app/Models/UserDevice.php @@ -0,0 +1,25 @@ + 'string', + 'last_seen_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/app/Models/Wallet.php b/src/app/Models/Wallet.php new file mode 100644 index 0000000..8361ae8 --- /dev/null +++ b/src/app/Models/Wallet.php @@ -0,0 +1,35 @@ + 'decimal:2', + 'status' => 'string', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function walletTransactions(): HasMany + { + return $this->hasMany(WalletTransaction::class); + } + + public function isActive(): bool + { + return $this->status === 'active'; + } +} diff --git a/src/app/Models/WalletTransaction.php b/src/app/Models/WalletTransaction.php new file mode 100644 index 0000000..b6ea640 --- /dev/null +++ b/src/app/Models/WalletTransaction.php @@ -0,0 +1,39 @@ + 'string', + 'amount' => 'decimal:2', + 'balance_before' => 'decimal:2', + 'balance_after' => 'decimal:2', + 'metadata' => 'array', + 'created_at' => 'datetime', + ]; + } + + public function wallet(): BelongsTo + { + return $this->belongsTo(Wallet::class); + } + + public function source(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/src/app/Models/Wash.php b/src/app/Models/Wash.php new file mode 100644 index 0000000..cd17c15 --- /dev/null +++ b/src/app/Models/Wash.php @@ -0,0 +1,46 @@ + 'string', + 'status' => 'string', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'duration_minutes' => 'integer', + 'cost' => 'decimal:2', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function machine(): BelongsTo + { + return $this->belongsTo(Machine::class); + } + + public function booking(): BelongsTo + { + return $this->belongsTo(Booking::class); + } + + public function machineCommand(): BelongsTo + { + return $this->belongsTo(MachineCommand::class); + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..8868978 --- /dev/null +++ b/src/app/Providers/AppServiceProvider.php @@ -0,0 +1,26 @@ +app->singleton(SimulatedMachineProvider::class); + $this->app->singleton(MachineIntegrationService::class); + + $this->app->bind(MachineProviderInterface::class, SimulatedMachineProvider::class); + + $this->app->singleton(WalletService::class); + $this->app->singleton(PricingService::class); + $this->app->singleton(AuditService::class); + $this->app->singleton(BookingService::class); + $this->app->singleton(WashService::class); + $this->app->singleton(StripePaymentService::class); + $this->app->singleton(PaymentService::class); + } + + public function boot(): void + { + // + } +} diff --git a/src/app/Services/AuditService.php b/src/app/Services/AuditService.php new file mode 100644 index 0000000..aa58818 --- /dev/null +++ b/src/app/Services/AuditService.php @@ -0,0 +1,49 @@ +resolveTarget($target); + + return AuditLog::query()->create([ + 'actor_type' => $actor->getMorphClass(), + 'actor_id' => $actor->getKey(), + 'organization_id' => $organizationId, + 'establishment_id' => $establishmentId, + 'action' => $action, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'before_data' => $before, + 'after_data' => $after, + 'ip_address' => $ip, + 'created_at' => Carbon::now(), + ]); + } + + /** + * @return array{0: string, 1: int|null} + */ + private function resolveTarget(Model|string $target): array + { + if ($target instanceof Model) { + return [$target->getMorphClass(), $target->getKey()]; + } + + return [$target, null]; + } +} diff --git a/src/app/Services/BookingService.php b/src/app/Services/BookingService.php new file mode 100644 index 0000000..d0128ee --- /dev/null +++ b/src/app/Services/BookingService.php @@ -0,0 +1,238 @@ +lte($slotStart)) { + throw new \InvalidArgumentException('La fin du créneau doit être postérieure au début.'); + } + + $lockKey = sprintf( + 'booking:machine:%d:%s', + $machine->id, + $slotStart->toIso8601String(), + ); + + return Cache::lock($lockKey, 10)->block(5, function () use ($user, $machine, $slotStart, $slotEnd, $idempotencyKey) { + return DB::transaction(function () use ($user, $machine, $slotStart, $slotEnd, $idempotencyKey) { + if ($idempotencyKey !== null) { + $existing = Booking::query() + ->where('user_id', $user->id) + ->where('machine_id', $machine->id) + ->where('slot_start', $slotStart) + ->where('status', '!=', 'cancelled') + ->first(); + + if ($existing !== null) { + return $existing; + } + } + + $conflict = Booking::query() + ->where('machine_id', $machine->id) + ->whereIn('status', ['pending', 'confirmed', 'active']) + ->where('slot_start', '<', $slotEnd) + ->where('slot_end', '>', $slotStart) + ->lockForUpdate() + ->exists(); + + if ($conflict) { + throw new BookingConflictException(); + } + + $washPrice = $this->pricingService->getActivePrice($machine, $slotStart); + $bookingFee = (float) config('laverie.booking.fee', 1.00); + $totalAmount = round($washPrice + $bookingFee, 2); + + $this->walletService->debit( + $user, + $totalAmount, + Booking::class, + null, + $idempotencyKey ? "booking-debit:{$idempotencyKey}" : null, + ['machine_id' => $machine->id, 'slot_start' => $slotStart->toIso8601String()], + ); + + $booking = Booking::query()->create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'machine_id' => $machine->id, + 'slot_start' => $slotStart, + 'slot_end' => $slotEnd, + 'booking_fee' => $bookingFee, + 'reserved_amount' => $washPrice, + 'status' => 'confirmed', + ]); + + if ($machine->status === 'available' && $slotStart->lte(now()) && $slotEnd->gt(now())) { + $machine->update(['status' => 'reserved']); + } + + $this->auditService->log( + $user, + 'booking.created', + $booking, + null, + $booking->toArray(), + $machine->establishment?->organization_id, + $machine->establishment_id, + ); + + return $booking; + }); + }); + } + + public function cancelBooking(Booking $booking, User $user): Booking + { + if ($booking->user_id !== $user->id) { + throw new \RuntimeException('Réservation non autorisée.'); + } + + if (! $booking->isCancellable()) { + throw new \RuntimeException('Cette réservation ne peut pas être annulée.'); + } + + return DB::transaction(function () use ($booking, $user) { + $booking = Booking::query()->whereKey($booking->id)->lockForUpdate()->firstOrFail(); + + $graceHours = (int) config('laverie.booking.cancellation_grace_hours', 2); + $withinGrace = $booking->slot_start->greaterThan(now()->addHours($graceHours)); + + $penaltyAmount = 0.0; + $refundAmount = (float) $booking->reserved_amount + (float) $booking->booking_fee; + + if (! $withinGrace) { + $penaltyAmount = (float) config('laverie.booking.penalty_amount', 3.00); + $refundAmount = max(0, $refundAmount - $penaltyAmount); + } + + if ($refundAmount > 0) { + $this->walletService->credit( + $user, + $refundAmount, + Booking::class, + $booking->id, + "booking-refund:{$booking->uuid}", + ['reason' => $withinGrace ? 'cancellation_full' : 'cancellation_partial'], + ); + } + + $booking->update([ + 'status' => 'cancelled', + 'cancelled_at' => now(), + 'penalty_amount' => $penaltyAmount, + 'penalty_applied_at' => $penaltyAmount > 0 ? now() : null, + ]); + + $machine = $booking->machine; + if ($machine !== null && $machine->status === 'reserved') { + $machine->update(['status' => 'available']); + } + + $this->auditService->log($user, 'booking.cancelled', $booking, null, $booking->fresh()->toArray()); + + return $booking->fresh(); + }); + } + + public function moveBooking(Booking $booking, User $user, Carbon $newSlotStart, Carbon $newSlotEnd): Booking + { + if ($booking->user_id !== $user->id) { + throw new \RuntimeException('Réservation non autorisée.'); + } + + if (! in_array($booking->status, ['confirmed', 'pending'], true)) { + throw new \RuntimeException('Cette réservation ne peut pas être déplacée.'); + } + + return DB::transaction(function () use ($booking, $user, $newSlotStart, $newSlotEnd) { + $machine = Machine::query()->findOrFail($booking->machine_id); + + $this->cancelBooking($booking, $user); + + return $this->createBooking( + $user, + $machine, + $newSlotStart, + $newSlotEnd, + "booking-move:{$booking->uuid}:{$newSlotStart->timestamp}", + ); + }); + } + + public function checkNoShows(): int + { + $graceMinutes = (int) config('laverie.booking.no_show_grace_minutes', 15); + $cutoff = now()->subMinutes($graceMinutes); + $processed = 0; + + $candidates = Booking::query() + ->with(['user', 'machine', 'washes']) + ->where('status', 'confirmed') + ->where('slot_start', '<=', $cutoff) + ->get(); + + foreach ($candidates as $booking) { + if ($booking->washes->isNotEmpty()) { + continue; + } + + DB::transaction(function () use ($booking, &$processed) { + $locked = Booking::query()->whereKey($booking->id)->lockForUpdate()->firstOrFail(); + + if ($locked->status !== 'confirmed' || $locked->washes()->exists()) { + return; + } + + $penaltyAmount = (float) config('laverie.booking.penalty_amount', 3.00); + + $locked->update([ + 'status' => 'no_show', + 'penalty_amount' => $penaltyAmount, + 'penalty_applied_at' => now(), + ]); + + $machine = $locked->machine; + if ($machine !== null && $machine->status === 'reserved') { + $machine->update(['status' => 'available']); + } + + $this->auditService->log( + $locked->user, + 'booking.no_show', + $locked, + null, + $locked->fresh()->toArray(), + ); + + $processed++; + }); + } + + return $processed; + } +} diff --git a/src/app/Services/DashboardService.php b/src/app/Services/DashboardService.php new file mode 100644 index 0000000..0fb40b3 --- /dev/null +++ b/src/app/Services/DashboardService.php @@ -0,0 +1,288 @@ + + */ + public function getKpis(Supervisor $supervisor, ?int $establishmentFilter = null): array + { + $offlineThresholdMinutes = (int) config('laverie.machine.offline_threshold_minutes', 10); + $today = Carbon::today(); + + $washQuery = $this->washQuery($supervisor, $establishmentFilter)->whereDate('started_at', $today); + $bookingQuery = $this->bookingQuery($supervisor, $establishmentFilter)->whereDate('slot_start', $today); + $machineQuery = $this->machineQuery($supervisor, $establishmentFilter); + + $offlineThreshold = now()->subMinutes($offlineThresholdMinutes); + + $offlineCount = (clone $machineQuery) + ->where(function ($query) use ($offlineThreshold) { + $query->where('status', 'offline') + ->orWhere(function ($query) use ($offlineThreshold) { + $query->where('last_heartbeat_at', '<', $offlineThreshold) + ->orWhereNull('last_heartbeat_at'); + }); + }) + ->count(); + + $machinesTotal = (clone $machineQuery)->count(); + + return [ + 'revenue_today' => (float) (clone $washQuery) + ->where('status', 'completed') + ->sum('cost'), + 'washes_today' => (clone $washQuery)->count(), + 'bookings_today' => (clone $bookingQuery)->count(), + 'offline_machines' => $offlineCount, + 'machines_total' => $machinesTotal, + 'running_machines' => (clone $machineQuery)->where('status', 'running')->count(), + 'available_machines' => (clone $machineQuery)->where('status', 'available')->count(), + 'active_bookings' => $this->bookingQuery($supervisor, $establishmentFilter) + ->whereIn('status', ['confirmed', 'active']) + ->where('slot_end', '>=', now()) + ->count(), + ]; + } + + /** + * @return array + */ + public function getMachineStatusBreakdown(Supervisor $supervisor, ?int $establishmentFilter = null): array + { + $statuses = ['available', 'reserved', 'running', 'maintenance', 'offline', 'error']; + $counts = $this->machineQuery($supervisor, $establishmentFilter) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status'); + + $breakdown = []; + foreach ($statuses as $status) { + $breakdown[$status] = (int) ($counts[$status] ?? 0); + } + + return $breakdown; + } + + /** + * @return Collection> + */ + public function getRecentWashes(Supervisor $supervisor, ?int $establishmentFilter = null, int $limit = 5): Collection + { + return $this->washQuery($supervisor, $establishmentFilter) + ->with(['machine:id,name,uuid', 'machine.establishment:id,name']) + ->orderByDesc('started_at') + ->limit($limit) + ->get() + ->map(fn (Wash $wash) => [ + 'uuid' => $wash->uuid, + 'machine_name' => $wash->machine?->name ?? '—', + 'establishment_name' => $wash->machine?->establishment?->name, + 'status' => $wash->status, + 'status_label' => $this->washStatusLabel($wash->status), + 'cost' => (float) $wash->cost, + 'started_at' => $wash->started_at?->toIso8601String(), + ]); + } + + /** + * @return array + */ + public function getContext(Supervisor $supervisor, ?int $establishmentFilter = null): array + { + $supervisor->loadMissing(['organization:id,name', 'establishment:id,name']); + + $establishmentName = $supervisor->establishment?->name; + + if ($establishmentName === null && $establishmentFilter !== null) { + $establishmentName = Establishment::query() + ->where('id', $establishmentFilter) + ->where('organization_id', $supervisor->organization_id) + ->value('name'); + } + + return [ + 'organization_name' => $supervisor->organization?->name, + 'establishment_name' => $establishmentName, + 'is_all_establishments' => $supervisor->establishment_id === null && $establishmentFilter === null, + ]; + } + + /** + * @return Collection> + */ + public function getAlerts(Supervisor $supervisor, ?int $establishmentFilter = null, int $limit = 10): Collection + { + $machineIds = $this->machineQuery($supervisor, $establishmentFilter)->pluck('id'); + + if ($machineIds->isEmpty()) { + return collect(); + } + + $errorMachines = Machine::query() + ->whereIn('id', $machineIds) + ->whereIn('status', ['error', 'maintenance', 'offline']) + ->with('establishment:id,name') + ->limit($limit) + ->get() + ->map(fn (Machine $machine) => [ + 'type' => 'machine_status', + 'severity' => $machine->status === 'error' ? 'high' : 'medium', + 'message' => sprintf( + '%s (%s) — %s', + $machine->name, + $machine->establishment?->name ?? '—', + $this->machineStatusLabel($machine->status), + ), + 'occurred_at' => $machine->updated_at?->toIso8601String(), + ]); + + $recentEvents = MachineEvent::query() + ->whereIn('machine_id', $machineIds) + ->whereIn('event_type', ['error_reported', 'machine_offline', 'cycle_failed']) + ->with('machine:id,name,establishment_id', 'machine.establishment:id,name') + ->orderByDesc('occurred_at') + ->limit($limit) + ->get() + ->map(fn (MachineEvent $event) => [ + 'type' => 'machine_event', + 'severity' => $event->event_type === 'error_reported' ? 'high' : 'medium', + 'message' => sprintf( + '%s — %s', + $event->machine?->name ?? 'Machine', + $this->eventTypeLabel($event->event_type), + ), + 'occurred_at' => $event->occurred_at?->toIso8601String(), + ]); + + return $errorMachines + ->concat($recentEvents) + ->sortByDesc('occurred_at') + ->take($limit) + ->values(); + } + + /** + * @return Collection> + */ + public function getMachinesOverview(Supervisor $supervisor, ?int $establishmentFilter = null): Collection + { + return $this->machineQuery($supervisor, $establishmentFilter) + ->with('establishment:id,name') + ->orderBy('name') + ->limit(12) + ->get() + ->map(fn (Machine $machine) => [ + 'uuid' => $machine->uuid, + 'name' => $machine->name, + 'type' => $machine->type, + 'type_label' => $this->machineTypeLabel($machine->type), + 'status' => $machine->status, + 'status_label' => $this->machineStatusLabel($machine->status), + 'establishment_name' => $machine->establishment?->name, + 'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(), + ]); + } + + private function machineQuery(Supervisor $supervisor, ?int $establishmentFilter = null) + { + $establishmentId = $supervisor->establishment_id ?? $establishmentFilter; + + $query = Machine::query()->whereHas('establishment', function ($query) use ($supervisor, $establishmentId) { + $query->where('organization_id', $supervisor->organization_id); + + if ($establishmentId !== null) { + $query->where('id', $establishmentId); + } + }); + + if ($establishmentId !== null) { + $query->where('establishment_id', $establishmentId); + } + + return $query; + } + + private function washQuery(Supervisor $supervisor, ?int $establishmentFilter = null) + { + $establishmentId = $supervisor->establishment_id ?? $establishmentFilter; + + return Wash::query()->whereHas('machine.establishment', function ($query) use ($supervisor, $establishmentId) { + $query->where('organization_id', $supervisor->organization_id); + + if ($establishmentId !== null) { + $query->where('id', $establishmentId); + } + }); + } + + private function bookingQuery(Supervisor $supervisor, ?int $establishmentFilter = null) + { + $establishmentId = $supervisor->establishment_id ?? $establishmentFilter; + + return Booking::query()->whereHas('machine.establishment', function ($query) use ($supervisor, $establishmentId) { + $query->where('organization_id', $supervisor->organization_id); + + if ($establishmentId !== null) { + $query->where('id', $establishmentId); + } + }); + } + + private function machineStatusLabel(string $status): string + { + return match ($status) { + 'available' => 'Disponible', + 'reserved' => 'Réservée', + 'running' => 'En cours', + 'maintenance' => 'Maintenance', + 'offline' => 'Hors ligne', + 'error' => 'Erreur', + default => $status, + }; + } + + private function machineTypeLabel(string $type): string + { + return match ($type) { + 'washer_small' => 'Lave-linge petit', + 'washer_large' => 'Lave-linge grand', + 'dryer_small' => 'Sèche-linge petit', + 'dryer_large' => 'Sèche-linge grand', + default => $type, + }; + } + + private function eventTypeLabel(string $type): string + { + return match ($type) { + 'error_reported' => 'Erreur signalée', + 'machine_offline' => 'Machine hors ligne', + 'cycle_failed' => 'Cycle échoué', + default => $type, + }; + } + + private function washStatusLabel(string $status): string + { + return match ($status) { + 'pending_start' => 'En attente', + 'running' => 'En cours', + 'completed' => 'Terminé', + 'cancelled' => 'Annulé', + 'failed' => 'Échoué', + default => $status, + }; + } +} diff --git a/src/app/Services/GeocodingService.php b/src/app/Services/GeocodingService.php new file mode 100644 index 0000000..63debd6 --- /dev/null +++ b/src/app/Services/GeocodingService.php @@ -0,0 +1,112 @@ +> + */ + public function search(string $query): array + { + $query = trim($query); + + if (strlen($query) < 3) { + return []; + } + + try { + $response = $this->client()->get(config('services.nominatim.url').'/search', [ + 'q' => $query, + 'format' => 'json', + 'addressdetails' => 1, + 'limit' => 5, + 'countrycodes' => 'fr', + ]); + } catch (ConnectionException|RequestException $exception) { + Log::warning('Geocoding search failed.', [ + 'query' => $query, + 'message' => $exception->getMessage(), + ]); + + return []; + } + + if (! $response->successful()) { + return []; + } + + return collect($response->json()) + ->map(fn (array $item) => $this->formatResult($item)) + ->filter(fn (array $item) => $item['address'] !== '') + ->values() + ->all(); + } + + /** + * @return array{latitude: float, longitude: float}|null + */ + public function geocode(string $address, ?string $city = null, ?string $zipCode = null): ?array + { + $query = collect([$address, $zipCode, $city, 'France']) + ->filter(fn (?string $part) => $part !== null && trim($part) !== '') + ->implode(', '); + + $results = $this->search($query); + + if ($results === [] || $results[0]['latitude'] === null || $results[0]['longitude'] === null) { + return null; + } + + return [ + 'latitude' => $results[0]['latitude'], + 'longitude' => $results[0]['longitude'], + ]; + } + + private function client(): PendingRequest + { + return Http::withHeaders([ + 'User-Agent' => config('services.nominatim.user_agent'), + 'Accept-Language' => 'fr', + ]) + ->withOptions([ + 'verify' => (bool) config('services.nominatim.verify_ssl', true), + ]) + ->timeout(8) + ->connectTimeout(4); + } + + /** + * @param array $item + * @return array + */ + private function formatResult(array $item): array + { + $addressParts = $item['address'] ?? []; + $street = trim(($addressParts['house_number'] ?? '').' '.($addressParts['road'] ?? '')); + + if ($street === '') { + $street = trim((string) ($item['name'] ?? '')); + } + + return [ + 'label' => (string) ($item['display_name'] ?? $street), + 'address' => $street, + 'city' => $addressParts['city'] + ?? $addressParts['town'] + ?? $addressParts['village'] + ?? $addressParts['municipality'] + ?? '', + 'zip_code' => (string) ($addressParts['postcode'] ?? ''), + 'latitude' => isset($item['lat']) ? (float) $item['lat'] : null, + 'longitude' => isset($item['lon']) ? (float) $item['lon'] : null, + ]; + } +} diff --git a/src/app/Services/MachineIntegrationService.php b/src/app/Services/MachineIntegrationService.php new file mode 100644 index 0000000..84230ab --- /dev/null +++ b/src/app/Services/MachineIntegrationService.php @@ -0,0 +1,83 @@ +> */ + private array $providerMap; + + public function __construct( + private readonly SimulatedMachineProvider $simulatedProvider, + ) { + $this->providerMap = [ + config('laverie.simulation.provider_name', 'simulated') => SimulatedMachineProvider::class, + 'simulated' => SimulatedMachineProvider::class, + ]; + } + + public function startCycle(Machine $machine, array $payload = [], ?User $user = null): MachineCommandResult + { + if ($user !== null) { + $payload['requested_by_user_id'] = $user->id; + } + + return $this->resolveProvider($machine)->startCycle($machine, $payload); + } + + public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult + { + return $this->resolveProvider($machine)->stopCycle($machine, $payload); + } + + public function refreshStatus(Machine $machine): MachineStatusSnapshot + { + return $this->resolveProvider($machine)->refreshStatus($machine); + } + + public function handleInboundEvent(string $provider, array $payload): void + { + $this->resolveProviderByName($provider)->handleInboundEvent($payload); + } + + private function resolveProvider(Machine $machine): MachineProviderInterface + { + $integration = $machine->relationLoaded('integration') + ? $machine->integration + : $machine->integration()->first(); + + if ($integration === null) { + $integration = MachineIntegration::query() + ->where('machine_id', $machine->id) + ->where('is_active', true) + ->first(); + } + + $providerName = $integration?->provider ?? config('laverie.simulation.provider_name', 'simulated'); + + return $this->resolveProviderByName($providerName); + } + + private function resolveProviderByName(string $providerName): MachineProviderInterface + { + $class = $this->providerMap[$providerName] ?? null; + + if ($class === null) { + throw new InvalidArgumentException("Fournisseur machine inconnu : {$providerName}"); + } + + return match ($class) { + SimulatedMachineProvider::class => $this->simulatedProvider, + default => app($class), + }; + } +} diff --git a/src/app/Services/PaymentService.php b/src/app/Services/PaymentService.php new file mode 100644 index 0000000..07cde2f --- /dev/null +++ b/src/app/Services/PaymentService.php @@ -0,0 +1,250 @@ + $maxAmount) { + throw new \InvalidArgumentException( + "Le montant doit être compris entre {$minAmount} et {$maxAmount} EUR.", + ); + } + + $existing = PaymentTransaction::query() + ->where('idempotency_key', $idempotencyKey) + ->first(); + + if ($existing !== null) { + return $existing; + } + + $provider = config('laverie.payment.default_provider', 'simulated'); + + if ($provider === 'stripe' && ! $this->stripePaymentService->isEnabled()) { + throw new \RuntimeException('Stripe n\'est pas configuré.'); + } + + return DB::transaction(function () use ($user, $amount, $idempotencyKey, $returnUrl, $provider) { + $payment = PaymentTransaction::query()->create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'provider' => $provider, + 'provider_payment_id' => null, + 'amount' => $amount, + 'currency' => 'EUR', + 'status' => 'initiated', + 'idempotency_key' => $idempotencyKey, + 'return_url' => $returnUrl, + ]); + + if ($provider === 'stripe') { + try { + $intent = $this->stripePaymentService->createPaymentIntent($user, $payment, $amount); + } catch (ApiErrorException $e) { + throw new \RuntimeException('Impossible de créer le paiement Stripe.', 0, $e); + } + + $payment->update([ + 'provider_payment_id' => $intent->id, + 'status' => 'pending', + 'raw_payload' => [ + 'client_secret' => $intent->client_secret, + ], + ]); + } else { + $payment->update([ + 'provider_payment_id' => 'sim-'.Str::random(16), + ]); + } + + $this->auditService->log($user, 'payment.initiated', $payment, null, $payment->fresh()->toArray()); + + return $payment->fresh(); + }); + } + + public function confirmTopUp(string $paymentUuid): PaymentTransaction + { + return DB::transaction(function () use ($paymentUuid) { + $payment = PaymentTransaction::query() + ->where('uuid', $paymentUuid) + ->lockForUpdate() + ->firstOrFail(); + + if ($payment->status === 'succeeded') { + return $payment; + } + + if (! in_array($payment->status, ['initiated', 'pending'], true)) { + throw new \RuntimeException("Paiement non confirmable (statut : {$payment->status})."); + } + + if ($payment->provider === 'stripe') { + if (blank($payment->provider_payment_id)) { + throw new \RuntimeException('Paiement Stripe introuvable.'); + } + + try { + $intent = $this->stripePaymentService->retrievePaymentIntent($payment->provider_payment_id); + } catch (ApiErrorException $e) { + throw new \RuntimeException('Impossible de vérifier le paiement Stripe.', 0, $e); + } + + if (! $this->stripePaymentService->isPaymentSucceeded($intent)) { + throw new \RuntimeException('Le paiement Stripe n\'est pas encore confirmé.'); + } + + $payment->update([ + 'raw_payload' => array_merge($payment->raw_payload ?? [], [ + 'stripe' => $this->stripePaymentService->summarizePaymentIntent($intent), + ]), + ]); + } + + return $this->markPaymentSucceeded($payment->fresh()); + }); + } + + public function handleWebhook(string $provider, array $payload): void + { + $providerPaymentId = $payload['provider_payment_id'] ?? $payload['payment_id'] ?? null; + $idempotencyKey = $payload['idempotency_key'] ?? null; + $status = $payload['status'] ?? 'succeeded'; + + if ($providerPaymentId === null && $idempotencyKey === null) { + throw new \InvalidArgumentException('Webhook paiement invalide.'); + } + + if ($status === 'ignored') { + return; + } + + DB::transaction(function () use ($provider, $payload, $providerPaymentId, $idempotencyKey, $status) { + $query = PaymentTransaction::query()->lockForUpdate(); + + if ($idempotencyKey !== null) { + $payment = $query->where('idempotency_key', $idempotencyKey)->first(); + } else { + $payment = $query + ->where('provider', $provider) + ->where('provider_payment_id', $providerPaymentId) + ->first(); + } + + if ($payment === null) { + return; + } + + if ($payment->status === 'succeeded') { + return; + } + + $payment->update([ + 'raw_payload' => array_merge($payment->raw_payload ?? [], [ + 'webhook' => $payload, + ]), + ]); + + if ($status === 'succeeded') { + $this->markPaymentSucceeded($payment); + } elseif (in_array($status, ['failed', 'cancelled', 'refunded'], true)) { + $payment->update(['status' => $status]); + } + }); + } + + public function handleStripeWebhook(string $payload, string $signature): void + { + $parsed = $this->stripePaymentService->parseWebhook($payload, $signature); + + if ($parsed['status'] === 'ignored') { + return; + } + + $this->handleWebhook('stripe', [ + 'provider_payment_id' => $parsed['provider_payment_id'], + 'status' => $parsed['status'], + 'payload' => $parsed['payload'], + ]); + } + + private function markPaymentSucceeded(PaymentTransaction $payment): PaymentTransaction + { + if ($payment->status === 'succeeded') { + return $payment; + } + + $payment->update(['status' => 'succeeded']); + + $this->walletService->credit( + $payment->user, + (float) $payment->amount, + PaymentTransaction::class, + $payment->id, + "payment-credit:{$payment->uuid}", + $this->buildWalletCreditMetadata($payment), + ); + + $this->auditService->log( + $payment->user, + 'payment.confirmed', + $payment, + null, + $payment->fresh()->toArray(), + ); + + return $payment->fresh(); + } + + /** + * @return array + */ + private function buildWalletCreditMetadata(PaymentTransaction $payment): array + { + $metadata = [ + 'operation' => 'top_up', + 'provider' => $payment->provider, + 'label' => match ($payment->provider) { + 'stripe' => 'Rechargement par carte', + 'simulated' => 'Rechargement (simulation)', + default => 'Rechargement portefeuille', + }, + 'payment_uuid' => $payment->uuid, + 'provider_payment_id' => $payment->provider_payment_id, + 'currency' => $payment->currency, + 'amount' => (float) $payment->amount, + ]; + + $raw = $payment->raw_payload ?? []; + + if ($payment->provider === 'stripe') { + $metadata['stripe'] = $raw['stripe'] ?? [ + 'payment_intent_id' => $payment->provider_payment_id, + 'status' => 'succeeded', + ]; + } + + return $metadata; + } +} diff --git a/src/app/Services/PricingService.php b/src/app/Services/PricingService.php new file mode 100644 index 0000000..f8de2a7 --- /dev/null +++ b/src/app/Services/PricingService.php @@ -0,0 +1,95 @@ +establishment; + + if ($establishment === null) { + $machine->loadMissing('establishment'); + $establishment = $machine->establishment; + } + + if ($establishment === null) { + throw new \RuntimeException('Établissement introuvable pour la machine.'); + } + + $dayType = $this->resolveDayType($at); + $time = $at->format('H:i:s'); + + $rules = PricingRule::query() + ->where('establishment_id', $establishment->id) + ->where('is_active', true) + ->where(function ($query) use ($machine) { + $query->whereNull('machine_id') + ->orWhere('machine_id', $machine->id); + }) + ->where(function ($query) use ($machine) { + $query->whereNull('machine_type') + ->orWhere('machine_type', $machine->type); + }) + ->where(function ($query) use ($dayType) { + $query->where('day_type', 'all') + ->orWhere('day_type', $dayType); + }) + ->where('slot_start', '<=', $time) + ->where('slot_end', '>', $time) + ->orderBy('priority') + ->orderByRaw('CASE WHEN machine_id IS NOT NULL THEN 0 WHEN machine_type IS NOT NULL THEN 1 ELSE 2 END') + ->get(); + + $rule = $rules->first(); + + if ($rule === null) { + throw new \RuntimeException('Aucune règle tarifaire active pour cette machine.'); + } + + $basePrice = (float) $rule->price; + + return $this->applyPromotion($establishment->id, $machine->type, $at, $basePrice); + } + + private function resolveDayType(CarbonInterface $at): string + { + if ($at->isWeekend()) { + return 'weekend'; + } + + return 'weekday'; + } + + private function applyPromotion(int $establishmentId, string $machineType, CarbonInterface $at, float $basePrice): float + { + $promotion = Promotion::query() + ->where('establishment_id', $establishmentId) + ->where('is_active', true) + ->where('starts_at', '<=', $at) + ->where('ends_at', '>=', $at) + ->where(function ($query) use ($machineType) { + $query->where('machine_type', 'all') + ->orWhere('machine_type', $machineType); + }) + ->orderByDesc('discount_value') + ->first(); + + if ($promotion === null) { + return round($basePrice, 2); + } + + $discount = $promotion->discount_type === 'percent' + ? $basePrice * ((float) $promotion->discount_value / 100) + : (float) $promotion->discount_value; + + return round(max(0, $basePrice - $discount), 2); + } +} diff --git a/src/app/Services/StripePaymentService.php b/src/app/Services/StripePaymentService.php new file mode 100644 index 0000000..6bb7762 --- /dev/null +++ b/src/app/Services/StripePaymentService.php @@ -0,0 +1,151 @@ +configured) { + return; + } + + if (! class_exists(Stripe::class)) { + throw new \RuntimeException( + 'SDK Stripe manquant. Exécutez : composer require stripe/stripe-php', + ); + } + + Stripe::setApiKey(config('services.stripe.secret')); + $this->configured = true; + } + + public function isEnabled(): bool + { + return class_exists(Stripe::class) + && filled(config('services.stripe.secret')) + && filled(config('services.stripe.key')); + } + + public function publishableKey(): string + { + return (string) config('services.stripe.key'); + } + + public function createPaymentIntent( + User $user, + PaymentTransaction $payment, + float $amount, + ): PaymentIntent { + $this->ensureConfigured(); + + return PaymentIntent::create([ + 'amount' => (int) round($amount * 100), + 'currency' => strtolower($payment->currency), + 'metadata' => [ + 'payment_uuid' => $payment->uuid, + 'user_id' => (string) $user->id, + 'idempotency_key' => $payment->idempotency_key, + ], + ]); + } + + public function retrievePaymentIntent(string $paymentIntentId): PaymentIntent + { + $this->ensureConfigured(); + + return PaymentIntent::retrieve($paymentIntentId, [ + 'expand' => ['payment_method'], + ]); + } + + /** + * @return array + */ + public function summarizePaymentIntent(PaymentIntent $intent): array + { + $summary = [ + 'payment_intent_id' => $intent->id, + 'status' => $intent->status, + 'amount' => $intent->amount / 100, + 'currency' => strtoupper($intent->currency), + ]; + + $paymentMethod = $intent->payment_method; + if (is_object($paymentMethod)) { + $summary['payment_method_type'] = $paymentMethod->type ?? null; + + if (isset($paymentMethod->card)) { + $summary['card_brand'] = $paymentMethod->card->brand ?? null; + $summary['card_last4'] = $paymentMethod->card->last4 ?? null; + $summary['card_exp_month'] = $paymentMethod->card->exp_month ?? null; + $summary['card_exp_year'] = $paymentMethod->card->exp_year ?? null; + } + } + + return $summary; + } + + public function isPaymentSucceeded(PaymentIntent $paymentIntent): bool + { + return $paymentIntent->status === 'succeeded'; + } + + /** + * @return array{provider_payment_id: string, status: string, payload: array} + */ + public function parseWebhook(string $payload, string $signature): array + { + $this->ensureConfigured(); + + $secret = config('services.stripe.webhook_secret'); + + if (blank($secret)) { + throw new \RuntimeException('Webhook Stripe non configuré.'); + } + + try { + $event = Webhook::constructEvent($payload, $signature, $secret); + } catch (UnexpectedValueException|SignatureVerificationException $e) { + throw new \InvalidArgumentException('Signature webhook Stripe invalide.', 0, $e); + } + + $type = $event->type; + $object = $event->data->object; + + if (! in_array($type, [ + 'payment_intent.succeeded', + 'payment_intent.payment_failed', + 'payment_intent.canceled', + ], true)) { + return [ + 'provider_payment_id' => $object->id ?? '', + 'status' => 'ignored', + 'payload' => $event->toArray(), + ]; + } + + $status = match ($type) { + 'payment_intent.succeeded' => 'succeeded', + 'payment_intent.payment_failed' => 'failed', + 'payment_intent.canceled' => 'cancelled', + default => 'ignored', + }; + + return [ + 'provider_payment_id' => $object->id, + 'status' => $status, + 'payload' => $event->toArray(), + ]; + } +} diff --git a/src/app/Services/WalletService.php b/src/app/Services/WalletService.php new file mode 100644 index 0000000..0080acb --- /dev/null +++ b/src/app/Services/WalletService.php @@ -0,0 +1,126 @@ +findOrCreateWallet($user); + + return (float) $wallet->current_balance; + } + + public function credit( + User $user, + float $amount, + string $sourceType, + ?int $sourceId = null, + ?string $idempotencyKey = null, + ?array $metadata = null, + ): WalletTransaction { + return $this->applyMovement($user, 'credit', $amount, $sourceType, $sourceId, $idempotencyKey, $metadata); + } + + public function debit( + User $user, + float $amount, + string $sourceType, + ?int $sourceId = null, + ?string $idempotencyKey = null, + ?array $metadata = null, + ): WalletTransaction { + return $this->applyMovement($user, 'debit', $amount, $sourceType, $sourceId, $idempotencyKey, $metadata); + } + + private function applyMovement( + User $user, + string $type, + float $amount, + string $sourceType, + ?int $sourceId, + ?string $idempotencyKey, + ?array $metadata, + ): WalletTransaction { + if ($amount <= 0) { + throw new \InvalidArgumentException('Le montant doit être strictement positif.'); + } + + return DB::transaction(function () use ($user, $type, $amount, $sourceType, $sourceId, $idempotencyKey, $metadata) { + if ($idempotencyKey !== null) { + $existing = WalletTransaction::query() + ->where('idempotency_key', $idempotencyKey) + ->first(); + + if ($existing !== null) { + return $existing; + } + } + + $wallet = Wallet::query() + ->where('user_id', $user->id) + ->lockForUpdate() + ->first(); + + if ($wallet === null) { + $wallet = $this->createWallet($user); + $wallet = Wallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); + } + + if (! $wallet->isActive()) { + throw new \RuntimeException('Le porte-monnaie n\'est pas actif.'); + } + + $balanceBefore = (float) $wallet->current_balance; + + if ($type === 'debit' && $balanceBefore < $amount) { + throw new WalletInsufficientFundsException($amount, $balanceBefore); + } + + $balanceAfter = $type === 'credit' + ? round($balanceBefore + $amount, 2) + : round($balanceBefore - $amount, 2); + + $wallet->update(['current_balance' => $balanceAfter]); + + return WalletTransaction::query()->create([ + 'uuid' => (string) Str::uuid(), + 'wallet_id' => $wallet->id, + 'type' => $type, + 'amount' => $amount, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceAfter, + 'source_type' => $sourceType, + 'source_id' => $sourceId, + 'idempotency_key' => $idempotencyKey, + 'metadata' => $metadata, + 'created_at' => now(), + ]); + }); + } + + private function findOrCreateWallet(User $user): Wallet + { + return Wallet::query()->firstOrCreate( + ['user_id' => $user->id], + ['currency' => 'EUR', 'current_balance' => 0, 'status' => 'active'], + ); + } + + private function createWallet(User $user): Wallet + { + return Wallet::query()->create([ + 'user_id' => $user->id, + 'currency' => 'EUR', + 'current_balance' => 0, + 'status' => 'active', + ]); + } +} diff --git a/src/app/Services/WashService.php b/src/app/Services/WashService.php new file mode 100644 index 0000000..976a513 --- /dev/null +++ b/src/app/Services/WashService.php @@ -0,0 +1,116 @@ +whereKey($machine->id)->lockForUpdate()->firstOrFail(); + + if (! in_array($machine->status, ['available', 'reserved'], true)) { + throw new MachineNotAvailableException("Statut actuel : {$machine->status}"); + } + + $booking = null; + if ($bookingId !== null) { + $booking = Booking::query() + ->whereKey($bookingId) + ->where('user_id', $user->id) + ->where('machine_id', $machine->id) + ->lockForUpdate() + ->firstOrFail(); + + if (! in_array($booking->status, ['confirmed', 'active'], true)) { + throw new \RuntimeException('La réservation n\'est pas valide pour démarrer un lavage.'); + } + } + + $cost = $booking !== null + ? (float) $booking->reserved_amount + : $this->pricingService->getActivePrice($machine); + + if ($booking === null) { + $this->walletService->debit( + $user, + $cost, + Wash::class, + null, + 'wash-debit:'.Str::uuid(), + ['machine_id' => $machine->id], + ); + } + + $wash = Wash::query()->create([ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user->id, + 'machine_id' => $machine->id, + 'booking_id' => $booking?->id, + 'trigger_method' => $triggerMethod, + 'status' => 'pending_start', + 'program' => $program, + 'cost' => $cost, + 'duration_minutes' => WashCycleProgress::estimateDurationMinutes($machine->type), + ]); + + $result = $this->machineIntegrationService->startCycle($machine, [ + 'wash_id' => $wash->id, + 'wash_uuid' => $wash->uuid, + 'program' => $program, + 'requested_by_user_id' => $user->id, + ], $user); + + if (! $result->success) { + throw new \RuntimeException('Échec du démarrage du cycle machine.'); + } + + $command = $machine->commands()->latest('id')->first(); + + $wash->update([ + 'status' => 'running', + 'machine_command_id' => $command?->id, + 'started_at' => now(), + ]); + + if ($booking !== null) { + $booking->update(['status' => 'active']); + } + + $machine->update(['current_user_id' => $user->id]); + + $this->auditService->log( + $user, + 'wash.started', + $wash, + null, + $wash->fresh()->toArray(), + $machine->establishment?->organization_id, + $machine->establishment_id, + ); + + return $wash->fresh(['machine', 'booking']); + }); + } +} diff --git a/src/app/Support/WashCycleProgress.php b/src/app/Support/WashCycleProgress.php new file mode 100644 index 0000000..1a6ca45 --- /dev/null +++ b/src/app/Support/WashCycleProgress.php @@ -0,0 +1,108 @@ +|null + */ + public static function forWash(Wash $wash): ?array + { + if (! in_array($wash->status, ['pending_start', 'running', 'active'], true)) { + return null; + } + + $wash->loadMissing('machine'); + $machine = $wash->machine; + $startedAt = $wash->started_at ?? $wash->created_at; + $endsAt = $machine?->cycle_ends_at; + + if ($startedAt === null) { + return [ + 'percent' => 0, + 'phase' => 'pending', + 'phase_label' => 'En attente de démarrage', + 'estimated_end_at' => $endsAt?->toIso8601String(), + 'remaining_seconds' => null, + ]; + } + + $totalSeconds = $endsAt !== null + ? max(1, (int) $startedAt->diffInSeconds($endsAt)) + : max(1, ($wash->duration_minutes ?? 45) * 60); + + $elapsed = max(0, (int) $startedAt->diffInSeconds(now())); + $percent = min(99, (int) round(($elapsed / $totalSeconds) * 100)); + + [$phase, $phaseLabel] = self::phaseForPercent($percent, $machine?->type); + + $remainingSeconds = $endsAt !== null + ? max(0, (int) now()->diffInSeconds($endsAt, false)) + : max(0, $totalSeconds - $elapsed); + + return [ + 'percent' => $percent, + 'phase' => $phase, + 'phase_label' => $phaseLabel, + 'estimated_end_at' => $endsAt?->toIso8601String(), + 'remaining_seconds' => $remainingSeconds, + ]; + } + + /** + * @return array{0: string, 1: string} + */ + private static function phaseForPercent(int $percent, ?string $type): array + { + $isDryer = str_starts_with($type ?? '', 'dryer'); + + if ($percent < 5) { + return ['lock', 'Verrouillage']; + } + + if ($isDryer) { + if ($percent < 15) { + return ['heat', 'Préchauffage']; + } + if ($percent < 85) { + return ['dry', 'Séchage']; + } + + return ['finish', 'Refroidissement']; + } + + if ($percent < 15) { + return ['fill', 'Remplissage']; + } + if ($percent < 55) { + return ['wash', 'Lavage']; + } + if ($percent < 75) { + return ['rinse', 'Rinçage']; + } + if ($percent < 90) { + return ['spin', 'Essorage']; + } + + return ['finish', 'Finition']; + } + + public static function estimateDurationMinutes(?string $machineType): int + { + $simSeconds = (int) config('laverie.simulation.cycle_duration_seconds', 30); + + if ($simSeconds >= 120) { + return max(1, (int) ceil($simSeconds / 60)); + } + + return match (true) { + str_starts_with($machineType ?? '', 'dryer_large') => 55, + str_starts_with($machineType ?? '', 'dryer') => 45, + str_starts_with($machineType ?? '', 'washer_large') => 50, + default => 40, + }; + } +} diff --git a/src/artisan b/src/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/src/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/src/bootstrap/app.php b/src/bootstrap/app.php new file mode 100644 index 0000000..7db1cb6 --- /dev/null +++ b/src/bootstrap/app.php @@ -0,0 +1,36 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + apiPrefix: 'api', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + \App\Http\Middleware\HandleInertiaRequests::class, + \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, + ]); + + $middleware->redirectGuestsTo('/login'); + $middleware->redirectUsersTo('/dashboard'); + + $middleware->alias([ + 'machine.integration' => \App\Http\Middleware\AuthenticateMachineIntegration::class, + 'supervisor.scope' => \App\Http\Middleware\EnsureSupervisorScope::class, + 'auth.supervisor' => \App\Http\Middleware\AuthenticateSupervisor::class, + 'auth.api.user' => \App\Http\Middleware\EnsureApiUser::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*'), + ); + })->create(); diff --git a/src/bootstrap/cache/.gitignore b/src/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/bootstrap/providers.php b/src/bootstrap/providers.php new file mode 100644 index 0000000..149426c --- /dev/null +++ b/src/bootstrap/providers.php @@ -0,0 +1,8 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'Europe/Paris'), + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/src/config/auth.php b/src/config/auth.php new file mode 100644 index 0000000..5cbd54b --- /dev/null +++ b/src/config/auth.php @@ -0,0 +1,55 @@ + [ + 'guard' => env('AUTH_GUARD', 'supervisor'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'supervisors'), + ], + + 'guards' => [ + 'supervisor' => [ + 'driver' => 'session', + 'provider' => 'supervisors', + ], + + 'web' => [ + 'driver' => 'session', + 'provider' => 'supervisors', + ], + ], + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => User::class, + ], + + 'supervisors' => [ + 'driver' => 'eloquent', + 'model' => Supervisor::class, + ], + ], + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + + 'supervisors' => [ + 'provider' => 'supervisors', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/src/config/cache.php b/src/config/cache.php new file mode 100644 index 0000000..d7eec61 --- /dev/null +++ b/src/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/src/config/cors.php b/src/config/cors.php new file mode 100644 index 0000000..f43c064 --- /dev/null +++ b/src/config/cors.php @@ -0,0 +1,39 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => $allowedOrigins !== null && $allowedOrigins !== '' + ? array_values(array_filter(array_map('trim', explode(',', $allowedOrigins)))) + : ['*'], + + 'allowed_origins_patterns' => [ + '#^https?://localhost(:\d+)?$#', + '#^https?://127\.0\.0\.1(:\d+)?$#', + '#^https?://\[::1\](:\d+)?$#', + ], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 86400, + + 'supports_credentials' => false, + +]; diff --git a/src/config/database.php b/src/config/database.php new file mode 100644 index 0000000..abbb88e --- /dev/null +++ b/src/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/src/config/filesystems.php b/src/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/src/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/src/config/laverie.php b/src/config/laverie.php new file mode 100644 index 0000000..539a665 --- /dev/null +++ b/src/config/laverie.php @@ -0,0 +1,34 @@ + [ + 'cycle_duration_seconds' => (int) env('LAVERIE_SIM_CYCLE_SECONDS', 30), + 'provider_name' => 'simulated', + ], + + 'booking' => [ + 'fee' => (float) env('LAVERIE_BOOKING_FEE', 1.00), + 'cancellation_grace_hours' => (int) env('LAVERIE_BOOKING_CANCEL_GRACE_HOURS', 2), + 'no_show_grace_minutes' => (int) env('LAVERIE_BOOKING_NO_SHOW_GRACE_MINUTES', 15), + 'penalty_amount' => (float) env('LAVERIE_BOOKING_PENALTY_AMOUNT', 3.00), + ], + + 'machine' => [ + 'offline_threshold_minutes' => (int) env('LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES', 10), + ], + + 'payment' => [ + 'default_provider' => env('LAVERIE_PAYMENT_PROVIDER', 'simulated'), + 'top_up_min' => (float) env('LAVERIE_TOP_UP_MIN', 5), + 'top_up_max' => (float) env('LAVERIE_TOP_UP_MAX', 150), + ], + + 'machine_api_key' => env('LAVERIE_MACHINE_API_KEY'), + + 'auth' => [ + 'access_token_ttl_minutes' => (int) env('LAVERIE_ACCESS_TOKEN_TTL', 60), + 'refresh_token_ttl_days' => (int) env('LAVERIE_REFRESH_TOKEN_TTL_DAYS', 30), + ], + +]; diff --git a/src/config/logging.php b/src/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/src/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/src/config/mail.php b/src/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/src/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/src/config/queue.php b/src/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/src/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/src/config/sanctum.php b/src/config/sanctum.php new file mode 100644 index 0000000..fc77755 --- /dev/null +++ b/src/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['supervisor', 'web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/src/config/services.php b/src/config/services.php new file mode 100644 index 0000000..3f55479 --- /dev/null +++ b/src/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + + 'nominatim' => [ + 'url' => env('NOMINATIM_URL', 'https://nominatim.openstreetmap.org'), + 'user_agent' => env('NOMINATIM_USER_AGENT', 'LaverieBackOffice/1.0 (contact@laverie.local)'), + 'verify_ssl' => env('NOMINATIM_VERIFY_SSL', env('APP_ENV', 'production') !== 'local'), + ], + +]; diff --git a/src/config/session.php b/src/config/session.php new file mode 100644 index 0000000..f574482 --- /dev/null +++ b/src/config/session.php @@ -0,0 +1,233 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/src/database/.gitignore b/src/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/src/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/src/database/factories/UserFactory.php b/src/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/src/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/src/database/migrations/0001_01_01_000000_create_users_table.php b/src/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..19a01ce --- /dev/null +++ b/src/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,51 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('first_name', 100); + $table->string('last_name', 100); + $table->string('email')->unique(); + $table->string('phone', 20)->unique()->nullable(); + $table->date('birthdate')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->string('locale', 10)->default('fr'); + $table->boolean('is_active')->default(true); + $table->timestamp('anonymized_at')->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/src/database/migrations/0001_01_01_000001_create_cache_table.php b/src/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..06dc7a5 --- /dev/null +++ b/src/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/src/database/migrations/0001_01_01_000002_create_jobs_table.php b/src/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..3891887 --- /dev/null +++ b/src/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection', 100); + $table->string('queue', 100); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent()->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/src/database/migrations/2026_06_27_000001_create_laverie_domain_tables.php b/src/database/migrations/2026_06_27_000001_create_laverie_domain_tables.php new file mode 100644 index 0000000..966654d --- /dev/null +++ b/src/database/migrations/2026_06_27_000001_create_laverie_domain_tables.php @@ -0,0 +1,328 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name', 200); + $table->string('code', 50)->unique()->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('establishments', function (Blueprint $table) { + $table->id(); + $table->foreignId('organization_id')->constrained(); + $table->uuid('uuid')->unique(); + $table->string('name', 200); + $table->text('address'); + $table->string('city', 100)->nullable(); + $table->string('zip_code', 10)->nullable(); + $table->decimal('latitude', 10, 8)->nullable(); + $table->decimal('longitude', 11, 8)->nullable(); + $table->string('timezone', 50)->default('Europe/Paris'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('supervisors', function (Blueprint $table) { + $table->id(); + $table->foreignId('organization_id')->constrained(); + $table->foreignId('establishment_id')->nullable()->constrained(); + $table->uuid('uuid')->unique(); + $table->string('first_name', 100); + $table->string('last_name', 100); + $table->string('email')->unique(); + $table->string('password'); + $table->enum('role', ['platform_admin', 'owner', 'manager', 'viewer'])->default('manager'); + $table->boolean('is_active')->default(true); + $table->timestamp('last_login_at')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('gdpr_consents', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->enum('type', ['data_processing', 'marketing', 'analytics']); + $table->boolean('accepted'); + $table->string('policy_version', 50); + $table->string('policy_text_hash'); + $table->enum('source', ['mobile', 'web', 'backoffice']); + $table->string('consent_language', 10)->default('fr'); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('accepted_at'); + $table->timestamp('revoked_at')->nullable(); + }); + + Schema::create('user_devices', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->enum('platform', ['android', 'ios', 'web']); + $table->string('push_token')->unique(); + $table->string('app_version', 50)->nullable(); + $table->string('device_name', 100)->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('notification_preferences', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->unique()->constrained(); + $table->boolean('transaction_enabled')->default(true); + $table->boolean('reminder_enabled')->default(true); + $table->boolean('marketing_enabled')->default(false); + $table->boolean('system_enabled')->default(true); + $table->timestamps(); + }); + + Schema::create('machines', function (Blueprint $table) { + $table->id(); + $table->foreignId('establishment_id')->constrained(); + $table->uuid('uuid')->unique(); + $table->string('name', 100); + $table->enum('type', ['washer_small', 'washer_large', 'dryer_small', 'dryer_large']); + $table->string('qr_code')->unique(); + $table->enum('status', ['available', 'reserved', 'running', 'maintenance', 'offline', 'error'])->default('available'); + $table->foreignId('current_user_id')->nullable()->constrained('users'); + $table->timestamp('cycle_started_at')->nullable(); + $table->timestamp('cycle_ends_at')->nullable(); + $table->timestamp('last_heartbeat_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('machine_integrations', function (Blueprint $table) { + $table->id(); + $table->foreignId('machine_id')->constrained(); + $table->string('provider', 100); + $table->string('external_machine_id', 100); + $table->string('external_site_id', 100)->nullable(); + $table->enum('mode', ['pull', 'push', 'hybrid', 'simulated'])->default('simulated'); + $table->json('config')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['provider', 'external_machine_id']); + }); + + Schema::create('machine_commands', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('machine_id')->constrained(); + $table->string('provider', 100); + $table->enum('command_type', ['start_cycle', 'stop_cycle', 'refresh_status']); + $table->json('payload')->nullable(); + $table->enum('status', ['pending', 'sent', 'acknowledged', 'failed', 'timeout', 'cancelled'])->default('pending'); + $table->string('external_reference')->nullable(); + $table->string('correlation_id')->nullable(); + $table->foreignId('requested_by_user_id')->nullable()->constrained('users'); + $table->foreignId('requested_by_supervisor_id')->nullable()->constrained('supervisors'); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('responded_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('machine_events', function (Blueprint $table) { + $table->id(); + $table->foreignId('machine_id')->constrained(); + $table->string('provider', 100); + $table->string('external_event_id')->nullable(); + $table->enum('event_type', [ + 'heartbeat', 'machine_online', 'machine_offline', + 'cycle_started', 'cycle_completed', 'cycle_failed', + 'error_reported', 'status_changed', + ]); + $table->json('payload')->nullable(); + $table->timestamp('occurred_at'); + $table->timestamp('received_at'); + $table->timestamp('processed_at')->nullable(); + $table->enum('processing_status', ['pending', 'processed', 'failed', 'ignored'])->default('pending'); + $table->timestamps(); + }); + + Schema::create('machine_status_history', function (Blueprint $table) { + $table->id(); + $table->foreignId('machine_id')->constrained(); + $table->string('previous_status', 50)->nullable(); + $table->string('new_status', 50); + $table->string('source', 100); + $table->string('reason')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('pricing_rules', function (Blueprint $table) { + $table->id(); + $table->foreignId('establishment_id')->constrained(); + $table->foreignId('machine_id')->nullable()->constrained(); + $table->enum('machine_type', ['washer_small', 'washer_large', 'dryer_small', 'dryer_large'])->nullable(); + $table->enum('day_type', ['weekday', 'weekend', 'holiday', 'all'])->default('all'); + $table->time('slot_start'); + $table->time('slot_end'); + $table->decimal('price', 8, 2); + $table->string('label', 100)->nullable(); + $table->boolean('requires_app')->default(false); + $table->smallInteger('priority')->default(100); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('promotions', function (Blueprint $table) { + $table->id(); + $table->foreignId('establishment_id')->constrained(); + $table->enum('machine_type', ['washer_small', 'washer_large', 'dryer_small', 'dryer_large', 'all'])->default('all'); + $table->enum('discount_type', ['percent', 'fixed']); + $table->decimal('discount_value', 8, 2); + $table->timestamp('starts_at'); + $table->timestamp('ends_at'); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('wallets', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->unique()->constrained(); + $table->char('currency', 3)->default('EUR'); + $table->decimal('current_balance', 10, 2)->default(0); + $table->enum('status', ['active', 'blocked', 'closed'])->default('active'); + $table->timestamps(); + }); + + Schema::create('payment_transactions', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('user_id')->constrained(); + $table->string('provider', 50); + $table->string('provider_payment_id')->nullable(); + $table->decimal('amount', 10, 2); + $table->char('currency', 3)->default('EUR'); + $table->enum('status', ['initiated', 'pending', 'succeeded', 'failed', 'cancelled', 'refunded'])->default('initiated'); + $table->string('idempotency_key', 100)->unique(); + $table->string('return_url')->nullable(); + $table->json('raw_payload')->nullable(); + $table->timestamps(); + }); + + Schema::create('wallet_transactions', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('wallet_id')->constrained(); + $table->enum('type', ['credit', 'debit', 'hold', 'release', 'refund', 'adjustment']); + $table->decimal('amount', 10, 2); + $table->decimal('balance_before', 10, 2); + $table->decimal('balance_after', 10, 2); + $table->string('source_type', 100); + $table->unsignedBigInteger('source_id')->nullable(); + $table->string('idempotency_key', 100)->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('bookings', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('user_id')->constrained(); + $table->foreignId('machine_id')->constrained(); + $table->timestamp('slot_start'); + $table->timestamp('slot_end'); + $table->decimal('booking_fee', 8, 2)->default(0); + $table->decimal('reserved_amount', 8, 2)->default(0); + $table->decimal('penalty_amount', 8, 2)->default(0); + $table->enum('status', ['pending', 'confirmed', 'cancelled', 'expired', 'active', 'completed', 'no_show'])->default('pending'); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamp('penalty_applied_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('washes', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('user_id')->constrained(); + $table->foreignId('machine_id')->constrained(); + $table->foreignId('booking_id')->nullable()->constrained(); + $table->foreignId('machine_command_id')->nullable()->constrained(); + $table->enum('trigger_method', ['qr_code', 'booking', 'supervisor', 'system']); + $table->enum('status', ['pending_start', 'running', 'completed', 'failed', 'cancelled'])->default('pending_start'); + $table->string('program', 100)->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('ended_at')->nullable(); + $table->unsignedInteger('duration_minutes')->nullable(); + $table->decimal('cost', 8, 2); + $table->timestamps(); + }); + + Schema::create('push_notifications', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained(); + $table->string('type', 100); + $table->string('title'); + $table->text('body'); + $table->json('data')->nullable(); + $table->enum('status', ['pending', 'sent', 'failed'])->default('pending'); + $table->timestamp('sent_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('audit_logs', function (Blueprint $table) { + $table->id(); + $table->string('actor_type', 50); + $table->unsignedBigInteger('actor_id'); + $table->foreignId('organization_id')->nullable()->constrained(); + $table->foreignId('establishment_id')->nullable()->constrained(); + $table->string('action', 100); + $table->string('target_type', 100); + $table->unsignedBigInteger('target_id')->nullable(); + $table->json('before_data')->nullable(); + $table->json('after_data')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('daily_establishment_stats', function (Blueprint $table) { + $table->id(); + $table->foreignId('establishment_id')->constrained(); + $table->date('stat_date'); + $table->unsignedInteger('total_washes')->default(0); + $table->decimal('total_revenue', 10, 2)->default(0); + $table->unsignedInteger('bookings_count')->default(0); + $table->unsignedInteger('no_show_count')->default(0); + $table->decimal('top_up_amount', 10, 2)->default(0); + $table->decimal('occupancy_rate', 5, 2)->default(0); + $table->timestamps(); + $table->unique(['establishment_id', 'stat_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('daily_establishment_stats'); + Schema::dropIfExists('audit_logs'); + Schema::dropIfExists('push_notifications'); + Schema::dropIfExists('washes'); + Schema::dropIfExists('bookings'); + Schema::dropIfExists('wallet_transactions'); + Schema::dropIfExists('payment_transactions'); + Schema::dropIfExists('wallets'); + Schema::dropIfExists('promotions'); + Schema::dropIfExists('pricing_rules'); + Schema::dropIfExists('machine_status_history'); + Schema::dropIfExists('machine_events'); + Schema::dropIfExists('machine_commands'); + Schema::dropIfExists('machine_integrations'); + Schema::dropIfExists('machines'); + Schema::dropIfExists('notification_preferences'); + Schema::dropIfExists('user_devices'); + Schema::dropIfExists('gdpr_consents'); + Schema::dropIfExists('supervisors'); + Schema::dropIfExists('establishments'); + Schema::dropIfExists('organizations'); + } +}; diff --git a/src/database/migrations/2026_06_27_212930_create_personal_access_tokens_table.php b/src/database/migrations/2026_06_27_212930_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/src/database/migrations/2026_06_27_212930_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/src/database/seeders/DatabaseSeeder.php b/src/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..34b03f0 --- /dev/null +++ b/src/database/seeders/DatabaseSeeder.php @@ -0,0 +1,15 @@ +call([ + DemoSeeder::class, + ]); + } +} diff --git a/src/database/seeders/DemoSeeder.php b/src/database/seeders/DemoSeeder.php new file mode 100644 index 0000000..514bf87 --- /dev/null +++ b/src/database/seeders/DemoSeeder.php @@ -0,0 +1,296 @@ + 'Laverie du Centre', + 'code' => 'LDC', + 'is_active' => true, + ]); + + $orgExpress = Organization::create([ + 'name' => 'Laverie Express', + 'code' => 'LEX', + 'is_active' => true, + ]); + + // --- Établissements (2 pour org1, 1 pour org2) --- + $estCentreVille = Establishment::create([ + 'organization_id' => $orgCentre->id, + 'name' => 'Laverie Centre-Ville', + 'address' => '12 rue de la République', + 'city' => 'Lyon', + 'zip_code' => '69002', + 'latitude' => 45.7578137, + 'longitude' => 4.8320114, + 'timezone' => 'Europe/Paris', + 'is_active' => true, + ]); + + $estPartDieu = Establishment::create([ + 'organization_id' => $orgCentre->id, + 'name' => 'Laverie Part-Dieu', + 'address' => '45 avenue du Général Leclerc', + 'city' => 'Lyon', + 'zip_code' => '69003', + 'latitude' => 45.7601061, + 'longitude' => 4.8566930, + 'timezone' => 'Europe/Paris', + 'is_active' => true, + ]); + + $estExpressNord = Establishment::create([ + 'organization_id' => $orgExpress->id, + 'name' => 'Laverie Express Nord', + 'address' => '8 boulevard de la Croix-Rousse', + 'city' => 'Lyon', + 'zip_code' => '69004', + 'latitude' => 45.7740000, + 'longitude' => 4.8325000, + 'timezone' => 'Europe/Paris', + 'is_active' => true, + ]); + + // --- Superviseurs --- + $this->createSupervisor( + organizationId: $orgCentre->id, + establishmentId: null, + firstName: 'Admin', + lastName: 'Plateforme', + email: 'platform_admin@laverie.local', + role: 'platform_admin', + ); + + $this->createSupervisor( + organizationId: $orgCentre->id, + establishmentId: null, + firstName: 'Pierre', + lastName: 'Moreau', + email: 'owner1@laverie.local', + role: 'owner', + ); + + $this->createSupervisor( + organizationId: $orgCentre->id, + establishmentId: $estCentreVille->id, + firstName: 'Sophie', + lastName: 'Bernard', + email: 'manager1@laverie.local', + role: 'manager', + ); + + $this->createSupervisor( + organizationId: $orgExpress->id, + establishmentId: null, + firstName: 'Marc', + lastName: 'Lefèvre', + email: 'owner2@laverie.local', + role: 'owner', + ); + + // --- Utilisateurs finaux avec portefeuille (20 € chacun) --- + $demoUsers = [ + ['first_name' => 'Marie', 'last_name' => 'Dupont', 'email' => 'marie.dupont@demo.local'], + ['first_name' => 'Jean', 'last_name' => 'Martin', 'email' => 'jean.martin@demo.local'], + ['first_name' => 'Lucie', 'last_name' => 'Petit', 'email' => 'lucie.petit@demo.local'], + ['first_name' => 'Thomas', 'last_name' => 'Roux', 'email' => 'thomas.roux@demo.local'], + ['first_name' => 'Emma', 'last_name' => 'Girard', 'email' => 'emma.girard@demo.local'], + ]; + + $users = collect($demoUsers)->map(function (array $data) { + $user = User::create([ + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'email' => $data['email'], + 'password' => self::DEMO_PASSWORD, + 'locale' => 'fr', + 'is_active' => true, + 'email_verified_at' => now(), + ]); + + Wallet::create([ + 'user_id' => $user->id, + 'currency' => 'EUR', + 'current_balance' => 20.00, + 'status' => 'active', + ]); + + return $user; + }); + + // --- Machines (10 au total, statuts variés) --- + $machineDefinitions = [ + // Laverie Centre-Ville (4 machines) + ['establishment' => $estCentreVille, 'name' => 'Lave-linge 1', 'type' => 'washer_small', 'status' => 'available'], + ['establishment' => $estCentreVille, 'name' => 'Lave-linge 2', 'type' => 'washer_large', 'status' => 'running', 'user' => $users[0]], + ['establishment' => $estCentreVille, 'name' => 'Sèche-linge 1', 'type' => 'dryer_small', 'status' => 'reserved'], + ['establishment' => $estCentreVille, 'name' => 'Sèche-linge 2', 'type' => 'dryer_large', 'status' => 'maintenance'], + // Laverie Part-Dieu (3 machines) + ['establishment' => $estPartDieu, 'name' => 'Lave-linge A', 'type' => 'washer_small', 'status' => 'available'], + ['establishment' => $estPartDieu, 'name' => 'Lave-linge B', 'type' => 'washer_large', 'status' => 'offline'], + ['establishment' => $estPartDieu, 'name' => 'Sèche-linge A', 'type' => 'dryer_small', 'status' => 'error'], + // Laverie Express Nord (3 machines) + ['establishment' => $estExpressNord, 'name' => 'Express Lave 1', 'type' => 'washer_small', 'status' => 'available'], + ['establishment' => $estExpressNord, 'name' => 'Express Lave 2', 'type' => 'washer_large', 'status' => 'running', 'user' => $users[1]], + ['establishment' => $estExpressNord, 'name' => 'Express Sèche 1', 'type' => 'dryer_large', 'status' => 'available'], + ]; + + $machineIndex = 1; + + foreach ($machineDefinitions as $definition) { + $machine = Machine::create([ + 'establishment_id' => $definition['establishment']->id, + 'name' => $definition['name'], + 'type' => $definition['type'], + 'qr_code' => 'LAVERIE-DEMO-'.str_pad((string) $machineIndex, 3, '0', STR_PAD_LEFT), + 'status' => $definition['status'], + 'current_user_id' => isset($definition['user']) ? $definition['user']->id : null, + 'cycle_started_at' => $definition['status'] === 'running' ? now()->subMinutes(15) : null, + 'cycle_ends_at' => $definition['status'] === 'running' ? now()->addMinutes(27) : null, + 'last_heartbeat_at' => $definition['status'] === 'offline' ? now()->subHours(2) : now()->subMinutes(2), + ]); + + MachineIntegration::create([ + 'machine_id' => $machine->id, + 'provider' => $simulatedProvider, + 'external_machine_id' => 'SIM-'.$machine->uuid, + 'external_site_id' => 'SITE-'.$definition['establishment']->uuid, + 'mode' => 'simulated', + 'config' => [ + 'cycle_duration_seconds' => config('laverie.simulation.cycle_duration_seconds', 30), + ], + 'is_active' => true, + ]); + + $machineIndex++; + } + + // --- Règles tarifaires (4 € lavage, 3 € séchage) par établissement --- + foreach ([$estCentreVille, $estPartDieu, $estExpressNord] as $establishment) { + PricingRule::create([ + 'establishment_id' => $establishment->id, + 'machine_type' => 'washer_small', + 'day_type' => 'all', + 'slot_start' => '00:00:00', + 'slot_end' => '23:59:59', + 'price' => 4.00, + 'label' => 'Lavage standard', + 'requires_app' => false, + 'priority' => 100, + 'is_active' => true, + ]); + + PricingRule::create([ + 'establishment_id' => $establishment->id, + 'machine_type' => 'washer_large', + 'day_type' => 'all', + 'slot_start' => '00:00:00', + 'slot_end' => '23:59:59', + 'price' => 4.00, + 'label' => 'Lavage grand tambour', + 'requires_app' => false, + 'priority' => 100, + 'is_active' => true, + ]); + + PricingRule::create([ + 'establishment_id' => $establishment->id, + 'machine_type' => 'dryer_small', + 'day_type' => 'all', + 'slot_start' => '00:00:00', + 'slot_end' => '23:59:59', + 'price' => 3.00, + 'label' => 'Séchage standard', + 'requires_app' => false, + 'priority' => 100, + 'is_active' => true, + ]); + + PricingRule::create([ + 'establishment_id' => $establishment->id, + 'machine_type' => 'dryer_large', + 'day_type' => 'all', + 'slot_start' => '00:00:00', + 'slot_end' => '23:59:59', + 'price' => 3.00, + 'label' => 'Sèche-linge grand tambour', + 'requires_app' => false, + 'priority' => 100, + 'is_active' => true, + ]); + } + + // --- Promotions actives (2) --- + Promotion::create([ + 'establishment_id' => $estCentreVille->id, + 'machine_type' => 'washer_small', + 'discount_type' => 'percent', + 'discount_value' => 10.00, + 'starts_at' => now()->subDays(7), + 'ends_at' => now()->addDays(30), + 'description' => 'Offre de bienvenue : -10 % sur les lavages petit tambour', + 'is_active' => true, + ]); + + Promotion::create([ + 'establishment_id' => $estExpressNord->id, + 'machine_type' => 'all', + 'discount_type' => 'fixed', + 'discount_value' => 0.50, + 'starts_at' => now()->subDays(3), + 'ends_at' => now()->addDays(14), + 'description' => 'Happy hour Express : 0,50 € de réduction sur tous les cycles', + 'is_active' => true, + ]); + } + + /** + * Crée un superviseur avec mot de passe de démonstration. + */ + private function createSupervisor( + int $organizationId, + ?int $establishmentId, + string $firstName, + string $lastName, + string $email, + string $role, + ): Supervisor { + return Supervisor::create([ + 'organization_id' => $organizationId, + 'establishment_id' => $establishmentId, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email' => $email, + 'password' => self::DEMO_PASSWORD, + 'role' => $role, + 'is_active' => true, + ]); + } +} diff --git a/src/jsconfig.json b/src/jsconfig.json new file mode 100644 index 0000000..6269354 --- /dev/null +++ b/src/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["resources/js/*"], + "ziggy-js": ["./vendor/tightenco/ziggy"] + } + }, + "exclude": ["node_modules", "public"] +} diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..b490710 --- /dev/null +++ b/src/package.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "version": "0.0.1", + "scripts": { + "prod": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@inertiajs/vue3": "^2.0.0", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^6.0.0", + "autoprefixer": "^10.4.12", + "axios": "^1.18.1", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^3.1", + "postcss": "^8.4.31", + "tailwindcss": "^3.2.1", + "vite": "^8.0.0", + "vue": "^3.4.0" + } +} diff --git a/src/phpunit.xml b/src/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/src/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/src/postcss.config.js b/src/postcss.config.js new file mode 100644 index 0000000..49c0612 --- /dev/null +++ b/src/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/public/.htaccess b/src/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/src/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/src/public/favicon.ico b/src/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/src/public/index.php b/src/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/src/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/src/public/robots.txt b/src/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/src/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/src/resources/css/app.css b/src/resources/css/app.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/resources/css/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/resources/js/Components/ApplicationLogo.vue b/src/resources/js/Components/ApplicationLogo.vue new file mode 100644 index 0000000..d952df7 --- /dev/null +++ b/src/resources/js/Components/ApplicationLogo.vue @@ -0,0 +1,7 @@ + diff --git a/src/resources/js/Components/Checkbox.vue b/src/resources/js/Components/Checkbox.vue new file mode 100644 index 0000000..194de24 --- /dev/null +++ b/src/resources/js/Components/Checkbox.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/resources/js/Components/ConfirmDeleteModal.vue b/src/resources/js/Components/ConfirmDeleteModal.vue new file mode 100644 index 0000000..2ac3eaa --- /dev/null +++ b/src/resources/js/Components/ConfirmDeleteModal.vue @@ -0,0 +1,67 @@ + + + \ No newline at end of file diff --git a/src/resources/js/Components/DangerButton.vue b/src/resources/js/Components/DangerButton.vue new file mode 100644 index 0000000..ac45afc --- /dev/null +++ b/src/resources/js/Components/DangerButton.vue @@ -0,0 +1,7 @@ + diff --git a/src/resources/js/Components/Dropdown.vue b/src/resources/js/Components/Dropdown.vue new file mode 100644 index 0000000..06ee2ea --- /dev/null +++ b/src/resources/js/Components/Dropdown.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/resources/js/Components/DropdownLink.vue b/src/resources/js/Components/DropdownLink.vue new file mode 100644 index 0000000..e5ab50b --- /dev/null +++ b/src/resources/js/Components/DropdownLink.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/resources/js/Components/InputError.vue b/src/resources/js/Components/InputError.vue new file mode 100644 index 0000000..3e98ae5 --- /dev/null +++ b/src/resources/js/Components/InputError.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/resources/js/Components/InputLabel.vue b/src/resources/js/Components/InputLabel.vue new file mode 100644 index 0000000..dc81a53 --- /dev/null +++ b/src/resources/js/Components/InputLabel.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/resources/js/Components/LaverieLogo.vue b/src/resources/js/Components/LaverieLogo.vue new file mode 100644 index 0000000..a585276 --- /dev/null +++ b/src/resources/js/Components/LaverieLogo.vue @@ -0,0 +1,7 @@ + diff --git a/src/resources/js/Components/Modal.vue b/src/resources/js/Components/Modal.vue new file mode 100644 index 0000000..7887bbf --- /dev/null +++ b/src/resources/js/Components/Modal.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/resources/js/Components/NavLink.vue b/src/resources/js/Components/NavLink.vue new file mode 100644 index 0000000..e669803 --- /dev/null +++ b/src/resources/js/Components/NavLink.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/resources/js/Components/PrimaryButton.vue b/src/resources/js/Components/PrimaryButton.vue new file mode 100644 index 0000000..a4ca033 --- /dev/null +++ b/src/resources/js/Components/PrimaryButton.vue @@ -0,0 +1,7 @@ + diff --git a/src/resources/js/Components/ResponsiveNavLink.vue b/src/resources/js/Components/ResponsiveNavLink.vue new file mode 100644 index 0000000..f6c4566 --- /dev/null +++ b/src/resources/js/Components/ResponsiveNavLink.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/resources/js/Components/SecondaryButton.vue b/src/resources/js/Components/SecondaryButton.vue new file mode 100644 index 0000000..538515c --- /dev/null +++ b/src/resources/js/Components/SecondaryButton.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/resources/js/Components/Supervisor/AddressAutocomplete.vue b/src/resources/js/Components/Supervisor/AddressAutocomplete.vue new file mode 100644 index 0000000..d032627 --- /dev/null +++ b/src/resources/js/Components/Supervisor/AddressAutocomplete.vue @@ -0,0 +1,162 @@ + + + diff --git a/src/resources/js/Components/Supervisor/AlertBanner.vue b/src/resources/js/Components/Supervisor/AlertBanner.vue new file mode 100644 index 0000000..44fd833 --- /dev/null +++ b/src/resources/js/Components/Supervisor/AlertBanner.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/resources/js/Components/Supervisor/DataTable.vue b/src/resources/js/Components/Supervisor/DataTable.vue new file mode 100644 index 0000000..d149495 --- /dev/null +++ b/src/resources/js/Components/Supervisor/DataTable.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/resources/js/Components/Supervisor/FormField.vue b/src/resources/js/Components/Supervisor/FormField.vue new file mode 100644 index 0000000..7d2cd03 --- /dev/null +++ b/src/resources/js/Components/Supervisor/FormField.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/resources/js/Components/Supervisor/FormModal.vue b/src/resources/js/Components/Supervisor/FormModal.vue new file mode 100644 index 0000000..423dc74 --- /dev/null +++ b/src/resources/js/Components/Supervisor/FormModal.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/resources/js/Components/Supervisor/OutlineSelect.vue b/src/resources/js/Components/Supervisor/OutlineSelect.vue new file mode 100644 index 0000000..6fc1ef6 --- /dev/null +++ b/src/resources/js/Components/Supervisor/OutlineSelect.vue @@ -0,0 +1,266 @@ + + + diff --git a/src/resources/js/Components/Supervisor/PageToolbar.vue b/src/resources/js/Components/Supervisor/PageToolbar.vue new file mode 100644 index 0000000..82c833b --- /dev/null +++ b/src/resources/js/Components/Supervisor/PageToolbar.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/resources/js/Components/Supervisor/Pagination.vue b/src/resources/js/Components/Supervisor/Pagination.vue new file mode 100644 index 0000000..ed36786 --- /dev/null +++ b/src/resources/js/Components/Supervisor/Pagination.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/resources/js/Components/Supervisor/StatusBadge.vue b/src/resources/js/Components/Supervisor/StatusBadge.vue new file mode 100644 index 0000000..2ba08e0 --- /dev/null +++ b/src/resources/js/Components/Supervisor/StatusBadge.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/resources/js/Components/Supervisor/TableHeaderCell.vue b/src/resources/js/Components/Supervisor/TableHeaderCell.vue new file mode 100644 index 0000000..17c97ba --- /dev/null +++ b/src/resources/js/Components/Supervisor/TableHeaderCell.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/resources/js/Components/Supervisor/ui.js b/src/resources/js/Components/Supervisor/ui.js new file mode 100644 index 0000000..0b3cf6d --- /dev/null +++ b/src/resources/js/Components/Supervisor/ui.js @@ -0,0 +1,67 @@ +export const labelClass = 'block text-xs font-semibold uppercase tracking-wide text-slate-500'; + +export const inputClass = + 'mt-1.5 block w-full rounded-xl border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-800 shadow-sm transition placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20'; + +export const selectClass = inputClass; + +export const cardClass = 'overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm'; + +export const filterBarClass = + 'mb-6 flex flex-wrap gap-4 rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm'; + +export const thFilterClass = 'px-6 py-3 align-bottom'; + +export const filterLabelClass = + 'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-600'; + +export const filterInputClass = + 'block w-full min-w-[7rem] rounded-lg border-slate-200 bg-white px-2.5 py-1.5 text-sm text-slate-800 shadow-sm transition placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20'; + +export const filterSelectClass = filterInputClass; + +export const formPanelClass = + 'mb-6 rounded-2xl border border-indigo-200/60 bg-white p-6 shadow-sm ring-1 ring-indigo-50'; + +export const thClass = + 'px-6 py-4 text-left text-xs font-semibold uppercase tracking-wide text-slate-600'; + +export const tdClass = 'px-6 py-4 text-sm text-slate-600'; + +export const rowClass = ''; + +export const linkClass = 'font-medium text-indigo-600 transition hover:text-indigo-800'; + +export const machineStatusColors = { + available: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20', + reserved: 'bg-amber-100 text-amber-800 ring-amber-600/20', + running: 'bg-sky-100 text-sky-800 ring-sky-600/20', + maintenance: 'bg-orange-100 text-orange-800 ring-orange-600/20', + offline: 'bg-slate-100 text-slate-600 ring-slate-500/20', + error: 'bg-red-100 text-red-800 ring-red-600/20', +}; + +export const bookingStatusColors = { + pending: 'bg-slate-100 text-slate-600 ring-slate-500/20', + confirmed: 'bg-sky-100 text-sky-800 ring-sky-600/20', + expired: 'bg-orange-100 text-orange-800 ring-orange-600/20', + active: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20', + completed: 'bg-indigo-100 text-indigo-800 ring-indigo-600/20', + cancelled: 'bg-amber-100 text-amber-800 ring-amber-600/20', + no_show: 'bg-red-100 text-red-800 ring-red-600/20', +}; + +export const washStatusColors = { + pending_start: 'bg-slate-100 text-slate-600 ring-slate-500/20', + running: 'bg-sky-100 text-sky-800 ring-sky-600/20', + completed: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20', + failed: 'bg-red-100 text-red-800 ring-red-600/20', + cancelled: 'bg-amber-100 text-amber-800 ring-amber-600/20', +}; + +export const activeStatusColors = { + active: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20', + inactive: 'bg-slate-100 text-slate-600 ring-slate-500/20', +}; + +export const getStatusColor = (status, map) => map[status] ?? 'bg-slate-100 text-slate-700 ring-slate-500/20'; diff --git a/src/resources/js/Components/Supervisor/useListFilters.js b/src/resources/js/Components/Supervisor/useListFilters.js new file mode 100644 index 0000000..a0e2ae7 --- /dev/null +++ b/src/resources/js/Components/Supervisor/useListFilters.js @@ -0,0 +1,75 @@ +import { router } from '@inertiajs/vue3'; +import { reactive, watch } from 'vue'; + +export const listPerPageOptions = [10, 25, 50]; + +export function useListFilters(routeName, initialFilters, { debounceKeys = [] } = {}) { + const localFilters = reactive({ ...initialFilters }); + + const buildParams = ({ resetPage = false } = {}) => { + const params = { ...localFilters }; + + if (resetPage) { + delete params.page; + } + + Object.keys(params).forEach((key) => { + if (params[key] === '' || params[key] === null || params[key] === undefined) { + delete params[key]; + } + }); + + return params; + }; + + const fetchList = ({ resetPage = false } = {}) => { + router.get(route(routeName), buildParams({ resetPage }), { + preserveState: true, + replace: true, + }); + }; + + let debounceTimer = null; + + if (debounceKeys.length > 0) { + debounceKeys.forEach((key) => { + watch( + () => localFilters[key], + () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => fetchList({ resetPage: true }), 300); + }, + ); + }); + } + + const immediateKeys = Object.keys(initialFilters).filter( + (key) => !debounceKeys.includes(key) && key !== 'per_page', + ); + + if (immediateKeys.length > 0) { + watch( + () => immediateKeys.map((key) => localFilters[key]), + () => fetchList({ resetPage: true }), + ); + } + + if ('per_page' in initialFilters) { + watch( + () => localFilters.per_page, + () => fetchList({ resetPage: true }), + ); + } + + const toggleSort = (key, descFirstKeys = []) => { + if (localFilters.sort === key) { + localFilters.direction = localFilters.direction === 'asc' ? 'desc' : 'asc'; + return; + } + + localFilters.sort = key; + localFilters.direction = descFirstKeys.includes(key) ? 'desc' : 'asc'; + }; + + return { localFilters, toggleSort }; +} diff --git a/src/resources/js/Components/TextInput.vue b/src/resources/js/Components/TextInput.vue new file mode 100644 index 0000000..7e31d8c --- /dev/null +++ b/src/resources/js/Components/TextInput.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/resources/js/Layouts/AuthenticatedLayout.vue b/src/resources/js/Layouts/AuthenticatedLayout.vue new file mode 100644 index 0000000..154305f --- /dev/null +++ b/src/resources/js/Layouts/AuthenticatedLayout.vue @@ -0,0 +1,198 @@ + + + diff --git a/src/resources/js/Layouts/GuestLayout.vue b/src/resources/js/Layouts/GuestLayout.vue new file mode 100644 index 0000000..1b209cb --- /dev/null +++ b/src/resources/js/Layouts/GuestLayout.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/resources/js/Layouts/SupervisorLayout.vue b/src/resources/js/Layouts/SupervisorLayout.vue new file mode 100644 index 0000000..8e6e4b6 --- /dev/null +++ b/src/resources/js/Layouts/SupervisorLayout.vue @@ -0,0 +1,326 @@ + + + diff --git a/src/resources/js/Pages/Auth/ConfirmPassword.vue b/src/resources/js/Pages/Auth/ConfirmPassword.vue new file mode 100644 index 0000000..1e97cb9 --- /dev/null +++ b/src/resources/js/Pages/Auth/ConfirmPassword.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/resources/js/Pages/Auth/ForgotPassword.vue b/src/resources/js/Pages/Auth/ForgotPassword.vue new file mode 100644 index 0000000..fe5a196 --- /dev/null +++ b/src/resources/js/Pages/Auth/ForgotPassword.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/resources/js/Pages/Auth/Login.vue b/src/resources/js/Pages/Auth/Login.vue new file mode 100644 index 0000000..8e906a9 --- /dev/null +++ b/src/resources/js/Pages/Auth/Login.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/resources/js/Pages/Auth/Register.vue b/src/resources/js/Pages/Auth/Register.vue new file mode 100644 index 0000000..de4a5bf --- /dev/null +++ b/src/resources/js/Pages/Auth/Register.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/resources/js/Pages/Auth/ResetPassword.vue b/src/resources/js/Pages/Auth/ResetPassword.vue new file mode 100644 index 0000000..e795844 --- /dev/null +++ b/src/resources/js/Pages/Auth/ResetPassword.vue @@ -0,0 +1,101 @@ + + + diff --git a/src/resources/js/Pages/Auth/VerifyEmail.vue b/src/resources/js/Pages/Auth/VerifyEmail.vue new file mode 100644 index 0000000..ffd8ed8 --- /dev/null +++ b/src/resources/js/Pages/Auth/VerifyEmail.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/resources/js/Pages/Dashboard.vue b/src/resources/js/Pages/Dashboard.vue new file mode 100644 index 0000000..f22f58e --- /dev/null +++ b/src/resources/js/Pages/Dashboard.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/resources/js/Pages/Profile/Edit.vue b/src/resources/js/Pages/Profile/Edit.vue new file mode 100644 index 0000000..47558d4 --- /dev/null +++ b/src/resources/js/Pages/Profile/Edit.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/resources/js/Pages/Profile/Partials/DeleteUserForm.vue b/src/resources/js/Pages/Profile/Partials/DeleteUserForm.vue new file mode 100644 index 0000000..6dbd3e2 --- /dev/null +++ b/src/resources/js/Pages/Profile/Partials/DeleteUserForm.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue b/src/resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue new file mode 100644 index 0000000..45dfbde --- /dev/null +++ b/src/resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue b/src/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue new file mode 100644 index 0000000..3785a7c --- /dev/null +++ b/src/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Auth/Login.vue b/src/resources/js/Pages/Supervisor/Auth/Login.vue new file mode 100644 index 0000000..a7626d7 --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Auth/Login.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Bookings/Index.vue b/src/resources/js/Pages/Supervisor/Bookings/Index.vue new file mode 100644 index 0000000..883fd56 --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Bookings/Index.vue @@ -0,0 +1,189 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Dashboard.vue b/src/resources/js/Pages/Supervisor/Dashboard.vue new file mode 100644 index 0000000..01f29d8 --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Dashboard.vue @@ -0,0 +1,553 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Machines/Index.vue b/src/resources/js/Pages/Supervisor/Machines/Index.vue new file mode 100644 index 0000000..1a57efd --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Machines/Index.vue @@ -0,0 +1,337 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Machines/Show.vue b/src/resources/js/Pages/Supervisor/Machines/Show.vue new file mode 100644 index 0000000..9a4c12f --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Machines/Show.vue @@ -0,0 +1,130 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Organizations/Index.vue b/src/resources/js/Pages/Supervisor/Organizations/Index.vue new file mode 100644 index 0000000..59c9e19 --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Organizations/Index.vue @@ -0,0 +1,490 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Pricing/Index.vue b/src/resources/js/Pages/Supervisor/Pricing/Index.vue new file mode 100644 index 0000000..642c461 --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Pricing/Index.vue @@ -0,0 +1,355 @@ + + + diff --git a/src/resources/js/Pages/Supervisor/Promotions/Index.vue b/src/resources/js/Pages/Supervisor/Promotions/Index.vue new file mode 100644 index 0000000..2c019a2 --- /dev/null +++ b/src/resources/js/Pages/Supervisor/Promotions/Index.vue @@ -0,0 +1,363 @@ + + +