From 221f46ef1bdb1762e05c16a20b11ab0549d12ecf Mon Sep 17 00:00:00 2001 From: Hanchan Date: Sun, 7 Jun 2026 15:24:35 +0700 Subject: [PATCH 01/16] laravel first setup for test CI workflow --- backend/.editorconfig | 18 + backend/.env.example | 65 + backend/.gitattributes | 11 + backend/.gitignore | 24 + backend/README.md | 59 + backend/app/Http/Controllers/Controller.php | 8 + backend/app/Models/User.php | 49 + backend/app/Providers/AppServiceProvider.php | 24 + backend/artisan | 18 + backend/bootstrap/app.php | 19 + backend/bootstrap/cache/.gitignore | 2 + backend/bootstrap/providers.php | 7 + backend/composer.json | 87 + backend/composer.lock | 8475 +++++++++++++++++ backend/config/app.php | 126 + backend/config/auth.php | 117 + backend/config/cache.php | 117 + backend/config/database.php | 184 + backend/config/filesystems.php | 80 + backend/config/logging.php | 132 + backend/config/mail.php | 118 + backend/config/queue.php | 129 + backend/config/sanctum.php | 87 + backend/config/services.php | 38 + backend/config/session.php | 217 + backend/database/.gitignore | 1 + backend/database/factories/UserFactory.php | 45 + .../0001_01_01_000000_create_users_table.php | 49 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ...13_create_personal_access_tokens_table.php | 33 + backend/database/seeders/DatabaseSeeder.php | 25 + backend/package.json | 17 + backend/phpunit.xml | 36 + backend/public/.htaccess | 25 + backend/public/favicon.ico | 0 backend/public/index.php | 20 + backend/public/robots.txt | 2 + backend/resources/css/app.css | 11 + backend/resources/js/app.js | 1 + backend/resources/js/bootstrap.js | 4 + backend/resources/views/welcome.blade.php | 277 + backend/routes/api.php | 8 + backend/routes/console.php | 8 + backend/routes/web.php | 7 + backend/storage/app/.gitignore | 4 + backend/storage/app/private/.gitignore | 2 + backend/storage/app/public/.gitignore | 2 + backend/storage/framework/.gitignore | 9 + backend/storage/framework/cache/.gitignore | 3 + .../storage/framework/cache/data/.gitignore | 2 + backend/storage/framework/sessions/.gitignore | 2 + backend/storage/framework/testing/.gitignore | 2 + backend/storage/framework/views/.gitignore | 2 + backend/storage/logs/.gitignore | 2 + backend/tests/Feature/ExampleTest.php | 19 + backend/tests/TestCase.php | 10 + backend/tests/Unit/ExampleTest.php | 16 + backend/vite.config.js | 18 + 59 files changed, 10965 insertions(+) create mode 100644 backend/.editorconfig create mode 100644 backend/.env.example create mode 100644 backend/.gitattributes create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/app/Http/Controllers/Controller.php create mode 100644 backend/app/Models/User.php create mode 100644 backend/app/Providers/AppServiceProvider.php create mode 100644 backend/artisan create mode 100644 backend/bootstrap/app.php create mode 100644 backend/bootstrap/cache/.gitignore create mode 100644 backend/bootstrap/providers.php create mode 100644 backend/composer.json create mode 100644 backend/composer.lock create mode 100644 backend/config/app.php create mode 100644 backend/config/auth.php create mode 100644 backend/config/cache.php create mode 100644 backend/config/database.php create mode 100644 backend/config/filesystems.php create mode 100644 backend/config/logging.php create mode 100644 backend/config/mail.php create mode 100644 backend/config/queue.php create mode 100644 backend/config/sanctum.php create mode 100644 backend/config/services.php create mode 100644 backend/config/session.php create mode 100644 backend/database/.gitignore create mode 100644 backend/database/factories/UserFactory.php create mode 100644 backend/database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 backend/database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 backend/database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 backend/database/migrations/2026_06_07_065413_create_personal_access_tokens_table.php create mode 100644 backend/database/seeders/DatabaseSeeder.php create mode 100644 backend/package.json create mode 100644 backend/phpunit.xml create mode 100644 backend/public/.htaccess create mode 100644 backend/public/favicon.ico create mode 100644 backend/public/index.php create mode 100644 backend/public/robots.txt create mode 100644 backend/resources/css/app.css create mode 100644 backend/resources/js/app.js create mode 100644 backend/resources/js/bootstrap.js create mode 100644 backend/resources/views/welcome.blade.php create mode 100644 backend/routes/api.php create mode 100644 backend/routes/console.php create mode 100644 backend/routes/web.php create mode 100644 backend/storage/app/.gitignore create mode 100644 backend/storage/app/private/.gitignore create mode 100644 backend/storage/app/public/.gitignore create mode 100644 backend/storage/framework/.gitignore create mode 100644 backend/storage/framework/cache/.gitignore create mode 100644 backend/storage/framework/cache/data/.gitignore create mode 100644 backend/storage/framework/sessions/.gitignore create mode 100644 backend/storage/framework/testing/.gitignore create mode 100644 backend/storage/framework/views/.gitignore create mode 100644 backend/storage/logs/.gitignore create mode 100644 backend/tests/Feature/ExampleTest.php create mode 100644 backend/tests/TestCase.php create mode 100644 backend/tests/Unit/ExampleTest.php create mode 100644 backend/vite.config.js diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/backend/.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.yaml] +indent_size = 4 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c0660ea --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/backend/.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/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b71b1ea --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,24 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..0165a77 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

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

+ +## 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. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts 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. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## 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/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/backend/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ + */ + use HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/backend/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php new file mode 100644 index 0000000..c3928c5 --- /dev/null +++ b/backend/bootstrap/app.php @@ -0,0 +1,19 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/backend/bootstrap/cache/.gitignore b/backend/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/backend/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:40:51+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:30:48+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-05-23T22:00:21+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.61.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-04T14:22:52+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.18" + }, + "time": "2026-05-19T00:47:18+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:56:14+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e0be088d22278583a82da281886e8c3592fbf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bc354f47c62301e990b7874fa662326368508e2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "9df847980c436451f4f51d1284491bb4356dd989" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T08:31:43+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:20:23+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:22:37+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "8339098cae28673c15cce00d80734af0453054e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T15:23:29+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/ada7578c30dd5feaa8259cff3e885069ea81ddde", + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:19:24+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:44:50+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.62.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-05-27T04:02:01+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:06:12+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/backend/config/app.php b/backend/config/app.php new file mode 100644 index 0000000..423eed5 --- /dev/null +++ b/backend/config/app.php @@ -0,0 +1,126 @@ + 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' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | 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/backend/config/auth.php b/backend/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/backend/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/backend/config/cache.php b/backend/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/backend/config/cache.php @@ -0,0 +1,117 @@ + 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", "octane", + | "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'), + ], + + '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-'), + +]; diff --git a/backend/config/database.php b/backend/config/database.php new file mode 100644 index 0000000..64709ce --- /dev/null +++ b/backend/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([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::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([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::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/backend/config/filesystems.php b/backend/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/backend/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/backend/config/logging.php b/backend/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/backend/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/backend/config/mail.php b/backend/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/backend/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/backend/config/queue.php b/backend/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/backend/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/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/backend/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' => ['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/backend/config/services.php b/backend/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/backend/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'), + ], + ], + +]; diff --git a/backend/config/session.php b/backend/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/backend/config/session.php @@ -0,0 +1,217 @@ + 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), + +]; diff --git a/backend/database/.gitignore b/backend/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/backend/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/backend/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/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + 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(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000001_create_cache_table.php b/backend/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/backend/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000002_create_jobs_table.php b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('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->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/backend/database/migrations/2026_06_07_065413_create_personal_access_tokens_table.php b/backend/database/migrations/2026_06_07_065413_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2026_06_07_065413_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/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..6b901f8 --- /dev/null +++ b/backend/database/seeders/DatabaseSeeder.php @@ -0,0 +1,25 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7686b29 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } +} diff --git a/backend/phpunit.xml b/backend/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/backend/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/backend/public/.htaccess b/backend/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/backend/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/backend/public/favicon.ico b/backend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/backend/resources/css/app.css b/backend/resources/css/app.css new file mode 100644 index 0000000..3e6abea --- /dev/null +++ b/backend/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/backend/resources/js/app.js b/backend/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/backend/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/backend/resources/js/bootstrap.js b/backend/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/backend/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php new file mode 100644 index 0000000..b7355d7 --- /dev/null +++ b/backend/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..ccc387f --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,8 @@ +user(); +})->middleware('auth:sanctum'); diff --git a/backend/routes/console.php b/backend/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/backend/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/backend/routes/web.php b/backend/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/backend/routes/web.php @@ -0,0 +1,7 @@ +get('/'); +// +// $response->assertStatus(200); +// } +} diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/backend/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/backend/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +}); From dcf9d9cf770b6db1e3861f754f9a8f104e20d9a0 Mon Sep 17 00:00:00 2001 From: rival Date: Sun, 7 Jun 2026 20:59:03 +0700 Subject: [PATCH 02/16] Mengisi file ADR di docs --- docs/decisions/ADR-001-monorepo.md | 50 +++++++++++++++++++++++++++ docs/decisions/ADR-002-react-vite.md | 46 ++++++++++++++++++++++++ docs/decisions/ADR-003-laravel-api.md | 48 +++++++++++++++++++++++++ docs/decisions/ADR-004-postgre.md | 48 +++++++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 docs/decisions/ADR-003-laravel-api.md create mode 100644 docs/decisions/ADR-004-postgre.md diff --git a/docs/decisions/ADR-001-monorepo.md b/docs/decisions/ADR-001-monorepo.md index e69de29..06cb866 100644 --- a/docs/decisions/ADR-001-monorepo.md +++ b/docs/decisions/ADR-001-monorepo.md @@ -0,0 +1,50 @@ +# ADR-001: Monorepo Structure + +## Status + +Accepted + +## Date + +2026-06-05 + +## Context + +QueueNova Health dikerjakan oleh tim 2 developer dengan pembagian frontend dan backend. +Kami membutuhkan strategi manajemen repository yang memungkinkan: + +- Koordinasi perubahan lintas frontend dan backend dalam satu PR jika diperlukan +- Satu pipeline CI yang dapat memverifikasi keseluruhan sistem +- Kemudahan onboarding karena semua kode ada di satu tempat +- Konsistensi dokumentasi, Docker, dan konfigurasi GitHub Actions + +Alternatif yang dipertimbangkan adalah polyrepo (dua repository terpisah untuk frontend dan backend). + +## Decision + +Kami menggunakan **monorepo** dengan struktur: +project/ +├── .github/ +├── backend/ +├── infrastructure/ +├── docs/ +├── frontend/ +├── README.md +└── docker-compose.yml +Setiap bagian memiliki scope yang jelas dan tidak saling mengintervensi, +namun tetap dalam satu repository untuk kemudahan koordinasi. + +## Consequences + +### Positif + +- Satu repository untuk di-clone, satu CI pipeline untuk dikelola +- Perubahan yang membutuhkan update frontend dan backend bisa masuk dalam satu PR +- Dokumentasi dan keputusan arsitektur terpusat di `docs/` +- Docker Compose root dapat mengorkestrasikan semua service sekaligus + +### Negatif + +- CI pipeline harus didesain dengan hati-hati agar job frontend dan backend tidak saling memblokir secara tidak perlu +- Akses kontrol per-folder tidak bisa dilakukan di level repository (hanya lewat konvensi tim) +- Ukuran repository akan lebih besar seiring waktu karena semua artifact ada di satu tempat diff --git a/docs/decisions/ADR-002-react-vite.md b/docs/decisions/ADR-002-react-vite.md index e69de29..5e7966c 100644 --- a/docs/decisions/ADR-002-react-vite.md +++ b/docs/decisions/ADR-002-react-vite.md @@ -0,0 +1,46 @@ +# ADR-002: React + Vite Frontend + +## Status + +Accepted + +## Date + +2026-06-05 + +## Context + +QueueNova Health membutuhkan frontend yang mampu mendukung pengembangan antarmuka pengguna secara cepat dan maintainable oleh tim yang terdiri dari 2 developer. + +Kebutuhan frontend meliputi: + +- Pengembangan SPA (Single Page Application) dengan pengalaman pengguna yang responsif +- Dukungan ekosistem library yang luas untuk kebutuhan UI, routing, state management, dan integrasi API +- Startup development server yang cepat untuk meningkatkan produktivitas pengembangan +- Konfigurasi yang sederhana dan mudah dipahami oleh seluruh anggota tim +- Dukungan TypeScript untuk meningkatkan maintainability dan type safety + +Alternatif yang dipertimbangkan adalah Vue + Vite. + +## Decision + +Kami menggunakan **React** sebagai framework frontend dan **Vite** sebagai build tool dengan **TypeScript** sebagai bahasa utama pengembangan. + +Arsitektur frontend akan berupa Single Page Application (SPA) yang berkomunikasi dengan backend melalui REST API. + +## Consequences + +### Positif + +- Tim telah familiar dengan React sehingga onboarding dan pengembangan lebih cepat +- Ekosistem React menyediakan banyak library pendukung yang matang +- Vite memberikan startup development server yang cepat dan build yang efisien +- TypeScript membantu mengurangi kesalahan pada tahap development +- Struktur SPA sederhana dan mudah diintegrasikan dengan backend Laravel API + +### Negatif + +- SEO tidak sebaik pendekatan SSR karena aplikasi menggunakan SPA +- Pengelolaan state dapat menjadi kompleks seiring bertambahnya fitur +- Bundle frontend berpotensi bertambah besar jika dependensi tidak dikelola dengan baik +- React memberikan fleksibilitas tinggi sehingga diperlukan konvensi tim yang konsisten diff --git a/docs/decisions/ADR-003-laravel-api.md b/docs/decisions/ADR-003-laravel-api.md new file mode 100644 index 0000000..6884a85 --- /dev/null +++ b/docs/decisions/ADR-003-laravel-api.md @@ -0,0 +1,48 @@ +# ADR-003: Laravel REST API Backend + +## Status + +Accepted + +## Date + +2026-06-05 + +## Context + +QueueNova Health membutuhkan backend yang dapat mendukung pengembangan fitur sistem antrean dan appointment booking dengan cepat serta mudah dipelihara oleh tim kecil. + +Kebutuhan backend meliputi: + +- Penyediaan REST API untuk frontend +- Struktur aplikasi yang produktif dan mudah dikembangkan +- Dukungan fitur bawaan yang mengurangi kebutuhan implementasi manual +- Kemampuan menjalankan proses asynchronous menggunakan queue +- Pemisahan frontend dan backend secara jelas meskipun berada dalam satu repository + +Alternatif yang dipertimbangkan adalah NestJS. + +## Decision + +Kami menggunakan **Laravel** sebagai framework backend dan mengimplementasikan komunikasi melalui **REST API**. + +Frontend dan backend dipisahkan secara arsitektural (decoupled architecture), namun tetap berada dalam satu monorepo untuk memudahkan koordinasi pengembangan. + +Fitur queue Laravel akan digunakan untuk mendukung kebutuhan proses asynchronous apabila diperlukan pada pengembangan berikutnya. + +## Consequences + +### Positif + +- Tim telah menguasai Laravel sehingga pengembangan lebih efisien +- Banyak fitur bawaan tersedia tanpa memerlukan library tambahan +- Struktur proyek dan praktik pengembangan Laravel sudah matang +- Queue system tersedia dan siap digunakan untuk kebutuhan background processing +- REST API mudah diintegrasikan dengan frontend React + +### Negatif + +- Konsumsi resource dapat lebih tinggi dibanding framework yang lebih minimalis +- Ketergantungan terhadap ekosistem Laravel cukup besar +- Pemisahan frontend dan backend memerlukan pengelolaan kontrak API yang disiplin +- Skalabilitas horizontal memerlukan perencanaan tambahan ketika sistem berkembang diff --git a/docs/decisions/ADR-004-postgre.md b/docs/decisions/ADR-004-postgre.md new file mode 100644 index 0000000..95edfd8 --- /dev/null +++ b/docs/decisions/ADR-004-postgre.md @@ -0,0 +1,48 @@ +# ADR-004: PostgreSQL Database + +## Status + +Accepted + +## Date + +2026-06-05 + +## Context + +QueueNova Health membutuhkan database yang mampu menyimpan data relasional untuk sistem antrean dan appointment booking secara konsisten dan andal. + +Kebutuhan utama meliputi: + +- Penyimpanan data relasional yang terstruktur +- Dukungan transaksi yang konsisten +- Kemampuan mendukung kebutuhan reporting +- Solusi open source yang dapat digunakan tanpa biaya lisensi +- Kemudahan integrasi dengan Laravel + +Alternatif yang dipertimbangkan adalah MySQL. + +## Decision + +Kami menggunakan **PostgreSQL** sebagai database utama aplikasi. + +PostgreSQL dipilih karena bersifat open source, memiliki kepatuhan ACID yang kuat, serta mampu mendukung kebutuhan data relasional dan reporting yang menjadi fokus aplikasi. + +Pada tahap awal, sistem menggunakan satu instance database. + +## Consequences + +### Positif + +- Open source dan bebas biaya lisensi +- Menyediakan implementasi ACID yang kuat untuk menjaga konsistensi data +- Cocok untuk data relasional yang menjadi kebutuhan utama aplikasi +- Mendukung kebutuhan reporting dengan baik +- Integrasi dengan Laravel sudah matang dan stabil + +### Negatif + +- Membutuhkan pemahaman administrasi database yang lebih baik dibanding solusi yang lebih sederhana +- Scaling database memerlukan perencanaan tambahan ketika beban meningkat +- Operasional backup dan recovery tetap perlu dikelola secara disiplin +- Satu instance database menjadi single point of failure apabila tidak disertai strategi redundansi From b1dfa4558073aef607159493278af14a6c22d7bc Mon Sep 17 00:00:00 2001 From: Hanchan Date: Mon, 8 Jun 2026 00:54:12 +0700 Subject: [PATCH 03/16] fix: correct typo in CI workflow configuration. chore: change mysql into postgres in laravel & CI --- .github/workflows/ci.yml | 43 ++++++++++++++------------- backend/.env.example | 12 ++++---- backend/phpunit.xml | 8 +++-- backend/tests/Feature/ExampleTest.php | 12 ++++---- 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8354976..9d8a19a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: pull_request: branches: - main - - develop + - development jobs: frontend: @@ -48,15 +48,16 @@ jobs: working-directory: backend services: - mysql: - image: mysql:8.0 + postgres: + image: postgres:16 env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: testing + POSTGRES_DB: testing + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres ports: - - 3306:3306 + - 5432:5432 options: >- - --health-cmd="mysqladmin ping" + --health-cmd="pg_isready -U postgres" --health-interval=10s --health-timeout=5s --health-retries=3 @@ -69,37 +70,39 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: "8.3" - extensions: mbstring, bcmath, pdo, pdo_mysql + extensions: mbstring, bcmath, pdo, pdo_pgsql coverage: xdebug - name: Copy .env - run: cp .env.example .env.testing + run: cp .env.example .env - name: Install dependencies run: composer install --no-interaction --prefer-dist --optimize-autoloader - name: Generate app key - run: php artisan key:generate --env=testing + run: php artisan key:generate - name: Run migrations - run: php artisan migrate --env=testing --force + run: php artisan migrate --force env: - DB_CONNECTION: mysql + XDEBUG_MODE: off + DB_CONNECTION: pgsql DB_HOST: 127.0.0.1 - DB_PORT: 3306 + DB_PORT: 5432 DB_DATABASE: testing - DB_USERNAME: root - DB_PASSWORD: root + DB_USERNAME: postgres + DB_PASSWORD: postgres - name: Run tests - run: php artisan test --env=testing + run: php artisan test env: - DB_CONNECTION: mysql + XDEBUG_MODE: off + DB_CONNECTION: pgsql DB_HOST: 127.0.0.1 - DB_PORT: 3306 + DB_PORT: 5432 DB_DATABASE: testing - DB_USERNAME: root - DB_PASSWORD: root + DB_USERNAME: postgres + DB_PASSWORD: postgres docker-build: name: Docker Build Check diff --git a/backend/.env.example b/backend/.env.example index c0660ea..9426f08 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,12 +20,12 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=laravel +DB_USERNAME=postgres +DB_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 diff --git a/backend/phpunit.xml b/backend/phpunit.xml index e7f0a48..48e0914 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -23,8 +23,12 @@ - - + + + + + + diff --git a/backend/tests/Feature/ExampleTest.php b/backend/tests/Feature/ExampleTest.php index 5790d91..8364a84 100644 --- a/backend/tests/Feature/ExampleTest.php +++ b/backend/tests/Feature/ExampleTest.php @@ -10,10 +10,10 @@ class ExampleTest extends TestCase /** * A basic test example. */ -// public function test_the_application_returns_a_successful_response(): void -// { -// $response = $this->get('/'); -// -// $response->assertStatus(200); -// } + public function test_the_application_returns_a_successful_response(): void + { + $response = $this->get('/'); + + $response->assertStatus(200); + } } From b11c927440f7a4bc3061e51a2cf092060ee5747d Mon Sep 17 00:00:00 2001 From: Hanchan Date: Fri, 12 Jun 2026 01:01:40 +0700 Subject: [PATCH 04/16] temporarily disable docker flow on ci workflow --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d8a19a..e31bf03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,8 @@ jobs: docker-build: name: Docker Build Check runs-on: ubuntu-latest - needs: [frontend, backend] + needs: [frontend] +# needs: [frontend, backend] steps: - name: Checkout repository @@ -116,5 +117,5 @@ jobs: - name: Build frontend image run: docker build -t queuenova-frontend ./frontend - - name: Build backend image - run: docker build -t queuenova-backend ./backend \ No newline at end of file +# - name: Build backend image +# run: docker build -t queuenova-backend ./backend \ No newline at end of file From f96575829638f5765cc584c0e44a83faf9a44ec6 Mon Sep 17 00:00:00 2001 From: Hanchan Date: Sat, 13 Jun 2026 23:51:46 +0700 Subject: [PATCH 05/16] add api specs to docs folder --- docs/openapi_v2/openapi.yaml | 204 +++++++++++ docs/openapi_v2/paths/auth.yaml | 174 ++++++++++ docs/openapi_v2/paths/departments.yaml | 184 ++++++++++ docs/openapi_v2/paths/doctors.yaml | 211 ++++++++++++ docs/openapi_v2/paths/nurses.yaml | 197 +++++++++++ docs/openapi_v2/paths/patients.yaml | 104 ++++++ docs/openapi_v2/paths/queues.yaml | 193 +++++++++++ docs/openapi_v2/paths/reports.yaml | 110 ++++++ docs/openapi_v2/paths/reservations.yaml | 316 ++++++++++++++++++ docs/openapi_v2/paths/schedule_instances.yaml | 207 ++++++++++++ docs/openapi_v2/paths/schedule_templates.yaml | 210 ++++++++++++ docs/openapi_v2/schemas/auth.yaml | 98 ++++++ docs/openapi_v2/schemas/common.yaml | 45 +++ docs/openapi_v2/schemas/department.yaml | 46 +++ docs/openapi_v2/schemas/doctor.yaml | 128 +++++++ docs/openapi_v2/schemas/nurse.yaml | 116 +++++++ docs/openapi_v2/schemas/patient.yaml | 80 +++++ docs/openapi_v2/schemas/queue.yaml | 148 ++++++++ docs/openapi_v2/schemas/reservation.yaml | 123 +++++++ docs/openapi_v2/schemas/schedule.yaml | 199 +++++++++++ 20 files changed, 3093 insertions(+) create mode 100644 docs/openapi_v2/openapi.yaml create mode 100644 docs/openapi_v2/paths/auth.yaml create mode 100644 docs/openapi_v2/paths/departments.yaml create mode 100644 docs/openapi_v2/paths/doctors.yaml create mode 100644 docs/openapi_v2/paths/nurses.yaml create mode 100644 docs/openapi_v2/paths/patients.yaml create mode 100644 docs/openapi_v2/paths/queues.yaml create mode 100644 docs/openapi_v2/paths/reports.yaml create mode 100644 docs/openapi_v2/paths/reservations.yaml create mode 100644 docs/openapi_v2/paths/schedule_instances.yaml create mode 100644 docs/openapi_v2/paths/schedule_templates.yaml create mode 100644 docs/openapi_v2/schemas/auth.yaml create mode 100644 docs/openapi_v2/schemas/common.yaml create mode 100644 docs/openapi_v2/schemas/department.yaml create mode 100644 docs/openapi_v2/schemas/doctor.yaml create mode 100644 docs/openapi_v2/schemas/nurse.yaml create mode 100644 docs/openapi_v2/schemas/patient.yaml create mode 100644 docs/openapi_v2/schemas/queue.yaml create mode 100644 docs/openapi_v2/schemas/reservation.yaml create mode 100644 docs/openapi_v2/schemas/schedule.yaml diff --git a/docs/openapi_v2/openapi.yaml b/docs/openapi_v2/openapi.yaml new file mode 100644 index 0000000..59384d4 --- /dev/null +++ b/docs/openapi_v2/openapi.yaml @@ -0,0 +1,204 @@ +openapi: 3.0.3 + +# ============================================================================= +# Hospital Queue Management System — API Contract v2.1 (UI-First) +# ============================================================================= +# Stack : Laravel 11 (API) + React SPA +# Auth : Laravel Sanctum — plain-text Bearer Token +# Approach : UI-First — disesuaikan dengan Figma design +# ERD : v2 (schedule_templates + schedule_instances, field baru) +# +# PERUBAHAN UTAMA dari v2.0: +# --------------------------- +# 1. /schedules SPLIT menjadi: +# - /doctors/{id}/schedule-templates (recurring pattern) +# - /schedule-instances (tanggal aktual, auto-generated) +# 2. Patients: tambah bpjs_number, birth_place, birth_date, gender +# 3. Doctors : tambah sip_number, birth_date, gender +# 4. Nurses : tambah sip_number, birth_date, gender +# 5. Reservations: tambah complaint (keluhan pasien) +# 6. POST /reservations: FK ke instance_id (bukan schedule_id) +# 7. Endpoint baru: GET /patients/me (profil pasien sendiri) +# 8. Endpoint baru: PATCH /patients/me (update profil pasien) +# +# CARA PAKAI TOKEN SANCTUM +# ------------------------- +# 1. POST /auth/login → dapat data.token +# 2. Simpan token di React state/memory (BUKAN localStorage) +# 3. Sertakan di setiap request: Authorization: Bearer {token} +# 4. POST /auth/logout → token di-revoke +# +# ROLES +# ----- +# patient | doctor | nurse | admin +# +# QUEUE STATE MACHINE +# ------------------- +# booked → checked-in → waiting → called → in-progress → done +# ↘ no-show +# ↘ cancelled +# ============================================================================= + +info: + title: Hospital Queue Management System API + version: 2.1.0 + description: | + REST API untuk sistem manajemen antrian rumah sakit QueueNova Health. + Disesuaikan dengan UI Figma (UI-First approach). + + ### Perubahan dari v2.0 + - Schedule sekarang menggunakan sistem **template + instance** (Hybrid) + - Field baru di Patient, Doctor, Nurse (SIP, gender, tanggal lahir) + - Field `complaint` di Reservation (keluhan pasien) + - Endpoint profil pasien (`/patients/me`) + +servers: + - url: http://localhost:8000/api + description: Local Development Server + - url: https://api.queuenova.example.com/api + description: Production Server + +components: + securitySchemes: + sanctumToken: + type: http + scheme: bearer + bearerFormat: SanctumToken + description: | + Sanctum plain-text token dari POST /auth/login. + Header: `Authorization: Bearer {token}` + + schemas: + # Auth + RegisterRequest: { $ref: './schemas/auth.yaml#/RegisterRequest' } + LoginRequest: { $ref: './schemas/auth.yaml#/LoginRequest' } + LoginResponse: { $ref: './schemas/auth.yaml#/LoginResponse' } + UserResource: { $ref: './schemas/auth.yaml#/UserResource' } + + # Patient profile + PatientProfileResource: { $ref: './schemas/patient.yaml#/PatientProfileResource' } + PatientProfileRequest: { $ref: './schemas/patient.yaml#/PatientProfileRequest' } + + # Department + DepartmentResource: { $ref: './schemas/department.yaml#/DepartmentResource' } + DepartmentRequest: { $ref: './schemas/department.yaml#/DepartmentRequest' } + + # Doctor + DoctorResource: { $ref: './schemas/doctor.yaml#/DoctorResource' } + DoctorRequest: { $ref: './schemas/doctor.yaml#/DoctorRequest' } + DoctorUpdateRequest: { $ref: './schemas/doctor.yaml#/DoctorUpdateRequest' } + + # Nurse + NurseResource: { $ref: './schemas/nurse.yaml#/NurseResource' } + NurseRequest: { $ref: './schemas/nurse.yaml#/NurseRequest' } + NurseUpdateRequest: { $ref: './schemas/nurse.yaml#/NurseUpdateRequest' } + + # Schedule Template (recurring) + ScheduleTemplateResource: { $ref: './schemas/schedule.yaml#/ScheduleTemplateResource' } + ScheduleTemplateRequest: { $ref: './schemas/schedule.yaml#/ScheduleTemplateRequest' } + + # Schedule Instance (tanggal aktual) + ScheduleInstanceResource: { $ref: './schemas/schedule.yaml#/ScheduleInstanceResource' } + ScheduleInstanceRequest: { $ref: './schemas/schedule.yaml#/ScheduleInstanceRequest' } + + # Reservation + ReservationResource: { $ref: './schemas/reservation.yaml#/ReservationResource' } + ReservationRequest: { $ref: './schemas/reservation.yaml#/ReservationRequest' } + + # Queue + QueueResource: { $ref: './schemas/queue.yaml#/QueueResource' } + QueueStatusRequest: { $ref: './schemas/queue.yaml#/QueueStatusRequest' } + QueueReportResource: { $ref: './schemas/queue.yaml#/QueueReportResource' } + + # Common + SuccessResponse: { $ref: './schemas/common.yaml#/SuccessResponse' } + ErrorResponse: { $ref: './schemas/common.yaml#/ErrorResponse' } + ValidationErrorResponse: { $ref: './schemas/common.yaml#/ValidationErrorResponse' } + PaginationMeta: { $ref: './schemas/common.yaml#/PaginationMeta' } + +tags: + - name: Authentication + description: "**EPIC 1** — Register, login, logout" + - name: Patient Profile + description: "**EPIC 1/3** — Profil & data diri pasien" + - name: Departments + description: "**EPIC 2** — CRUD departemen (Admin)" + - name: Doctors + description: "**EPIC 2** — CRUD dokter (Admin)" + - name: Nurses + description: "**EPIC 2** — CRUD suster (Admin)" + - name: Schedule Templates + description: "**EPIC 2** — Template jadwal berulang per dokter (Admin)" + - name: Schedule Instances + description: "**EPIC 2/3** — Jadwal aktual per tanggal (auto-generated + override)" + - name: Booking + description: "**EPIC 3** — Reservasi pasien" + - name: Queue + description: "**EPIC 4** — Antrian (Nurse update, Doctor & Nurse view)" + - name: Reports + description: "**EPIC 4** — Laporan antrian (Admin)" + +paths: + # ---- EPIC 1: Auth ---- + /auth/register: + $ref: './paths/auth.yaml#/~1auth~1register' + /auth/login: + $ref: './paths/auth.yaml#/~1auth~1login' + /auth/logout: + $ref: './paths/auth.yaml#/~1auth~1logout' + /auth/me: + $ref: './paths/auth.yaml#/~1auth~1me' + + # ---- Patient Profile ---- + /patients/me: + $ref: './paths/patients.yaml#/~1patients~1me' + + # ---- EPIC 2: Departments ---- + /departments: + $ref: './paths/departments.yaml#/~1departments' + /departments/{id}: + $ref: './paths/departments.yaml#/~1departments~1{id}' + + # ---- EPIC 2: Doctors ---- + /doctors: + $ref: './paths/doctors.yaml#/~1doctors' + /doctors/{id}: + $ref: './paths/doctors.yaml#/~1doctors~1{id}' + + # ---- EPIC 2: Nurses ---- + /nurses: + $ref: './paths/nurses.yaml#/~1nurses' + /nurses/{id}: + $ref: './paths/nurses.yaml#/~1nurses~1{id}' + + # ---- EPIC 2: Schedule Templates (recurring) ---- + /doctors/{id}/schedule-templates: + $ref: './paths/schedule_templates.yaml#/~1doctors~1{id}~1schedule-templates' + /doctors/{id}/schedule-templates/{templateId}: + $ref: './paths/schedule_templates.yaml#/~1doctors~1{id}~1schedule-templates~1{templateId}' + + # ---- EPIC 2/3: Schedule Instances (tanggal aktual) ---- + # Admin: override/cancel per instance + # Patient: GET untuk melihat jadwal tersedia sebelum booking + /schedule-instances: + $ref: './paths/schedule_instances.yaml#/~1schedule-instances' + /schedule-instances/{instanceId}: + $ref: './paths/schedule_instances.yaml#/~1schedule-instances~1{instanceId}' + + # ---- EPIC 3: Reservations ---- + /reservations: + $ref: './paths/reservations.yaml#/~1reservations' + /reservations/{id}: + $ref: './paths/reservations.yaml#/~1reservations~1{id}' + /reservations/{id}/cancel: + $ref: './paths/reservations.yaml#/~1reservations~1{id}~1cancel' + + # ---- EPIC 4: Queue ---- + /queues: + $ref: './paths/queues.yaml#/~1queues' + /queues/{id}/status: + $ref: './paths/queues.yaml#/~1queues~1{id}~1status' + + # ---- EPIC 4: Reports ---- + /reports/queues: + $ref: './paths/reports.yaml#/~1reports~1queues' diff --git a/docs/openapi_v2/paths/auth.yaml b/docs/openapi_v2/paths/auth.yaml new file mode 100644 index 0000000..2064e43 --- /dev/null +++ b/docs/openapi_v2/paths/auth.yaml @@ -0,0 +1,174 @@ +# ============================================================================= +# Paths — EPIC 1: Authentication +# Dari Figma: app name = "QueueNova Health", brand = "QueueNova" +# Login screen: email + password + "Remember me" + "Forgot Password?" +# Register screen: nama, email, password, konfirmasi password +# ============================================================================= + +/auth/register: + post: + tags: [Authentication] + summary: Register pasien baru + description: | + Registrasi khusus **Pasien**. Doctor/Nurse/Admin didaftarkan oleh Admin. + Dari Figma register screen: field Name, Email, Password, Password Confirmation. + Data profil lengkap (BPJS, tempat lahir, dll) dilengkapi via `PATCH /patients/me`. + operationId: registerPatient + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/auth.yaml#/RegisterRequest' + example: + name: "Ucok Sitorus" + email: "ucok@example.com" + password: "password123" + password_confirmation: "password123" + phone: "081234567890" + responses: + '201': + description: Registrasi berhasil + content: + application/json: + example: + message: "Registrasi berhasil. Silakan login." + data: + user_id: 10 + email: "ucok@example.com" + role: "patient" + status: "active" + profile: + patient_id: 5 + name: "Ucok Sitorus" + phone: "081234567890" + bpjs_number: null + birth_place: null + birth_date: null + gender: null + '422': + description: Validasi gagal + content: + application/json: + schema: + $ref: '../schemas/common.yaml#/ValidationErrorResponse' + example: + message: "The given data was invalid." + errors: + email: ["The email has already been taken."] + +/auth/login: + post: + tags: [Authentication] + summary: Login — semua role (Patient, Doctor, Nurse, Admin) + description: | + Dari Figma login screen (admin & patient): + - Field: Email (`example@gmail.com`), Password (`@#*%`) + - Checkbox "Remember me" — implementasi di frontend (tidak di API) + - Link "Forgot Password?" — belum dalam scope + + Mengembalikan Sanctum plain-text Bearer Token. + operationId: loginUser + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/auth.yaml#/LoginRequest' + example: + email: "ucok@example.com" + password: "password123" + responses: + '200': + description: Login berhasil — token dikembalikan + content: + application/json: + schema: + $ref: '../schemas/auth.yaml#/LoginResponse' + example: + message: "Login berhasil." + data: + token: "3|aB1cUcokD2eF3gH4iJ5kL6" + token_type: "Bearer" + user: + user_id: 10 + email: "ucok@example.com" + role: "patient" + status: "active" + profile: + patient_id: 5 + name: "Ucok Sitorus" + phone: "081234567890" + bpjs_number: "0001234567890" + birth_place: "Medan" + birth_date: "1995-06-15" + gender: "laki-laki" + '401': + description: Email atau password salah + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + example: { message: "Email atau password salah." } + '422': + description: Validasi gagal + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } + +/auth/logout: + post: + tags: [Authentication] + summary: Logout — revoke token aktif + operationId: logoutUser + security: + - sanctumToken: [] + responses: + '200': + description: Logout berhasil + content: + application/json: + example: { message: "Logout berhasil." } + '401': + description: Token tidak valid + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + example: { message: "Unauthenticated." } + +/auth/me: + get: + tags: [Authentication] + summary: Ambil data user yang sedang login + description: | + Berguna untuk inisialisasi React SPA setelah refresh. + Profile bervariasi per role — lihat masing-masing Resource schema. + operationId: getAuthenticatedUser + security: + - sanctumToken: [] + responses: + '200': + description: Data user + content: + application/json: + example: + data: + user_id: 10 + email: "ucok@example.com" + role: "nurse" + status: "active" + profile: + nurse_id: 2 + name: "Ucok Sihombing" + sip_number: "112345678" + gender: "laki-laki" + birth_date: "1990-07-10" + department: + department_id: 1 + name: "Poli Umum" + '401': + description: Token tidak valid + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } diff --git a/docs/openapi_v2/paths/departments.yaml b/docs/openapi_v2/paths/departments.yaml new file mode 100644 index 0000000..d44904b --- /dev/null +++ b/docs/openapi_v2/paths/departments.yaml @@ -0,0 +1,184 @@ +# ============================================================================= +# Paths — EPIC 2: Departments +# Dari Figma admin-department-dashboard: list card dengan nama + aksi Delete/Edit/Info +# Dari Figma patient-view-department: card poli dengan nama, deskripsi, jumlah dokter +# ============================================================================= + +/departments: + get: + tags: [Departments] + summary: Lihat daftar departemen / poli + description: | + Semua role yang login dapat melihat daftar poli. + Pasien menggunakan ini sebagai halaman awal booking. + Dari Figma: menampilkan nama, deskripsi singkat, jumlah dokter tersedia. + operationId: getDepartments + security: + - sanctumToken: [] + parameters: + - name: search + in: query + required: false + description: "Dari Figma: 'Cari dokter atau poli...'" + schema: { type: string, example: "Poli Umum" } + responses: + '200': + description: Daftar departemen + content: + application/json: + example: + data: + - department_id: 1 + name: "Poli Umum" + description: "Pemeriksaan Kesehatan Umum" + doctors_count: 3 + - department_id: 2 + name: "Poli Mata" + description: "Pemeriksaan Penglihatan dan Kesehatan Mata" + doctors_count: 2 + - department_id: 3 + name: "Poli Kebidanan dan Kandungan" + description: "Pemeriksaan Kesehatan Kandungan" + doctors_count: 2 + '401': + description: Tidak terautentikasi + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + post: + tags: [Departments] + summary: Buat departemen baru + description: "**Admin only**. Dari Figma form: Department Name + Description." + operationId: createDepartment + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/department.yaml#/DepartmentRequest' } + example: + name: "Poli Umum" + description: "Pusat layanan kesehatan primer untuk diagnosa awal, pengobatan umum, dan rujukan" + responses: + '201': + description: Departemen dibuat + content: + application/json: + example: + message: "Departemen berhasil dibuat." + data: + department_id: 4 + name: "Poli Umum" + description: "Pusat layanan kesehatan primer untuk diagnosa awal, pengobatan umum, dan rujukan" + doctors_count: 0 + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '422': + description: Validasi gagal + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } + +/departments/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: integer, example: 1 } + + get: + tags: [Departments] + summary: Detail departemen + list dokter + description: | + Dari Figma admin-department-detail: + - Info: Nama poli, deskripsi panjang + - "List Dokter Poli Umum": tabel No., Nama Dokter, No SIP, Jenis Kelamin, Aksi + operationId: getDepartmentById + security: + - sanctumToken: [] + responses: + '200': + description: Detail departemen + content: + application/json: + example: + data: + department_id: 1 + name: "Poli Umum" + description: "Pusat layanan kesehatan primer untuk diagnosa awal, pengobatan umum, dan rujukan" + doctors_count: 4 + doctors: + - doctor_id: 1 + name: "dr. Ucok Napitupulu" + sip_number: "12345" + gender: "laki-laki" + - doctor_id: 2 + name: "dr. Tirta Pengpeng" + sip_number: "321321" + gender: "laki-laki" + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + patch: + tags: [Departments] + summary: Update departemen + description: "**Admin only** — partial update. Dari Figma: field Department Name + Description." + operationId: updateDepartment + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/department.yaml#/DepartmentRequest' } + example: + name: "Poli Umum & Gawat Darurat" + responses: + '200': + description: Departemen diperbarui + content: + application/json: + example: + message: "Departemen berhasil diperbarui." + data: + department_id: 1 + name: "Poli Umum & Gawat Darurat" + description: "Pusat layanan kesehatan primer untuk diagnosa awal, pengobatan umum, dan rujukan" + doctors_count: 4 + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + delete: + tags: [Departments] + summary: Hapus departemen + description: "**Admin only**. Gagal jika masih ada dokter aktif." + operationId: deleteDepartment + security: + - sanctumToken: [] + responses: + '200': + description: Dihapus + content: + application/json: + example: { message: "Departemen berhasil dihapus." } + '409': + description: Masih ada dokter aktif + content: + application/json: + example: { message: "Departemen tidak dapat dihapus karena masih memiliki dokter aktif." } diff --git a/docs/openapi_v2/paths/doctors.yaml b/docs/openapi_v2/paths/doctors.yaml new file mode 100644 index 0000000..5e5f5af --- /dev/null +++ b/docs/openapi_v2/paths/doctors.yaml @@ -0,0 +1,211 @@ +# ============================================================================= +# Paths — EPIC 2: Doctors +# Dari Figma admin-doctor-dashboard: list nama dokter + Delete/Edit/Info +# Dari Figma admin-doctor-form: Doctor Name, No SIP, Jenis Kelamin, +# Tanggal Lahir, Spesialisasi, Department, Email, Password +# Dari Figma admin-doctor-detail: Info Dokter + tabel Schedule (Hari, Pukul, Aksi) +# ============================================================================= + +/doctors: + get: + tags: [Doctors] + summary: Lihat daftar dokter + operationId: getDoctors + security: + - sanctumToken: [] + parameters: + - name: department_id + in: query + required: false + schema: { type: integer, example: 1 } + - name: search + in: query + required: false + schema: { type: string, example: "dr. Ucok" } + responses: + '200': + description: Daftar dokter + content: + application/json: + example: + data: + - doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + sip_number: "112345678" + gender: "laki-laki" + birth_date: "1980-03-20" + doctor_status: "active" + department: + department_id: 1 + name: "Poli Umum" + user_id: 5 + + post: + tags: [Doctors] + summary: Daftarkan dokter baru + description: | + **Admin only**. Dari Figma form doctor: + Doctor Name, No SIP, Jenis Kelamin, Tanggal Lahir, Spesialisasi, Department, Email, Password. + operationId: createDoctor + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/doctor.yaml#/DoctorRequest' } + example: + name: "dr. Ucok Napitupulu, Sp.PD" + email: "ucok.dokter@hospital.com" + password: "password123" + specialization: "Spesialis Penyakit Dalam" + department_id: 1 + sip_number: "112345678" + birth_date: "1980-03-20" + gender: "laki-laki" + responses: + '201': + description: Dokter didaftarkan + content: + application/json: + example: + message: "Dokter berhasil didaftarkan." + data: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + sip_number: "112345678" + gender: "laki-laki" + birth_date: "1980-03-20" + doctor_status: "active" + department: + department_id: 1 + name: "Poli Umum" + user_id: 5 + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '422': + description: Validasi gagal + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } + +/doctors/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: integer, example: 1 } + + get: + tags: [Doctors] + summary: Detail dokter + daftar template jadwal + description: | + Dari Figma admin-doctor-detail: + - Section "Informasi Dokter": Nama, No SIP, Jenis Kelamin, Spesialisasi, Email, Department + - Section "Schedule": tabel Hari + Jam + Aksi (Edit/Delete) + tombol "Add Schedule" + operationId: getDoctorById + security: + - sanctumToken: [] + responses: + '200': + description: Detail dokter + content: + application/json: + example: + data: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + sip_number: "112345678" + gender: "laki-laki" + birth_date: "1980-03-20" + doctor_status: "active" + department: + department_id: 1 + name: "Poli Umum" + user_id: 5 + # Template jadwal ditampilkan di halaman detail dokter Figma + schedule_templates: + - template_id: 1 + day_of_week: "senin" + start_time: "09:00" + end_time: "15:00" + max_patients: 10 + is_active: true + - template_id: 2 + day_of_week: "selasa" + start_time: "09:00" + end_time: "15:00" + max_patients: 10 + is_active: true + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + patch: + tags: [Doctors] + summary: Update data dokter + description: "**Admin only** — partial update. Email/password tidak diubah di sini." + operationId: updateDoctor + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/doctor.yaml#/DoctorUpdateRequest' } + example: + specialization: "Spesialis Penyakit Dalam & Konsultan Ginjal" + sip_number: "112345679" + responses: + '200': + description: Data diperbarui + content: + application/json: + example: + message: "Data dokter berhasil diperbarui." + data: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam & Konsultan Ginjal" + sip_number: "112345679" + doctor_status: "active" + department: + department_id: 1 + name: "Poli Umum" + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + delete: + tags: [Doctors] + summary: Hapus dokter + description: "**Admin only**. Hapus users + doctors. Gagal jika ada reservasi aktif." + operationId: deleteDoctor + security: + - sanctumToken: [] + responses: + '200': + description: Dihapus + content: + application/json: + example: { message: "Dokter berhasil dihapus." } + '409': + description: Masih ada reservasi aktif + content: + application/json: + example: { message: "Dokter tidak dapat dihapus karena masih memiliki reservasi aktif." } diff --git a/docs/openapi_v2/paths/nurses.yaml b/docs/openapi_v2/paths/nurses.yaml new file mode 100644 index 0000000..550acd8 --- /dev/null +++ b/docs/openapi_v2/paths/nurses.yaml @@ -0,0 +1,197 @@ +# ============================================================================= +# Paths — EPIC 2: Nurses +# Dari Figma admin-nurse-form: Nurse Name, No SIP, Jenis Kelamin, +# Tanggal Lahir, Email, Password +# Dari Figma admin-nurse-detail: Informasi Nurse — Nama, No SIP, Jenis Kelamin, Email +# ============================================================================= + +/nurses: + get: + tags: [Nurses] + summary: Lihat daftar suster + description: "**Admin only**" + operationId: getNurses + security: + - sanctumToken: [] + parameters: + - name: search + in: query + required: false + schema: { type: string, example: "Ucok" } + - name: department_id + in: query + required: false + schema: { type: integer, example: 1 } + responses: + '200': + description: Daftar suster + content: + application/json: + example: + data: + - nurse_id: 1 + name: "Ucok Sihombing" + sip_number: "112345678" + gender: "laki-laki" + birth_date: "1990-07-10" + department: + department_id: 1 + name: "Poli Umum" + user_id: 8 + - nurse_id: 2 + name: "Bunda Rahma" + sip_number: "123456789" + gender: "perempuan" + birth_date: "1988-03-15" + department: null + user_id: 9 + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + post: + tags: [Nurses] + summary: Daftarkan suster baru + description: | + **Admin only**. Dari Figma form nurse: + Nurse Name, No SIP, Jenis Kelamin, Tanggal Lahir, Email, Password. + operationId: createNurse + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/nurse.yaml#/NurseRequest' } + example: + name: "Ucok Sihombing" + email: "ucok.nurse@hospital.com" + password: "password123" + department_id: 1 + sip_number: "112345678" + birth_date: "1990-07-10" + gender: "laki-laki" + responses: + '201': + description: Suster didaftarkan + content: + application/json: + example: + message: "Suster berhasil didaftarkan." + data: + nurse_id: 1 + name: "Ucok Sihombing" + sip_number: "112345678" + gender: "laki-laki" + birth_date: "1990-07-10" + department: + department_id: 1 + name: "Poli Umum" + user_id: 8 + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '422': + description: Validasi gagal + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } + +/nurses/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: integer, example: 1 } + + get: + tags: [Nurses] + summary: Detail suster + description: | + **Admin only**. Dari Figma admin-nurse-detail: + Informasi Nurse: No SIP, Jenis Kelamin, Email, Nama Nurse. + operationId: getNurseById + security: + - sanctumToken: [] + responses: + '200': + description: Detail suster + content: + application/json: + example: + data: + nurse_id: 2 + name: "Bunda Rahma" + sip_number: "123456789" + gender: "perempuan" + birth_date: "1988-03-15" + department: null + user_id: 9 + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + patch: + tags: [Nurses] + summary: Update data suster + description: "**Admin only** — partial update." + operationId: updateNurse + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/nurse.yaml#/NurseUpdateRequest' } + example: + name: "Bunda Rahma, S.Kep" + sip_number: "123456789" + responses: + '200': + description: Data diperbarui + content: + application/json: + example: + message: "Data suster berhasil diperbarui." + data: + nurse_id: 2 + name: "Bunda Rahma, S.Kep" + sip_number: "123456789" + gender: "perempuan" + department: null + user_id: 9 + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + delete: + tags: [Nurses] + summary: Hapus suster + description: "**Admin only**. Hapus users + nurses." + operationId: deleteNurse + security: + - sanctumToken: [] + responses: + '200': + description: Dihapus + content: + application/json: + example: { message: "Suster berhasil dihapus." } + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } diff --git a/docs/openapi_v2/paths/patients.yaml b/docs/openapi_v2/paths/patients.yaml new file mode 100644 index 0000000..f0e76c2 --- /dev/null +++ b/docs/openapi_v2/paths/patients.yaml @@ -0,0 +1,104 @@ +# ============================================================================= +# Paths — Patient Profile +# [BARU v2.1] Dari Figma form Create Reservation: +# Section "Informasi Pasien" menampilkan: +# Nama, No. BPJS, Tempat lahir, Tanggal lahir +# Data ini perlu ada di profil pasien sebelum booking. +# Pasien bisa lihat dan update profil sendiri. +# ============================================================================= + +/patients/me: + get: + tags: [Patient Profile] + summary: Lihat profil pasien yang sedang login + description: | + Mengembalikan profil lengkap pasien yang sedang login. + Data ini ditampilkan di section "Informasi Pasien" pada form Create Reservation Figma: + Nama, No. BPJS, Tempat Lahir, Tanggal Lahir. + operationId: getMyPatientProfile + security: + - sanctumToken: [] + responses: + '200': + description: Profil pasien + content: + application/json: + schema: + type: object + properties: + data: + $ref: '../schemas/patient.yaml#/PatientProfileResource' + example: + data: + patient_id: 5 + name: "Ucok Sitorus" + phone: "081234567890" + bpjs_number: "0001234567890" + birth_place: "Medan" + birth_date: "1995-06-15" + gender: "laki-laki" + user_id: 10 + '401': + description: Tidak terautentikasi + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '403': + description: Bukan role patient + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + example: { message: "Endpoint ini hanya untuk pasien." } + + patch: + tags: [Patient Profile] + summary: Update profil pasien sendiri + description: | + Pasien melengkapi data profil — BPJS, tempat lahir, tanggal lahir, gender. + Data ini muncul di form Create Reservation (pre-filled dari profil). + Partial update — kirim hanya field yang ingin diubah. + operationId: updateMyPatientProfile + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/patient.yaml#/PatientProfileRequest' + example: + bpjs_number: "0001234567890" + birth_place: "Medan" + birth_date: "1995-06-15" + gender: "laki-laki" + responses: + '200': + description: Profil berhasil diperbarui + content: + application/json: + example: + message: "Profil berhasil diperbarui." + data: + patient_id: 5 + name: "Ucok Sitorus" + phone: "081234567890" + bpjs_number: "0001234567890" + birth_place: "Medan" + birth_date: "1995-06-15" + gender: "laki-laki" + user_id: 10 + '401': + description: Tidak terautentikasi + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '403': + description: Bukan role patient + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '422': + description: Validasi gagal + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } diff --git a/docs/openapi_v2/paths/queues.yaml b/docs/openapi_v2/paths/queues.yaml new file mode 100644 index 0000000..be709fa --- /dev/null +++ b/docs/openapi_v2/paths/queues.yaml @@ -0,0 +1,193 @@ +# ============================================================================= +# Paths — EPIC 4: Queue +# Dari Figma nurse-queue-list: +# - Header: "Poli Anda hari ini", "Lihat dan kelola semua bagian Anda" +# - Jadwal cards: Poli + Jam + Total Antrian + "Yang sudah check-in" +# - Filter tabs: Semua | Booked | Called | Done +# - Tabel: No. Antrian, Nama Pasien, Status, Estimasi Waktu Tunggu, Catatan +# Dari Figma nurse-chaos-mode: +# - Card alert: "TERLAMBAT 25 MENIT", nama dokter, tombol "BERITAHU PASIEN" +# - "Poli yang tersedia untuk perpindahan pasien" + slot antrian +# Dari Figma doctor-queue-list: +# - Kolom tambahan: Keluhan, Hasil Skrining, Jenis Kelamin +# ============================================================================= + +/queues: + get: + tags: [Queue] + summary: Lihat daftar antrian (Nurse & Doctor) + description: | + **Nurse**: melihat semua antrian hari ini, terkelompok per schedule instance. + **Doctor**: hanya antrian jadwal miliknya (filter by doctor_id dari token). + + Dari Figma nurse-queue-list: antrian dikelompokkan per jadwal + (misal "Poli Umum 08:00-09:45", "Poli Umum 10:00-11:45"). + + Response menyertakan `schedule_summary` per instance dan `queue_items` per antrian. + operationId: getQueue + security: + - sanctumToken: [] + parameters: + - name: date + in: query + description: "Filter by tanggal. Default: hari ini." + required: false + schema: { type: string, format: date, example: "2025-08-15" } + - name: status + in: query + required: false + schema: + type: string + enum: [booked, checked-in, waiting, called, in-progress, done, no-show, cancelled] + - name: instance_id + in: query + required: false + description: "Filter by jadwal tertentu" + schema: { type: integer, example: 1 } + responses: + '200': + description: Daftar antrian + content: + application/json: + example: + data: + # Grouped by schedule instance — dari Figma + - instance_id: 1 + schedule_summary: + department_name: "Poli Umum" + start_time: "08:00" + end_time: "09:45" + total_antrian: 20 + checked_in_count: 15 + queue_items: + - queue_id: 1 + queue_number: 1 + status: "called" + estimated_wait_minutes: 0 + nurse_notes: null + reservation: + reservation_id: 1 + complaint: "Batuk dan pilek" + patient: + patient_id: 1 + name: "Muhamad Rival Maulana" + gender: "laki-laki" + phone: "081234567890" + schedule: + instance_id: 1 + date: "2025-08-15" + start_time: "08:00" + end_time: "09:45" + - queue_id: 2 + queue_number: 2 + status: "waiting" + estimated_wait_minutes: 15 + nurse_notes: null + reservation: + reservation_id: 2 + complaint: "Demam" + patient: + patient_id: 2 + name: "Handika Chandra Pratama" + gender: "laki-laki" + phone: "082345678901" + schedule: + instance_id: 1 + date: "2025-08-15" + start_time: "08:00" + end_time: "09:45" + '403': + description: Bukan Nurse atau Doctor + content: + application/json: + example: { message: "Akses ditolak. Endpoint ini hanya untuk nurse dan doctor." } + +/queues/{id}/status: + parameters: + - name: id + in: path + required: true + schema: { type: integer, example: 2 } + + patch: + tags: [Queue] + summary: Update status antrian (Nurse only) + description: | + **Nurse only**. Dari Figma: nurse klik pasien di list → update status. + + Transisi valid: + ``` + booked → checked-in + checked-in → waiting + waiting → called (trigger notif ke pasien) + waiting → no-show + called → in-progress + in-progress → done + booked/checked-in → cancelled + ``` + + Nurse dapat menambahkan `nurse_notes` — tampil sebagai "Catatan" di nurse queue + dan "Hasil Skrining" di doctor queue (dari Figma). + + Setelah update, sistem otomatis: + 1. Sync `reservations.status` via QueueObserver + 2. Recalculate estimasi antrian semua pasien di instance yang sama + 3. Kirim notifikasi ke pasien jika status = `called` + operationId: updateQueueStatus + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/queue.yaml#/QueueStatusRequest' } + example: + status: "called" + nurse_notes: "Pasien sudah siap, tekanan darah normal" + responses: + '200': + description: Status diperbarui + content: + application/json: + example: + message: "Status antrian berhasil diperbarui." + data: + queue_id: 2 + queue_number: 2 + status: "called" + estimated_wait_minutes: null + nurse_notes: "Pasien sudah siap, tekanan darah normal" + updated_at: "2025-08-15T10:05:00Z" + reservation: + reservation_id: 2 + complaint: "Demam" + patient: + patient_id: 2 + name: "Handika Chandra Pratama" + gender: "laki-laki" + phone: "082345678901" + schedule: + instance_id: 1 + date: "2025-08-15" + start_time: "08:00" + end_time: "09:45" + '403': + description: Bukan Nurse + content: + application/json: + example: { message: "Akses ditolak. Endpoint ini hanya untuk nurse." } + '404': + description: Antrian tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '409': + description: Transisi status tidak valid + content: + application/json: + example: { message: "Transisi status tidak valid. Status 'done' tidak dapat diubah lagi." } + '422': + description: Nilai status tidak valid + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } diff --git a/docs/openapi_v2/paths/reports.yaml b/docs/openapi_v2/paths/reports.yaml new file mode 100644 index 0000000..dd34383 --- /dev/null +++ b/docs/openapi_v2/paths/reports.yaml @@ -0,0 +1,110 @@ +# ============================================================================= +# Paths — EPIC 4: Reports +# Dari Figma admin-reports: +# - Filter tabs: Semua | Bulan Ini | Bulanan | Tahunan +# - "Cari berdasarkan tanggal..." +# - Tabel: # | Profesi | Hadir | Izin | Aktif | Selesai +# - Summary cards: "Total antrian hari ini: 240", "Total antrian aktif: 60", +# "Total antrian dibatalkan: 10" +# ============================================================================= + +/reports/queues: + get: + tags: [Reports] + summary: Laporan antrian berdasarkan periode (Admin) + description: | + **Admin only**. Dari Figma admin-reports: + - Filter: Semua | Bulan Ini | Bulanan | Tahunan + input tanggal + - Summary: Total antrian hari ini, Total aktif, Total dibatalkan + - Breakdown per departemen dan per dokter + operationId: getQueueReports + security: + - sanctumToken: [] + parameters: + - name: period + in: query + required: true + description: "Dari Figma tabs: Semua=all, Bulan Ini=current_month, Bulanan=monthly, Tahunan=yearly" + schema: + type: string + enum: [all, current_month, monthly, yearly] + example: "monthly" + - name: date + in: query + required: false + description: | + Tanggal referensi (YYYY-MM-DD): + - `monthly` → sistem ambil bulan dari tanggal ini + - `yearly` → sistem ambil tahun dari tanggal ini + - `all` / `current_month` → diabaikan + schema: + type: string + format: date + example: "2025-08-01" + - name: department_id + in: query + required: false + schema: { type: integer, example: 1 } + - name: doctor_id + in: query + required: false + schema: { type: integer, example: 1 } + responses: + '200': + description: Laporan antrian + content: + application/json: + schema: { $ref: '../schemas/queue.yaml#/QueueReportResource' } + example: + data: + period: "monthly" + period_label: "Agustus 2025" + date_range: + from: "2025-08-01" + to: "2025-08-31" + # Dari Figma summary cards + summary: + total_reservations: 240 + completed: 180 + cancelled: 10 + active: 50 + # "Total antrian hari ini" — khusus hari ini + today_total: 24 + by_department: + - department_id: 1 + department_name: "Poli Umum" + total: 100 + completed: 75 + cancelled: 5 + active: 20 + - department_id: 2 + department_name: "Poli Mata" + total: 80 + completed: 60 + cancelled: 3 + active: 17 + by_doctor: + - doctor_id: 1 + doctor_name: "dr. Ucok Napitupulu, Sp.PD" + department_name: "Poli Umum" + total: 60 + completed: 45 + cancelled: 3 + active: 12 + - doctor_id: 2 + doctor_name: "dr. Tirta Pengpeng" + department_name: "Poli Umum" + total: 40 + completed: 30 + cancelled: 2 + active: 8 + '403': + description: Bukan Admin + content: + application/json: + example: { message: "Akses ditolak. Endpoint ini hanya untuk admin." } + '422': + description: Parameter tidak valid + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } diff --git a/docs/openapi_v2/paths/reservations.yaml b/docs/openapi_v2/paths/reservations.yaml new file mode 100644 index 0000000..16dfc04 --- /dev/null +++ b/docs/openapi_v2/paths/reservations.yaml @@ -0,0 +1,316 @@ +# ============================================================================= +# Paths — EPIC 3: Reservations +# Dari Figma patient-create-reservation: +# - "Informasi Pasien": Nama, No. BPJS, Tempat lahir, Tanggal lahir +# - "Keluhan Pasien" textarea +# - Info jadwal: Dokter, Estimasi nomor, Jam, Tanggal +# - "Dianjurkan datang pada 15:45 untuk check-in" +# - Checkbox "Checklist bila data sudah sesuai" +# - Tombol: Kirim | Kembali +# +# Dari Figma patient-reservation (history): +# - Tabs: Semua | Aktif | Selesai | Dibatalkan +# - Card: Poli, Dokter, "Estimasi nomor antrian: 16", Jam, Tanggal, Status badge +# +# Dari Figma patient-detail-reservation: +# - Panel kiri: list reservasi (history) +# - Panel kanan: "DETAIL RESERVATION" — info jadwal + info pasien + tombol Batalkan +# ============================================================================= + +/reservations: + get: + tags: [Booking] + summary: Riwayat reservasi pasien + description: | + **Patient only**. Dari Figma patient-reservation: + - Tab filter: Semua | Aktif | Selesai | Dibatalkan + - Card: Poli, Dokter, Estimasi nomor antrian, Jam, Tanggal, Status + operationId: getMyReservations + security: + - sanctumToken: [] + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: [active, completed, cancelled] + example: "active" + - name: page + in: query + required: false + schema: { type: integer, default: 1 } + - name: per_page + in: query + required: false + schema: { type: integer, default: 10 } + responses: + '200': + description: Riwayat reservasi + content: + application/json: + example: + data: + - reservation_id: 1 + status: "active" + complaint: "Batuk dan pilek" + created_at: "2025-08-10T09:00:00Z" + doctor: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + schedule: + instance_id: 1 + date: "2025-08-18" + start_time: "16:00" + end_time: "18:00" + recommended_checkin_time: "15:45" + queue: + queue_id: 1 + queue_number: 16 + status: "booked" + estimated_wait_minutes: null + estimated_queue_number: 16 + - reservation_id: 2 + status: "completed" + complaint: "Demam" + created_at: "2025-08-05T08:00:00Z" + doctor: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + schedule: + instance_id: 2 + date: "2025-08-07" + start_time: "16:00" + end_time: "18:00" + recommended_checkin_time: "15:45" + queue: + queue_id: 2 + queue_number: 16 + status: "done" + estimated_wait_minutes: null + estimated_queue_number: 16 + - reservation_id: 3 + status: "cancelled" + complaint: null + created_at: "2025-08-01T07:00:00Z" + doctor: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + schedule: + instance_id: 3 + date: "2025-08-03" + start_time: "16:00" + end_time: "18:00" + recommended_checkin_time: "15:45" + queue: + queue_id: 3 + queue_number: 16 + # Dari Figma: "Estimasi nomor antrian: Dibatalkan oleh pasien." + status: "cancelled" + estimated_wait_minutes: null + estimated_queue_number: null + meta: + current_page: 1 + per_page: 10 + total: 3 + last_page: 1 + from: 1 + to: 3 + '403': + description: Bukan Patient + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + post: + tags: [Booking] + summary: Buat reservasi baru + description: | + **Patient only**. Dari Figma patient-create-reservation: + - Input: instance_id (dari halaman pilih jadwal), complaint (keluhan pasien) + - Informasi Pasien ditampilkan dari `GET /patients/me` (pre-filled) + - Setelah submit: sistem generate queue_number otomatis dengan status `booked` + - [BARU] `instance_id` menggantikan `schedule_id` dari versi sebelumnya + operationId: createReservation + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/reservation.yaml#/ReservationRequest' } + example: + instance_id: 1 + complaint: "Batuk dan pilek sudah 3 hari, disertai demam ringan" + responses: + '201': + description: Reservasi berhasil — nomor antrian digenerate + content: + application/json: + example: + message: "Reservasi berhasil dibuat." + data: + reservation_id: 5 + status: "active" + complaint: "Batuk dan pilek sudah 3 hari, disertai demam ringan" + created_at: "2025-08-10T09:30:00Z" + doctor: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + schedule: + instance_id: 1 + date: "2025-08-18" + start_time: "16:00" + end_time: "18:00" + # Dari Figma: "Dianjurkan datang pada 15:45 untuk check-in" + recommended_checkin_time: "15:45" + queue: + queue_id: 5 + queue_number: 16 + status: "booked" + estimated_wait_minutes: null + estimated_queue_number: 16 + '409': + description: Slot penuh atau double booking + content: + application/json: + example: + message: "Slot jadwal sudah penuh atau Anda sudah memiliki reservasi aktif pada jadwal ini." + '422': + description: Validasi gagal + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } + example: + message: "The given data was invalid." + errors: + instance_id: ["Jadwal tidak tersedia atau sudah penuh."] + +/reservations/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: integer, example: 1 } + + get: + tags: [Booking] + summary: Detail reservasi + description: | + **Patient only** — hanya reservasi milik pasien yang login. + Dari Figma patient-detail-reservation: + - "DETAIL RESERVATION" panel kanan + - Info jadwal: Dokter, Estimasi nomor, Jam, Tanggal + - "Dianjurkan datang pada 15:45 untuk check-in" + - Informasi Pasien: Nama, No.BPJS, Tempat lahir, Tanggal lahir, Keluhan + - Tombol: Batalkan | Kembali + - Queue status real-time (polling dari React SPA) + operationId: getReservationById + security: + - sanctumToken: [] + responses: + '200': + description: Detail reservasi + content: + application/json: + example: + data: + reservation_id: 1 + status: "active" + complaint: "Batuk dan pilek sudah 3 hari" + created_at: "2025-08-10T09:00:00Z" + patient: + patient_id: 5 + name: "Ucok Sitorus" + gender: "laki-laki" + bpjs_number: "0001234567890" + birth_place: "Medan" + birth_date: "1995-06-15" + doctor: + doctor_id: 1 + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + schedule: + instance_id: 1 + date: "2025-08-18" + start_time: "16:00" + end_time: "18:00" + recommended_checkin_time: "15:45" + queue: + queue_id: 1 + queue_number: 16 + status: "waiting" + estimated_wait_minutes: 25 + estimated_queue_number: 16 + '403': + description: Bukan milik pasien ini + content: + application/json: + example: { message: "Akses ditolak. Reservasi ini bukan milik Anda." } + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + +/reservations/{id}/cancel: + parameters: + - name: id + in: path + required: true + schema: { type: integer, example: 1 } + + patch: + tags: [Booking] + summary: Batalkan reservasi + description: | + **Patient only** — hanya reservasi milik pasien yang login. + Dari Figma: tombol "Batalkan" di detail reservasi. + Tidak perlu request body. + Gagal jika antrian sudah `called/in-progress/done`. + operationId: cancelReservation + security: + - sanctumToken: [] + responses: + '200': + description: Reservasi dibatalkan + content: + application/json: + example: + message: "Reservasi berhasil dibatalkan." + data: + reservation_id: 1 + status: "cancelled" + queue: + queue_id: 1 + queue_number: 16 + status: "cancelled" + '403': + description: Bukan milik pasien ini + content: + application/json: + example: { message: "Akses ditolak. Reservasi ini bukan milik Anda." } + '409': + description: Tidak bisa dibatalkan + content: + application/json: + example: { message: "Reservasi tidak dapat dibatalkan karena pasien sudah dipanggil atau sedang konsultasi." } diff --git a/docs/openapi_v2/paths/schedule_instances.yaml b/docs/openapi_v2/paths/schedule_instances.yaml new file mode 100644 index 0000000..53838c6 --- /dev/null +++ b/docs/openapi_v2/paths/schedule_instances.yaml @@ -0,0 +1,207 @@ +# ============================================================================= +# Paths — EPIC 2/3: Schedule Instances (Tanggal Aktual) +# [BARU v2.1] +# +# Dari Figma patien-view-detail-department: +# - Card jadwal: "dr. Gia Pratama", "POLI UMUM", "Selasa - --/--/2026", +# "08:00", "Estimasi nomor antrian: 06", badge "Tersedia"/"Penuh" +# - Tombol "Pilih Jadwal" — mengarah ke Create Reservation +# +# Instances di-generate otomatis oleh Laravel Scheduler (2 minggu ke depan) +# Admin bisa override jam/kapasitas atau cancel instance tertentu +# ============================================================================= + +/schedule-instances: + get: + tags: [Schedule Instances] + summary: Lihat jadwal aktual tersedia + description: | + Mengembalikan daftar schedule instances (tanggal aktual). + Pasien menggunakan ini untuk melihat jadwal sebelum booking. + Dari Figma: menampilkan dokter, poli, tanggal, jam, estimasi antrian, status slot. + + Filter wajib minimal salah satu: `department_id` atau `doctor_id` atau `date`. + Default: hanya instances dengan `status = active` dan `date >= today`. + operationId: getScheduleInstances + security: + - sanctumToken: [] + parameters: + - name: department_id + in: query + required: false + description: "Filter by departemen — alur booking patient dari poli ke jadwal" + schema: { type: integer, example: 1 } + - name: doctor_id + in: query + required: false + description: "Filter by dokter tertentu" + schema: { type: integer, example: 1 } + - name: date + in: query + required: false + description: "Filter by tanggal spesifik (YYYY-MM-DD)" + schema: { type: string, format: date, example: "2025-08-18" } + - name: from_date + in: query + required: false + description: "Tanggal awal range. Default: hari ini." + schema: { type: string, format: date, example: "2025-08-18" } + - name: to_date + in: query + required: false + description: "Tanggal akhir range. Default: 2 minggu dari sekarang." + schema: { type: string, format: date, example: "2025-09-01" } + responses: + '200': + description: Daftar jadwal aktual + content: + application/json: + example: + data: + - instance_id: 1 + template_id: 1 + doctor_id: 1 + doctor: + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + date: "2025-08-18" + start_time: "08:00" + end_time: "12:00" + max_patients: 10 + booked_slots: 5 + available_slots: 5 + # Dari Figma: "Estimasi nomor antrian: 06" + estimated_queue_number: 6 + is_available: true + status: "active" + is_override: false + override_note: null + - instance_id: 2 + template_id: 1 + doctor_id: 1 + doctor: + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + date: "2025-08-18" + start_time: "10:00" + end_time: "14:00" + max_patients: 10 + booked_slots: 10 + available_slots: 0 + estimated_queue_number: 20 + # Dari Figma: badge "Penuh" + is_available: false + status: "active" + is_override: false + override_note: null + '401': + description: Tidak terautentikasi + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + +/schedule-instances/{instanceId}: + parameters: + - name: instanceId + in: path + required: true + schema: { type: integer, example: 1 } + + get: + tags: [Schedule Instances] + summary: Detail satu schedule instance + operationId: getScheduleInstanceById + security: + - sanctumToken: [] + responses: + '200': + description: Detail instance + content: + application/json: + example: + data: + instance_id: 1 + template_id: 1 + doctor_id: 1 + doctor: + name: "dr. Ucok Napitupulu, Sp.PD" + specialization: "Spesialis Penyakit Dalam" + department: + department_id: 1 + name: "Poli Umum" + date: "2025-08-18" + start_time: "08:00" + end_time: "12:00" + max_patients: 10 + booked_slots: 5 + available_slots: 5 + estimated_queue_number: 6 + is_available: true + status: "active" + is_override: false + override_note: null + '404': + description: Tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + patch: + tags: [Schedule Instances] + summary: Override atau cancel satu instance + description: | + **Admin only**. Override jam/kapasitas atau cancel instance tertentu. + Jika `status = cancelled`: + - Reservasi dengan status `booked` di instance ini akan dibatalkan otomatis + - Notifikasi dikirim ke pasien terdampak + - Reservasi yang sudah `checked-in/waiting/called/in-progress` → 409 Conflict + operationId: updateScheduleInstance + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/schedule.yaml#/ScheduleInstanceRequest' } + example: + start_time: "09:00" + end_time: "13:00" + override_note: "Jam diubah karena rapat koordinasi dokter" + responses: + '200': + description: Instance diperbarui + content: + application/json: + example: + message: "Jadwal berhasil diperbarui." + data: + instance_id: 1 + date: "2025-08-18" + start_time: "09:00" + end_time: "13:00" + max_patients: 10 + status: "active" + is_override: true + override_note: "Jam diubah karena rapat koordinasi dokter" + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '404': + description: Instance tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '409': + description: Ada pasien aktif (checked-in/waiting/called/in-progress) + content: + application/json: + example: + message: "Jadwal tidak dapat diubah karena ada pasien yang sedang aktif di klinik." diff --git a/docs/openapi_v2/paths/schedule_templates.yaml b/docs/openapi_v2/paths/schedule_templates.yaml new file mode 100644 index 0000000..ae33a2d --- /dev/null +++ b/docs/openapi_v2/paths/schedule_templates.yaml @@ -0,0 +1,210 @@ +# ============================================================================= +# Paths — EPIC 2: Schedule Templates (Recurring) +# [BARU v2.1] Menggantikan /doctors/{id}/schedules untuk bagian recurring +# +# Dari Figma admin-doctor-detail (tabel Schedule): +# - Kolom: No., Hari (Senin/Selasa/dll), Pukul (09:00-15:00), Aksi (Delete/Edit) +# - Tombol "Add Schedule" di atas tabel +# Dari Figma admin-create/update-doctor-schedule: +# - Form: Doctor Name, No SIP, Hari Praktek (dropdown hari), Waktu +# ============================================================================= + +/doctors/{id}/schedule-templates: + parameters: + - name: id + in: path + required: true + description: "ID dokter" + schema: { type: integer, example: 1 } + + get: + tags: [Schedule Templates] + summary: Lihat template jadwal dokter + description: | + Menampilkan semua template jadwal recurring dokter. + Dari Figma doctor-detail: tabel berisi Hari + Jam praktik. + Dapat diakses semua role yang login. + operationId: getDoctorScheduleTemplates + security: + - sanctumToken: [] + responses: + '200': + description: Daftar template jadwal + content: + application/json: + example: + data: + - template_id: 1 + doctor_id: 1 + day_of_week: "senin" + start_time: "09:00" + end_time: "15:00" + max_patients: 10 + is_active: true + valid_from: "2025-08-01" + valid_until: null + - template_id: 2 + doctor_id: 1 + day_of_week: "selasa" + start_time: "09:00" + end_time: "15:00" + max_patients: 10 + is_active: true + valid_from: "2025-08-01" + valid_until: null + - template_id: 3 + doctor_id: 1 + day_of_week: "kamis" + start_time: "09:00" + end_time: "12:00" + max_patients: 8 + is_active: true + valid_from: "2025-08-01" + valid_until: null + '404': + description: Dokter tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + post: + tags: [Schedule Templates] + summary: Tambah template jadwal dokter + description: | + **Admin only**. Dari Figma "Add Doctor's Schedule" form: + Doctor Name (read-only), No SIP (read-only), Hari Praktek (dropdown), Waktu. + Setelah template dibuat, Laravel Scheduler akan auto-generate instances + untuk 2 minggu ke depan. + operationId: createDoctorScheduleTemplate + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/schedule.yaml#/ScheduleTemplateRequest' } + example: + day_of_week: "jumat" + start_time: "09:00" + end_time: "12:00" + max_patients: 10 + is_active: true + valid_from: "2025-08-01" + valid_until: null + responses: + '201': + description: Template dibuat — instances akan di-generate otomatis + content: + application/json: + example: + message: "Template jadwal berhasil dibuat. Instances akan di-generate otomatis." + data: + template_id: 4 + doctor_id: 1 + day_of_week: "jumat" + start_time: "09:00" + end_time: "12:00" + max_patients: 10 + is_active: true + valid_from: "2025-08-01" + valid_until: null + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '409': + description: Dokter sudah punya template aktif di hari yang sama + content: + application/json: + example: { message: "Dokter sudah memiliki jadwal aktif di hari Jumat." } + '422': + description: Validasi gagal + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ValidationErrorResponse' } + +/doctors/{id}/schedule-templates/{templateId}: + parameters: + - name: id + in: path + required: true + schema: { type: integer, example: 1 } + - name: templateId + in: path + required: true + schema: { type: integer, example: 1 } + + patch: + tags: [Schedule Templates] + summary: Update template jadwal + description: | + **Admin only**. Dari Figma "Update Doctor's Schedule" form. + Update template tidak mengubah instances yang sudah ada — + hanya mempengaruhi instances yang di-generate setelah update ini. + operationId: updateDoctorScheduleTemplate + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '../schemas/schedule.yaml#/ScheduleTemplateRequest' } + example: + start_time: "10:00" + end_time: "14:00" + max_patients: 8 + responses: + '200': + description: Template diperbarui + content: + application/json: + example: + message: "Template jadwal berhasil diperbarui." + data: + template_id: 1 + doctor_id: 1 + day_of_week: "senin" + start_time: "10:00" + end_time: "14:00" + max_patients: 8 + is_active: true + valid_from: "2025-08-01" + valid_until: null + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '404': + description: Template tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + + delete: + tags: [Schedule Templates] + summary: Hapus template jadwal + description: | + **Admin only**. Menonaktifkan template (is_active = false) atau menghapus. + Instances yang sudah ada dan belum completed tidak dihapus otomatis. + Gunakan PATCH /schedule-instances/{id} untuk cancel instance per instance. + operationId: deleteDoctorScheduleTemplate + security: + - sanctumToken: [] + responses: + '200': + description: Template dihapus + content: + application/json: + example: { message: "Template jadwal berhasil dihapus." } + '403': + description: Bukan Admin + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } + '404': + description: Template tidak ditemukan + content: + application/json: + schema: { $ref: '../schemas/common.yaml#/ErrorResponse' } diff --git a/docs/openapi_v2/schemas/auth.yaml b/docs/openapi_v2/schemas/auth.yaml new file mode 100644 index 0000000..222decd --- /dev/null +++ b/docs/openapi_v2/schemas/auth.yaml @@ -0,0 +1,98 @@ +# ============================================================================= +# Schemas — Authentication +# ============================================================================= + +RegisterRequest: + type: object + required: [name, email, password, password_confirmation, phone] + properties: + name: + type: string + maxLength: 255 + example: "Ucok Sitorus" + email: + type: string + format: email + example: "ucok@example.com" + password: + type: string + format: password + minLength: 8 + example: "password123" + password_confirmation: + type: string + format: password + example: "password123" + phone: + type: string + maxLength: 15 + example: "081234567890" + +LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + example: "ucok@example.com" + password: + type: string + format: password + example: "password123" + +# Response setelah login — mengembalikan Sanctum plain-text token +# Simpan di React state/memory, BUKAN localStorage +LoginResponse: + type: object + properties: + message: + type: string + example: "Login berhasil." + data: + type: object + properties: + token: + type: string + description: "Sanctum plain-text token. Gunakan: Authorization: Bearer {token}" + example: "3|aB1cUcokD2eF3gH4iJ5kL6" + token_type: + type: string + example: "Bearer" + user: + $ref: '#/UserResource' + +# Resource user — profile bervariasi per role: +# patient → { patient_id, name, phone, bpjs_number, birth_place, birth_date, gender } +# doctor → { doctor_id, name, specialization, sip_number, gender, birth_date, doctor_status, department } +# nurse → { nurse_id, name, sip_number, gender, birth_date, department } +# admin → { admin_id, name } +UserResource: + type: object + properties: + user_id: + type: integer + example: 10 + email: + type: string + format: email + example: "ucok@example.com" + role: + type: string + enum: [patient, doctor, nurse, admin] + example: "patient" + status: + type: string + enum: [active, inactive] + example: "active" + profile: + type: object + description: "Struktur berbeda per role. Lihat PatientProfileResource / DoctorResource / NurseResource." + example: + patient_id: 5 + name: "Ucok Sitorus" + phone: "081234567890" + bpjs_number: "0001234567890" + birth_place: "Medan" + birth_date: "1995-06-15" + gender: "laki-laki" diff --git a/docs/openapi_v2/schemas/common.yaml b/docs/openapi_v2/schemas/common.yaml new file mode 100644 index 0000000..14ed172 --- /dev/null +++ b/docs/openapi_v2/schemas/common.yaml @@ -0,0 +1,45 @@ +# ============================================================================= +# Schemas — Common / Shared +# ============================================================================= + +SuccessResponse: + type: object + properties: + message: + type: string + example: "Operasi berhasil." + +ErrorResponse: + type: object + properties: + message: + type: string + example: "Terjadi kesalahan." + +# Format default Laravel 422 validation error +ValidationErrorResponse: + type: object + properties: + message: + type: string + example: "The given data was invalid." + errors: + type: object + additionalProperties: + type: array + items: + type: string + example: + email: ["The email has already been taken."] + password: ["The password must be at least 8 characters."] + +# Format Laravel Resource Pagination +PaginationMeta: + type: object + properties: + current_page: { type: integer, example: 1 } + per_page: { type: integer, example: 10 } + total: { type: integer, example: 35 } + last_page: { type: integer, example: 4 } + from: { type: integer, example: 1 } + to: { type: integer, example: 10 } diff --git a/docs/openapi_v2/schemas/department.yaml b/docs/openapi_v2/schemas/department.yaml new file mode 100644 index 0000000..6246b75 --- /dev/null +++ b/docs/openapi_v2/schemas/department.yaml @@ -0,0 +1,46 @@ +# ============================================================================= +# Schemas — Department +# Dari Figma: card poli menampilkan nama, deskripsi, jumlah dokter tersedia +# Screen: patient-view-department — "3 dokter tersedia", "Poli Umum" +# ============================================================================= + +DepartmentResource: + type: object + properties: + department_id: + type: integer + example: 1 + name: + type: string + example: "Poli Umum" + description: + type: string + nullable: true + # Dari Figma: "Pemeriksaan Kesehatan Umum" — deskripsi singkat di card + example: "Pusat layanan kesehatan primer untuk diagnosa awal, pengobatan umum, dan rujukan" + # Dari Figma: card menampilkan "3 dokter tersedia" — computed field + doctors_count: + type: integer + description: "Jumlah dokter aktif di departemen ini." + example: 3 + created_at: + type: string + format: date-time + example: "2025-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + example: "2025-08-01T10:00:00Z" + +# Digunakan untuk POST create dan PATCH update (partial) +DepartmentRequest: + type: object + properties: + name: + type: string + maxLength: 255 + example: "Poli Umum" + description: + type: string + nullable: true + example: "Pusat layanan kesehatan primer untuk diagnosa awal, pengobatan umum, dan rujukan" diff --git a/docs/openapi_v2/schemas/doctor.yaml b/docs/openapi_v2/schemas/doctor.yaml new file mode 100644 index 0000000..f1d29b3 --- /dev/null +++ b/docs/openapi_v2/schemas/doctor.yaml @@ -0,0 +1,128 @@ +# ============================================================================= +# Schemas — Doctor +# [BARU v2.1] Dari Figma form Doctor: +# - sip_number : field "No SIP" di form create/update doctor +# - birth_date : field "Tanggal Lahir" di form +# - gender : field "Jenis Kelamin" di form +# Dari Figma doctor-detail: tabel schedule menampilkan Hari + Jam +# Dari Figma doctor-queue-list: kolom tambahan Keluhan + Jenis Kelamin +# ============================================================================= + +DoctorResource: + type: object + properties: + doctor_id: + type: integer + example: 1 + name: + type: string + example: "dr. Ucok Napitupulu, Sp.PD" + specialization: + type: string + example: "Spesialis Penyakit Dalam" + # [BARU] dari Figma form doctor + sip_number: + type: string + nullable: true + description: "Nomor Surat Izin Praktik dokter." + example: "112345678" + birth_date: + type: string + format: date + nullable: true + example: "1980-03-20" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "laki-laki" + # doctor_status: dikelola DoctorDelayDetectionService (auto) dan Nurse/Admin (manual) + doctor_status: + type: string + enum: [active, late, absent] + example: "active" + department: + type: object + properties: + department_id: { type: integer, example: 1 } + name: { type: string, example: "Poli Umum" } + user_id: + type: integer + example: 5 + created_at: + type: string + format: date-time + example: "2025-01-15T08:00:00Z" + updated_at: + type: string + format: date-time + example: "2025-08-01T10:00:00Z" + +# Request body POST — Register Doctor (Admin) +# Membuat users + doctors sekaligus dalam satu transaksi +DoctorRequest: + type: object + required: [name, email, password, specialization, department_id] + properties: + name: + type: string + maxLength: 255 + example: "dr. Ucok Napitupulu, Sp.PD" + email: + type: string + format: email + example: "ucok.dokter@hospital.com" + password: + type: string + format: password + minLength: 8 + example: "password123" + specialization: + type: string + example: "Spesialis Penyakit Dalam" + department_id: + type: integer + example: 1 + # [BARU] field dari Figma + sip_number: + type: string + nullable: true + example: "112345678" + birth_date: + type: string + format: date + nullable: true + example: "1980-03-20" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "laki-laki" + +# Request body PATCH — Update Doctor (partial update) +DoctorUpdateRequest: + type: object + properties: + name: + type: string + example: "dr. Ucok Napitupulu, Sp.PD" + specialization: + type: string + example: "Spesialis Penyakit Dalam" + department_id: + type: integer + example: 1 + sip_number: + type: string + nullable: true + example: "112345678" + birth_date: + type: string + format: date + nullable: true + example: "1980-03-20" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "laki-laki" diff --git a/docs/openapi_v2/schemas/nurse.yaml b/docs/openapi_v2/schemas/nurse.yaml new file mode 100644 index 0000000..43a81ba --- /dev/null +++ b/docs/openapi_v2/schemas/nurse.yaml @@ -0,0 +1,116 @@ +# ============================================================================= +# Schemas — Nurse +# [BARU v2.1] Dari Figma form Nurse: +# - sip_number : field "No SIP" di form +# - birth_date : field "Tanggal Lahir" +# - gender : field "Jenis Kelamin" +# ============================================================================= + +NurseResource: + type: object + properties: + nurse_id: + type: integer + example: 1 + name: + type: string + example: "Ucok Sihombing" + # [BARU] dari Figma form nurse + sip_number: + type: string + nullable: true + description: "Nomor Surat Izin Praktik suster." + example: "112345678" + birth_date: + type: string + format: date + nullable: true + example: "1990-07-10" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "perempuan" + # department nullable — nurse bisa lintas departemen + department: + type: object + nullable: true + properties: + department_id: { type: integer, example: 1 } + name: { type: string, example: "Poli Umum" } + user_id: + type: integer + example: 8 + created_at: + type: string + format: date-time + example: "2025-01-15T08:00:00Z" + updated_at: + type: string + format: date-time + example: "2025-08-01T10:00:00Z" + +# Request body POST — Register Nurse (Admin) +NurseRequest: + type: object + required: [name, email, password] + properties: + name: + type: string + maxLength: 255 + example: "Ucok Sihombing" + email: + type: string + format: email + example: "ucok.nurse@hospital.com" + password: + type: string + format: password + minLength: 8 + example: "password123" + # department_id opsional — nurse bisa lintas departemen + department_id: + type: integer + nullable: true + example: 1 + # [BARU] field dari Figma + sip_number: + type: string + nullable: true + example: "112345678" + birth_date: + type: string + format: date + nullable: true + example: "1990-07-10" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "perempuan" + +# Request body PATCH — Update Nurse (partial update) +NurseUpdateRequest: + type: object + properties: + name: + type: string + example: "Ucok Sihombing, S.Kep" + department_id: + type: integer + nullable: true + example: 2 + sip_number: + type: string + nullable: true + example: "112345678" + birth_date: + type: string + format: date + nullable: true + example: "1990-07-10" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "perempuan" diff --git a/docs/openapi_v2/schemas/patient.yaml b/docs/openapi_v2/schemas/patient.yaml new file mode 100644 index 0000000..83a8def --- /dev/null +++ b/docs/openapi_v2/schemas/patient.yaml @@ -0,0 +1,80 @@ +# ============================================================================= +# Schemas — Patient Profile +# [BARU v2.1] Dari analisis Figma form Create Reservation: +# - bpjs_number, birth_place, birth_date, gender ditampilkan di form +# - Data ini perlu ada sebelum pasien bisa submit reservasi +# ============================================================================= + +# Resource profil pasien lengkap +PatientProfileResource: + type: object + properties: + patient_id: + type: integer + example: 5 + name: + type: string + example: "Ucok Sitorus" + phone: + type: string + example: "081234567890" + # Field dari Figma form Create Reservation + # "Informasi Pasien" section menampilkan semua field ini + bpjs_number: + type: string + nullable: true + description: "Nomor BPJS pasien. Nullable — bisa dilengkapi saat booking." + example: "0001234567890" + birth_place: + type: string + nullable: true + description: "Tempat lahir pasien." + example: "Medan" + birth_date: + type: string + format: date + nullable: true + description: "Tanggal lahir pasien (YYYY-MM-DD)." + example: "1995-06-15" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "laki-laki" + user_id: + type: integer + example: 10 + +# Request body untuk PATCH /patients/me (update profil sendiri) +# Semua field opsional — partial update +PatientProfileRequest: + type: object + properties: + name: + type: string + maxLength: 255 + example: "Ucok Sitorus" + phone: + type: string + maxLength: 15 + example: "081234567890" + bpjs_number: + type: string + nullable: true + maxLength: 50 + example: "0001234567890" + birth_place: + type: string + nullable: true + maxLength: 100 + example: "Medan" + birth_date: + type: string + format: date + nullable: true + example: "1995-06-15" + gender: + type: string + nullable: true + enum: ["laki-laki", "perempuan"] + example: "laki-laki" diff --git a/docs/openapi_v2/schemas/queue.yaml b/docs/openapi_v2/schemas/queue.yaml new file mode 100644 index 0000000..4ac93c5 --- /dev/null +++ b/docs/openapi_v2/schemas/queue.yaml @@ -0,0 +1,148 @@ +# ============================================================================= +# Schemas — Queue +# Dari Figma nurse-queue-list: +# - Kolom: No. Antrian, Nama Pasien, Status, Estimasi Waktu Tunggu, Catatan +# - Status badge: CALLED, WAITING, BOOKED, DONE +# - Filter tabs: Semua | Booked | Called | Done +# Dari Figma doctor-queue-list: +# - Kolom tambahan: Keluhan, Jenis Kelamin (dari patients/reservations) +# - "Hasil Skrining" — field catatan nurse (belum di-spec sebelumnya) +# ============================================================================= + +QueueResource: + type: object + properties: + queue_id: + type: integer + example: 4 + queue_number: + type: integer + description: "Nomor urut antrian dalam satu instance. Dimulai dari 1." + example: 4 + # State machine: booked→checked-in→waiting→called→in-progress→done/no-show + status: + type: string + enum: [booked, checked-in, waiting, called, in-progress, done, no-show, cancelled] + example: "waiting" + estimated_wait_minutes: + type: integer + nullable: true + description: "Estimasi menit tunggu. null jika belum dikalkulasi (status masih booked)." + example: 25 + # [BARU dari Figma doctor-queue-list] catatan nurse saat check-in + # Figma menampilkan kolom "Catatan" di nurse queue dan "Hasil Skrining" di doctor queue + nurse_notes: + type: string + nullable: true + description: "Catatan dari nurse saat check-in atau update status. Terlihat oleh dokter." + example: "Pasien terlihat lemas, tekanan darah 130/90" + updated_at: + type: string + format: date-time + example: "2025-08-15T09:05:00Z" + # Nested reservation — berisi info pasien untuk tampilan nurse/doctor + reservation: + type: object + properties: + reservation_id: { type: integer, example: 4 } + # complaint dari reservations — ditampilkan di doctor-queue-list Figma + complaint: + type: string + nullable: true + example: "Batuk dan pilek sudah 3 hari" + patient: + type: object + properties: + patient_id: { type: integer, example: 4 } + name: { type: string, example: "Ucok Siahaan" } + # gender — ditampilkan di doctor-queue-list Figma (kolom "Jenis Kelamin") + gender: + type: string + nullable: true + example: "laki-laki" + phone: { type: string, example: "084567890123" } + schedule: + type: object + properties: + instance_id: { type: integer, example: 1 } + date: { type: string, format: date, example: "2025-08-15" } + start_time: { type: string, example: "08:00" } + end_time: { type: string, example: "12:00" } + +# Request body PATCH /queues/{id}/status (Nurse only) +QueueStatusRequest: + type: object + required: [status] + properties: + status: + type: string + description: | + Status antrian baru. Transisi valid: + booked → checked-in + checked-in → waiting + waiting → called (trigger notifikasi ke pasien) + waiting → no-show + called → in-progress + in-progress → done + booked → cancelled + checked-in → cancelled + enum: [checked-in, waiting, called, in-progress, done, no-show, cancelled] + example: "called" + # [BARU dari Figma] kolom "Catatan" di nurse queue list + # Nurse bisa tambah catatan saat update status + nurse_notes: + type: string + nullable: true + description: "Catatan nurse — ditampilkan sebagai 'Hasil Skrining' di queue list dokter." + example: "Pasien terlihat lemas, tekanan darah 130/90" + +# Resource laporan antrian untuk Admin +QueueReportResource: + type: object + properties: + data: + type: object + properties: + period: + type: string + enum: [daily, monthly, yearly] + example: "monthly" + period_label: + type: string + example: "Agustus 2025" + date_range: + type: object + properties: + from: { type: string, format: date, example: "2025-08-01" } + to: { type: string, format: date, example: "2025-08-31" } + # Dari Figma admin-reports: "Total antrian hari ini: 240", dll + summary: + type: object + properties: + total_reservations: { type: integer, example: 240 } + completed: { type: integer, example: 180 } + cancelled: { type: integer, example: 10 } + active: { type: integer, example: 50 } + by_department: + type: array + items: + type: object + properties: + department_id: { type: integer, example: 1 } + department_name: { type: string, example: "Poli Umum" } + total: { type: integer, example: 70 } + completed: { type: integer, example: 50 } + cancelled: { type: integer, example: 5 } + active: { type: integer, example: 15 } + by_doctor: + type: array + items: + type: object + properties: + doctor_id: { type: integer, example: 1 } + doctor_name: { type: string, example: "dr. Ucok Napitupulu, Sp.PD" } + department_name: { type: string, example: "Poli Umum" } + total: { type: integer, example: 40 } + completed: { type: integer, example: 30 } + cancelled: { type: integer, example: 3 } + active: { type: integer, example: 7 } diff --git a/docs/openapi_v2/schemas/reservation.yaml b/docs/openapi_v2/schemas/reservation.yaml new file mode 100644 index 0000000..b9f4518 --- /dev/null +++ b/docs/openapi_v2/schemas/reservation.yaml @@ -0,0 +1,123 @@ +# ============================================================================= +# Schemas — Reservation +# [BARU v2.1]: +# - instance_id menggantikan schedule_id (FK ke schedule_instances) +# - complaint (text, nullable) — dari Figma form Create Reservation +# Tampil di: form booking, detail reservasi, dan queue list dokter +# ============================================================================= + +ReservationResource: + type: object + properties: + reservation_id: + type: integer + example: 1 + # status: derived dari queues.status via QueueObserver + # active = queue belum done/no-show/cancelled + # completed = queue done + # cancelled = queue no-show atau cancelled + status: + type: string + enum: [active, completed, cancelled] + example: "active" + # [BARU] keluhan pasien — dari Figma "Keluhan Pasien" di form booking + complaint: + type: string + nullable: true + description: "Keluhan pasien yang diisi saat membuat reservasi. Ditampilkan di queue list dokter." + example: "Batuk dan pilek sudah 3 hari" + created_at: + type: string + format: date-time + example: "2025-08-10T09:00:00Z" + # Info pasien — muncul di detail reservasi dan di queue list dokter + patient: + type: object + properties: + patient_id: { type: integer, example: 5 } + name: { type: string, example: "Ucok Sitorus" } + gender: + type: string + nullable: true + example: "laki-laki" + bpjs_number: + type: string + nullable: true + example: "0001234567890" + birth_place: + type: string + nullable: true + example: "Medan" + birth_date: + type: string + format: date + nullable: true + example: "1995-06-15" + doctor: + type: object + properties: + doctor_id: { type: integer, example: 1 } + name: { type: string, example: "dr. Ucok Napitupulu, Sp.PD" } + specialization: { type: string, example: "Spesialis Penyakit Dalam" } + department: + type: object + properties: + department_id: { type: integer, example: 1 } + name: { type: string, example: "Poli Umum" } + # Schedule info — dari instance (tanggal aktual) + schedule: + type: object + properties: + instance_id: { type: integer, example: 1 } + date: { type: string, format: date, example: "2025-08-18" } + start_time: { type: string, example: "08:00" } + end_time: { type: string, example: "12:00" } + # Dari Figma: "Dianjurkan datang pada 15:45 untuk check-in" + recommended_checkin_time: + type: string + description: "15 menit sebelum start_time — ditampilkan di detail reservasi Figma" + example: "07:45" + # Queue — real-time, di-join ke reservations + queue: + type: object + properties: + queue_id: { type: integer, example: 1 } + queue_number: { type: integer, example: 4 } + status: + type: string + enum: [booked, checked-in, waiting, called, in-progress, done, no-show, cancelled] + example: "waiting" + estimated_wait_minutes: + type: integer + nullable: true + example: 25 + # Dari Figma: "Estimasi nomor antrian: 16" — ditampilkan di card history + estimated_queue_number: + type: integer + description: "Nomor antrian estimasi — sama dengan queue_number setelah booking" + example: 16 + +# Request body POST — Create Reservation (Patient only) +# [BARU v2.1]: instance_id menggantikan schedule_id +# complaint ditambahkan — wajib diisi saat booking +ReservationRequest: + type: object + required: [instance_id] + properties: + # FK ke schedule_instances — bukan schedule_templates + # Pasien booking ke tanggal aktual (instance), bukan template recurring + instance_id: + type: integer + description: | + ID jadwal aktual (schedule_instances.instance_id). + doctor_id diambil otomatis dari instance.doctor_id di backend. + Validasi: slot tersedia, tidak double booking, tanggal belum lampau, + instance.status = active. + example: 1 + # [BARU] dari Figma form Create Reservation + complaint: + type: string + nullable: true + description: "Keluhan pasien. Opsional tapi direkomendasikan untuk diisi." + maxLength: 1000 + example: "Batuk dan pilek sudah 3 hari, disertai demam ringan" diff --git a/docs/openapi_v2/schemas/schedule.yaml b/docs/openapi_v2/schemas/schedule.yaml new file mode 100644 index 0000000..cf1c1d6 --- /dev/null +++ b/docs/openapi_v2/schemas/schedule.yaml @@ -0,0 +1,199 @@ +# ============================================================================= +# Schemas — Schedule (Template + Instance) +# [BARU v2.1] Tabel schedules lama DIGANTI dengan sistem hybrid: +# +# ScheduleTemplate → recurring pattern (Hari + Jam, misal "Senin 08:00-12:00") +# ScheduleInstance → tanggal aktual (misal "2025-08-18 08:00-12:00") +# +# Dari Figma admin-doctor-detail: +# - Jadwal ditampilkan: Hari (Senin/Selasa/dll) + Jam (09:00-15:00) +# - Ada tombol Edit + Delete per jadwal +# - Tombol "Add Schedule" untuk tambah jadwal baru +# +# Dari Figma patien-view-detail-department: +# - Pasien melihat: "Selasa - --/--/2026", "08:00", "Estimasi nomor antrian: 06" +# - Instance yang ditampilkan ke pasien, bukan template +# ============================================================================= + +# ---- TEMPLATE ---- + +ScheduleTemplateResource: + type: object + properties: + template_id: + type: integer + example: 1 + doctor_id: + type: integer + example: 1 + # Dari Figma: jadwal disimpan per hari (Senin, Selasa, dst) + day_of_week: + type: string + enum: [senin, selasa, rabu, kamis, jumat, sabtu, minggu] + example: "senin" + start_time: + type: string + description: "Format HH:MM" + example: "08:00" + end_time: + type: string + description: "Format HH:MM" + example: "12:00" + max_patients: + type: integer + example: 10 + is_active: + type: boolean + description: "false = template tidak generate instance baru, tapi instance lama tetap valid" + example: true + valid_from: + type: string + format: date + description: "Tanggal template mulai berlaku" + example: "2025-08-01" + valid_until: + type: string + format: date + nullable: true + description: "Tanggal template berakhir. null = berlaku selamanya." + example: null + created_at: + type: string + format: date-time + example: "2025-07-01T00:00:00Z" + +# Request body POST/PATCH template +ScheduleTemplateRequest: + type: object + required: [day_of_week, start_time, end_time] + properties: + day_of_week: + type: string + enum: [senin, selasa, rabu, kamis, jumat, sabtu, minggu] + example: "senin" + start_time: + type: string + description: "Format HH:MM" + example: "08:00" + end_time: + type: string + description: "Format HH:MM, harus setelah start_time" + example: "12:00" + max_patients: + type: integer + minimum: 1 + maximum: 100 + default: 10 + example: 10 + is_active: + type: boolean + default: true + example: true + valid_from: + type: string + format: date + example: "2025-08-01" + valid_until: + type: string + format: date + nullable: true + example: null + +# ---- INSTANCE ---- + +ScheduleInstanceResource: + type: object + properties: + instance_id: + type: integer + example: 1 + template_id: + type: integer + nullable: true + description: "null jika instance manual tanpa template (jadwal insidental)" + example: 1 + doctor_id: + type: integer + example: 1 + # Info dokter untuk tampilan pasien (Figma: "dr. Gia Pratama", "POLI UMUM") + doctor: + type: object + properties: + name: { type: string, example: "dr. Ucok Napitupulu, Sp.PD" } + specialization: { type: string, example: "Spesialis Penyakit Dalam" } + department: + type: object + properties: + department_id: { type: integer, example: 1 } + name: { type: string, example: "Poli Umum" } + date: + type: string + format: date + description: "Tanggal aktual jadwal praktik" + example: "2025-08-18" + start_time: + type: string + example: "08:00" + end_time: + type: string + example: "12:00" + max_patients: + type: integer + example: 10 + # Computed — dihitung runtime dari jumlah reservasi aktif + booked_slots: + type: integer + example: 4 + available_slots: + type: integer + example: 6 + # Dari Figma: "Estimasi nomor antrian: 06" — ditampilkan di card jadwal pasien + estimated_queue_number: + type: integer + description: "Estimasi nomor antrian yang akan didapat pasien jika booking sekarang (booked_slots + 1)" + example: 5 + is_available: + type: boolean + description: "true jika available_slots > 0 dan tanggal belum lampau" + example: true + status: + type: string + enum: [active, cancelled, completed] + example: "active" + is_override: + type: boolean + description: "true jika admin mengubah data dari template asli" + example: false + override_note: + type: string + nullable: true + description: "Catatan alasan override oleh admin" + example: null + +# Request body untuk Admin override instance +# Digunakan PATCH /schedule-instances/{id} +ScheduleInstanceRequest: + type: object + properties: + start_time: + type: string + description: "Format HH:MM — override jam mulai" + example: "09:00" + end_time: + type: string + description: "Format HH:MM — override jam selesai" + example: "13:00" + max_patients: + type: integer + minimum: 1 + example: 8 + status: + type: string + enum: [active, cancelled] + description: "cancelled = batalkan instance ini (pasien booked akan dinotif)" + example: "active" + override_note: + type: string + nullable: true + description: "Alasan perubahan — ditampilkan di notifikasi pasien terdampak" + example: "Jam berubah karena acara rapat rumah sakit" From 954ebffc28c259a78de11fade0929d141b78d0b6 Mon Sep 17 00:00:00 2001 From: Hanchan Date: Mon, 15 Jun 2026 22:22:59 +0700 Subject: [PATCH 06/16] feat(auth): implement authentication flow (register, login, logout, me) with Sanctum SPA Cookie for QNH-310, QNH-312, and QNH-314 --- .../Http/Controllers/Auth/AuthController.php | 25 ++ .../Auth/AuthenticatedSessionController.php | 54 ++++ ...mailVerificationNotificationController.php | 25 ++ .../Auth/NewPasswordController.php | 53 ++++ .../Auth/PasswordResetLinkController.php | 39 +++ .../Auth/RegisteredUserController.php | 32 ++ .../Auth/VerifyEmailController.php | 31 ++ .../Http/Middleware/EnsureEmailIsVerified.php | 27 ++ .../app/Http/Requests/Auth/LoginRequest.php | 86 ++++++ .../Http/Requests/Auth/RegisterRequest.php | 32 ++ .../Http/Resources/PatientProfileResource.php | 28 ++ backend/app/Http/Resources/UserResource.php | 35 +++ backend/app/Models/Patient.php | 38 +++ backend/app/Models/User.php | 13 +- backend/app/Providers/AppServiceProvider.php | 5 +- backend/app/Providers/AuthServiceProvider.php | 29 ++ backend/app/Services/AuthService.php | 11 + backend/app/Services/impl/AuthService.php | 27 ++ backend/bootstrap/app.php | 5 + backend/bootstrap/providers.php | 5 +- backend/composer.json | 1 + backend/composer.lock | 63 +++- backend/config/cors.php | 36 +++ backend/config/sanctum.php | 6 +- backend/database/factories/PatientFactory.php | 24 ++ .../0001_01_01_000000_create_users_table.php | 3 +- ...026_06_12_125654_create_patients_table.php | 34 +++ backend/database/seeders/PatientSeeder.php | 17 ++ backend/package.json | 17 -- backend/resources/css/app.css | 11 - backend/resources/js/app.js | 1 - backend/resources/js/bootstrap.js | 4 - backend/resources/views/.gitkeep | 1 + backend/resources/views/welcome.blade.php | 277 ------------------ backend/routes/api.php | 8 +- backend/routes/auth.php | 45 +++ backend/routes/web.php | 4 +- backend/test.http | 44 +++ .../tests/Feature/Auth/AuthenticationTest.php | 47 +++ .../Feature/Auth/EmailVerificationTest.php | 49 ++++ .../tests/Feature/Auth/PasswordResetTest.php | 49 ++++ .../tests/Feature/Auth/RegistrationTest.php | 24 ++ backend/vite.config.js | 18 -- 43 files changed, 1042 insertions(+), 341 deletions(-) create mode 100644 backend/app/Http/Controllers/Auth/AuthController.php create mode 100644 backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php create mode 100644 backend/app/Http/Controllers/Auth/EmailVerificationNotificationController.php create mode 100644 backend/app/Http/Controllers/Auth/NewPasswordController.php create mode 100644 backend/app/Http/Controllers/Auth/PasswordResetLinkController.php create mode 100644 backend/app/Http/Controllers/Auth/RegisteredUserController.php create mode 100644 backend/app/Http/Controllers/Auth/VerifyEmailController.php create mode 100644 backend/app/Http/Middleware/EnsureEmailIsVerified.php create mode 100644 backend/app/Http/Requests/Auth/LoginRequest.php create mode 100644 backend/app/Http/Requests/Auth/RegisterRequest.php create mode 100644 backend/app/Http/Resources/PatientProfileResource.php create mode 100644 backend/app/Http/Resources/UserResource.php create mode 100644 backend/app/Models/Patient.php create mode 100644 backend/app/Providers/AuthServiceProvider.php create mode 100644 backend/app/Services/AuthService.php create mode 100644 backend/app/Services/impl/AuthService.php create mode 100644 backend/config/cors.php create mode 100644 backend/database/factories/PatientFactory.php create mode 100644 backend/database/migrations/2026_06_12_125654_create_patients_table.php create mode 100644 backend/database/seeders/PatientSeeder.php delete mode 100644 backend/package.json delete mode 100644 backend/resources/css/app.css delete mode 100644 backend/resources/js/app.js delete mode 100644 backend/resources/js/bootstrap.js create mode 100644 backend/resources/views/.gitkeep delete mode 100644 backend/resources/views/welcome.blade.php create mode 100644 backend/routes/auth.php create mode 100644 backend/test.http create mode 100644 backend/tests/Feature/Auth/AuthenticationTest.php create mode 100644 backend/tests/Feature/Auth/EmailVerificationTest.php create mode 100644 backend/tests/Feature/Auth/PasswordResetTest.php create mode 100644 backend/tests/Feature/Auth/RegistrationTest.php delete mode 100644 backend/vite.config.js diff --git a/backend/app/Http/Controllers/Auth/AuthController.php b/backend/app/Http/Controllers/Auth/AuthController.php new file mode 100644 index 0000000..87397d3 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/AuthController.php @@ -0,0 +1,25 @@ +user(); + + $relation = match ($user->role) { + 'patient' => 'patient', + default => 'patient', // expand di EPIC 2 + }; + + return response()->json([ + 'data' => new UserResource($user->load($relation)), + ]); + } +} diff --git a/backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..e8574a5 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,54 @@ +authenticate(); + + $request->session()->regenerate(); + + $user = Auth::user(); + + $relation = match ($user->role) { + 'patient' => 'patient', + default => 'patient', // expand di EPIC 2 + }; + + return response()->json([ + 'message' => 'Login berhasil.', + 'data' => [ + 'user' => new UserResource($user->load($relation)), + ], + ]); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): JsonResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return response()->json([ + 'message' => 'Logout Successful.', + ]); + } +} diff --git a/backend/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/backend/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..0550fbd --- /dev/null +++ b/backend/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,25 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended('/dashboard'); + } + + $request->user()->sendEmailVerificationNotification(); + + return response()->json(['status' => 'verification-link-sent']); + } +} diff --git a/backend/app/Http/Controllers/Auth/NewPasswordController.php b/backend/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..8c0959b --- /dev/null +++ b/backend/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,53 @@ +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->string('password')), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + if ($status != Password::PASSWORD_RESET) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return response()->json(['status' => __($status)]); + } +} diff --git a/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php b/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..d555988 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,39 @@ +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) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return response()->json(['status' => __($status)]); + } +} diff --git a/backend/app/Http/Controllers/Auth/RegisteredUserController.php b/backend/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..bb939b7 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,32 @@ +authService->registerPatient($request->validated()); + + return response()->json([ + 'message' => 'Register Success', + 'data' => new UserResource($user), + ], 201); + } +} diff --git a/backend/app/Http/Controllers/Auth/VerifyEmailController.php b/backend/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..33cbed4 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,31 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended( + config('app.frontend_url').'/dashboard?verified=1' + ); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect()->intended( + config('app.frontend_url').'/dashboard?verified=1' + ); + } +} diff --git a/backend/app/Http/Middleware/EnsureEmailIsVerified.php b/backend/app/Http/Middleware/EnsureEmailIsVerified.php new file mode 100644 index 0000000..2a7df86 --- /dev/null +++ b/backend/app/Http/Middleware/EnsureEmailIsVerified.php @@ -0,0 +1,27 @@ +user() || + ($request->user() instanceof MustVerifyEmail && + ! $request->user()->hasVerifiedEmail())) { + return response()->json(['message' => 'Your email address is not verified.'], 409); + } + + return $next($request); + } +} diff --git a/backend/app/Http/Requests/Auth/LoginRequest.php b/backend/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..9dc38aa --- /dev/null +++ b/backend/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' => __('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->input('email')).'|'.$this->ip()); + } +} diff --git a/backend/app/Http/Requests/Auth/RegisterRequest.php b/backend/app/Http/Requests/Auth/RegisterRequest.php new file mode 100644 index 0000000..42dbef5 --- /dev/null +++ b/backend/app/Http/Requests/Auth/RegisterRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'phone' => ['required', 'string', 'max:15'], + ]; + } +} diff --git a/backend/app/Http/Resources/PatientProfileResource.php b/backend/app/Http/Resources/PatientProfileResource.php new file mode 100644 index 0000000..b6265e1 --- /dev/null +++ b/backend/app/Http/Resources/PatientProfileResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'patient_id' => $this->patient_id, + 'name' => $this->name, + 'phone' => $this->phone, + 'bpjs_number' => $this->bpjs_number, + 'birth_place' => $this->birth_place, + 'birth_date' => $this->birth_date?->format('Y-m-d'), + 'gender' => $this->gender, + 'user_id' => $this->user_id, + ]; + } +} diff --git a/backend/app/Http/Resources/UserResource.php b/backend/app/Http/Resources/UserResource.php new file mode 100644 index 0000000..582aec1 --- /dev/null +++ b/backend/app/Http/Resources/UserResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'email' => $this->email, + 'role' => $this->role, + 'status' => $this->status, + 'profile' => $this->resolveProfile(), + ]; + } + + private function resolveProfile(): mixed + { + return match ($this->role) { + 'patient' => $this->patient + ? new PatientProfileResource($this->patient) + : null, + default => null, + }; + } +} diff --git a/backend/app/Models/Patient.php b/backend/app/Models/Patient.php new file mode 100644 index 0000000..5cb45a2 --- /dev/null +++ b/backend/app/Models/Patient.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + protected $primaryKey = 'id'; + + protected $fillable = [ + 'user_id', + 'name', + 'phone', + 'bpjs_number', + 'birth_place', + 'birth_date', + 'gender', + ]; + + protected function casts(): array + { + return [ + 'birth_date' => 'date', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 68f3a66..0cf2a97 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -5,13 +5,17 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasApiTokens; + + protected $primaryKey = 'id'; /** * The attributes that are mass assignable. @@ -22,6 +26,8 @@ class User extends Authenticatable 'name', 'email', 'password', + 'role', + 'status', ]; /** @@ -46,4 +52,9 @@ protected function casts(): array 'password' => 'hashed', ]; } + + public function patient(): HasOne + { + return $this->hasOne(Patient::class, 'user_id', 'id'); + } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 452e6b6..96ddc37 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +20,8 @@ public function register(): void */ public function boot(): void { - // + ResetPassword::createUrlUsing(function (object $notifiable, string $token) { + return config('app.frontend_url')."/password-reset/$token?email={$notifiable->getEmailForPasswordReset()}"; + }); } } diff --git a/backend/app/Providers/AuthServiceProvider.php b/backend/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..f5789a8 --- /dev/null +++ b/backend/app/Providers/AuthServiceProvider.php @@ -0,0 +1,29 @@ + \App\Services\impl\AuthService::class + ]; + + /** + * Register services. + */ + public function register(): void + { + // + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} diff --git a/backend/app/Services/AuthService.php b/backend/app/Services/AuthService.php new file mode 100644 index 0000000..c3c8b1e --- /dev/null +++ b/backend/app/Services/AuthService.php @@ -0,0 +1,11 @@ + $data['email'], + 'password' => $data['password'], + 'role' => 'patient', + 'status' => 'active', + ]); + + Patient::create([ + 'user_id' => $user->id, + 'name' => $data['name'], + 'phone' => $data['phone'], + ]); + + return $user->load('patient'); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index c3928c5..6491d2a 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -12,6 +12,11 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->statefulApi(); + $middleware->alias([ + 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, + ]); + // }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php index fc94ae6..8dc3a48 100644 --- a/backend/bootstrap/providers.php +++ b/backend/bootstrap/providers.php @@ -1,7 +1,6 @@ ['*'], + + 'paths' => ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php index cde73cf..8ae01bf 100644 --- a/backend/config/sanctum.php +++ b/backend/config/sanctum.php @@ -19,10 +19,10 @@ */ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( - '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + '%s%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:3000,127.0.0.1:8000,::1', Sanctum::currentApplicationUrlWithPort(), - // Sanctum::currentRequestHost(), + env('FRONTEND_URL') ? ','.parse_url(env('FRONTEND_URL'), PHP_URL_HOST) : '' ))), /* diff --git a/backend/database/factories/PatientFactory.php b/backend/database/factories/PatientFactory.php new file mode 100644 index 0000000..6438787 --- /dev/null +++ b/backend/database/factories/PatientFactory.php @@ -0,0 +1,24 @@ + + */ +class PatientFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..02839d6 100644 --- a/backend/database/migrations/0001_01_01_000000_create_users_table.php +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -13,10 +13,11 @@ public function up(): void { Schema::create('users', function (Blueprint $table) { $table->id(); - $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->enum('role', ['patient', 'doctor', 'nurse', 'admin']); + $table->enum('status', ['active', 'inactive'])->default('active'); $table->rememberToken(); $table->timestamps(); }); diff --git a/backend/database/migrations/2026_06_12_125654_create_patients_table.php b/backend/database/migrations/2026_06_12_125654_create_patients_table.php new file mode 100644 index 0000000..148bf7f --- /dev/null +++ b/backend/database/migrations/2026_06_12_125654_create_patients_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained('users', 'id')->cascadeOnDelete(); + $table->string('name'); + $table->string('phone', 15); + $table->string('bpjs_number', 50)->nullable(); + $table->string('birth_place', 100)->nullable(); + $table->date('birth_date')->nullable(); + $table->enum('gender', ['male', 'female'])->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('patients'); + } +}; diff --git a/backend/database/seeders/PatientSeeder.php b/backend/database/seeders/PatientSeeder.php new file mode 100644 index 0000000..2ae1ea6 --- /dev/null +++ b/backend/database/seeders/PatientSeeder.php @@ -0,0 +1,17 @@ + - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{-- Dark Mode 12 SVG --}} - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/backend/routes/api.php b/backend/routes/api.php index ccc387f..c8ff9bd 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -3,6 +3,10 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; -Route::get('/user', function (Request $request) { +Route::middleware(['auth:sanctum'])->get('/user', function (Request $request) { return $request->user(); -})->middleware('auth:sanctum'); +}); + +Route::prefix('auth')->group(function () { + require __DIR__.'/auth.php'; +}); diff --git a/backend/routes/auth.php b/backend/routes/auth.php new file mode 100644 index 0000000..27d13f5 --- /dev/null +++ b/backend/routes/auth.php @@ -0,0 +1,45 @@ +middleware('guest') + ->name('register'); + +Route::post('/login', [AuthenticatedSessionController::class, 'store']) + ->middleware('guest') + ->name('login'); + +// Protected +Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']) + ->name('logout'); + + Route::get('/me', [AuthController::class, 'me']) + ->name('me'); +}); + +Route::post('/forgot-password', [PasswordResetLinkController::class, 'store']) + ->middleware('guest') + ->name('password.email'); + +Route::post('/reset-password', [NewPasswordController::class, 'store']) + ->middleware('guest') + ->name('password.store'); + +Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['auth', 'signed', 'throttle:6,1']) + ->name('verification.verify'); + +Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware(['auth', 'throttle:6,1']) + ->name('verification.send'); diff --git a/backend/routes/web.php b/backend/routes/web.php index 86a06c5..cdd4027 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -3,5 +3,7 @@ use Illuminate\Support\Facades\Route; Route::get('/', function () { - return view('welcome'); + return ['Laravel' => app()->version()]; }); + +require __DIR__.'/auth.php'; diff --git a/backend/test.http b/backend/test.http new file mode 100644 index 0000000..ae3807e --- /dev/null +++ b/backend/test.http @@ -0,0 +1,44 @@ +@baseUrl = http://localhost:8000 + +### User Data +@name = Handika Testing +@email = handika.testing@example.com +@password = Password123! +@phone = 081234567890 + +### Sanctum +@xsrfToken = eyJpdiI6IkV3S24vTEVqK3VLMWJyQ1hrV3hkOEE9PSIsInZhbHVlIjoiZFB3dGQ3azNUakZqWUxIZUEwdldUTjZIMFRJVW9LQUFwRlIxR2JxaWNDWHdtRzNwL1dRN1UyZlVBYW9JMGdtbHV5dVZHa0w0UGtRdUFaZDE0cTFQZEVDK2pyeGNtSWJRRUIzSk1lMndGdGtPNkpGY1M5ZWcvc3hDWVc2a2RKdUkiLCJtYWMiOiI4ZTkxZGYwNjNjOTg4MWIzZWJhYzk4N2ZiNzk3ZTE1MmM1OTQzYTMyMTVlZGE3M2Y0YzYwOThiNzM3N2Q2NWE4IiwidGFnIjoiIn0%3D +@sessionCookie = eyJpdiI6IlVLTkxwTVNqbFk3UGhXNjFTNk5vd3c9PSIsInZhbHVlIjoibi9qWGhPNkRkalpLZkNsS01tWGlnMFpOenhPQktES1RZQ01hdThUT3BRM0cxdDRNek96amRQZk5CbWtYamZ6YWEyTXVpQ3huWEpESTl6UVlGUDErSXJMeE5KM0t0bUdMdm1XMmMrcHlkaklHdTJnSXozNzlKNndrckVSdTgwNmciLCJtYWMiOiIxZTU3ODljOWFjMjIyYWZlOGRkMGZlMjNiY2I0ODg0YjNiYmFjZDkwY2EyOTc1MzI1ZmYxZDU3NmZmMWU4YzIzIiwidGFnIjoiIn0%3D + + +GET {{baseUrl}}/sanctum/csrf-cookie +Origin: http://localhost:3000 + +### Register User +POST {{baseUrl}}/api/auth/register +Content-Type: application/json +Origin: http://localhost:3000 +X-XSRF-TOKEN: {{xsrfToken}} +Cookie: XSRF-TOKEN={{xsrfToken}}; laravel-session={{sessionCookie}} + +{ + "name": "{{name}}", + "email": "{{email}}", + "password": "{{password}}", + "password_confirmation": "{{password}}", + "phone": "{{phone}}" +} + + +### Login +POST {{baseUrl}}/api/auth/login +Content-Type: application/json +Accept: application/json +Origin: http://localhost:3000 +X-XSRF-TOKEN: {{xsrfToken}} +Cookie: XSRF-TOKEN={{xsrfToken}}; laravel-session={{sessionCookie}} + +{ + "email": "{{email}}", + "password": "{{password}}" +} diff --git a/backend/tests/Feature/Auth/AuthenticationTest.php b/backend/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..2236551 --- /dev/null +++ b/backend/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,47 @@ +create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertNoContent(); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } + + public function test_users_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertNoContent(); + } +} diff --git a/backend/tests/Feature/Auth/EmailVerificationTest.php b/backend/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..2db7299 --- /dev/null +++ b/backend/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,49 @@ +unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(config('app.frontend_url').'/dashboard?verified=1'); + } + + public function test_email_is_not_verified_with_invalid_hash(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/backend/tests/Feature/Auth/PasswordResetTest.php b/backend/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..9b652bc --- /dev/null +++ b/backend/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,49 @@ +create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function (object $notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertStatus(200); + + return true; + }); + } +} diff --git a/backend/tests/Feature/Auth/RegistrationTest.php b/backend/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..b48e150 --- /dev/null +++ b/backend/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,24 @@ +post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertNoContent(); + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js deleted file mode 100644 index f35b4e7..0000000 --- a/backend/vite.config.js +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; -import tailwindcss from '@tailwindcss/vite'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - tailwindcss(), - ], - server: { - watch: { - ignored: ['**/storage/framework/views/**'], - }, - }, -}); From 6e70ea8cb52b30cc2bbb167c6d4f8ef142deb300 Mon Sep 17 00:00:00 2001 From: Hanchan Date: Wed, 17 Jun 2026 01:19:52 +0700 Subject: [PATCH 07/16] feat(auth-testing): implement feature test fot flow (register, login, logout, me), add new middleware (RedirectIfAuthenticated), add factory for patient, QNH-310, QNH-312, and QNH-314 --- backend/.gitignore | 1 + .../Middleware/RedirectIfAuthenticated.php | 35 ++++ backend/bootstrap/app.php | 5 +- backend/database/factories/PatientFactory.php | 7 +- backend/database/factories/UserFactory.php | 6 +- backend/phpunit.xml | 9 +- backend/tests/Feature/Api/Auth/LoginTest.php | 185 ++++++++++++++++ backend/tests/Feature/Api/Auth/LogoutTest.php | 76 +++++++ backend/tests/Feature/Api/Auth/MeTest.php | 102 +++++++++ .../tests/Feature/Api/Auth/RegisterTest.php | 197 ++++++++++++++++++ .../tests/Feature/Auth/AuthenticationTest.php | 47 ----- .../Feature/Auth/EmailVerificationTest.php | 49 ----- .../tests/Feature/Auth/PasswordResetTest.php | 49 ----- .../tests/Feature/Auth/RegistrationTest.php | 24 --- backend/tests/Feature/ExampleTest.php | 19 -- .../Unit/{ExampleTest.php => SampleTest.php} | 6 +- 16 files changed, 617 insertions(+), 200 deletions(-) create mode 100644 backend/app/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 backend/tests/Feature/Api/Auth/LoginTest.php create mode 100644 backend/tests/Feature/Api/Auth/LogoutTest.php create mode 100644 backend/tests/Feature/Api/Auth/MeTest.php create mode 100644 backend/tests/Feature/Api/Auth/RegisterTest.php delete mode 100644 backend/tests/Feature/Auth/AuthenticationTest.php delete mode 100644 backend/tests/Feature/Auth/EmailVerificationTest.php delete mode 100644 backend/tests/Feature/Auth/PasswordResetTest.php delete mode 100644 backend/tests/Feature/Auth/RegistrationTest.php delete mode 100644 backend/tests/Feature/ExampleTest.php rename backend/tests/Unit/{ExampleTest.php => SampleTest.php} (52%) diff --git a/backend/.gitignore b/backend/.gitignore index b71b1ea..2f164e3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,6 +1,7 @@ *.log .DS_Store .env +.env.testing .env.backup .env.production .phpactor.json diff --git a/backend/app/Http/Middleware/RedirectIfAuthenticated.php b/backend/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..050f8eb --- /dev/null +++ b/backend/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,35 @@ +check()) { + if ($request->expectsJson()) { + return response()->json([ + 'message' => 'Already authenticated.', + ], 409); + } + + return redirect('/'); + } + } + + return $next($request); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 6491d2a..b09060e 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -14,10 +14,13 @@ ->withMiddleware(function (Middleware $middleware): void { $middleware->statefulApi(); $middleware->alias([ + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, ]); + $middleware->appendToGroup('api', [ + \Illuminate\Session\Middleware\StartSession::class, + ]); - // }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/backend/database/factories/PatientFactory.php b/backend/database/factories/PatientFactory.php index 6438787..ac6b320 100644 --- a/backend/database/factories/PatientFactory.php +++ b/backend/database/factories/PatientFactory.php @@ -18,7 +18,12 @@ class PatientFactory extends Factory public function definition(): array { return [ - // + 'name' => $this->faker->name(), + 'phone' => $this->faker->numerify('08##########'), + 'bpjs_number' => null, + 'birth_place' => null, + 'birth_date' => null, + 'gender' => null, ]; } } diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php index c4ceb07..de48345 100644 --- a/backend/database/factories/UserFactory.php +++ b/backend/database/factories/UserFactory.php @@ -25,14 +25,18 @@ class UserFactory extends Factory public function definition(): array { return [ - 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), + 'role' => 'patient', + 'status' => 'active', 'remember_token' => Str::random(10), ]; } +// public function doctor(): static +// public function nurse(): static + /** * Indicate that the model's email address should be unverified. */ diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 48e0914..f99cb16 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -23,18 +23,15 @@ - - - - - - + + + diff --git a/backend/tests/Feature/Api/Auth/LoginTest.php b/backend/tests/Feature/Api/Auth/LoginTest.php new file mode 100644 index 0000000..ec97d54 --- /dev/null +++ b/backend/tests/Feature/Api/Auth/LoginTest.php @@ -0,0 +1,185 @@ +create(array_merge([ + 'email' => 'handika@example.com', + 'password' => Hash::make('Password123!'), + 'role' => 'patient', + 'status' => 'active', + ], $overrides)); + + Patient::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Handika Testing', + 'phone' => '081234567890', + ]); + + return $user; + } + + public function test_user_can_login_with_valid_credentials(): void + { + $this->createPatientUser(); + + $response = $this->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + 'password' => 'Password123!', + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'message', + 'data' => [ + 'user' => [ + 'id', + 'email', + 'role', + 'status', + 'profile' => [ + 'patient_id', + 'name', + 'phone', + ], + ], + ], + ]) + ->assertJsonPath('data.user.email', 'handika@example.com') + ->assertJsonPath('data.user.role', 'patient'); + } + + public function test_login_response_does_not_contain_bearer_token(): void + { + $this->createPatientUser(); + + $response = $this->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + 'password' => 'Password123!', + ]); + + $response->assertJsonMissingPath('data.token'); + $response->assertJsonMissingPath('data.token_type'); + } + + public function test_login_authenticates_user_in_session(): void + { + $user = $this->createPatientUser(); + + $this->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + 'password' => 'Password123!', + ]); + + $this->assertAuthenticatedAs($user); + } + + public function test_login_fails_with_wrong_password(): void + { + $this->createPatientUser(); + + $response = $this->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + 'password' => 'WrongPassword!', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + $this->assertGuest(); + } + + public function test_login_fails_when_email_not_registered(): void + { + $response = $this->postJson($this->endpoint, [ + 'email' => 'notfound@example.com', + 'password' => 'Password123!', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + $this->assertGuest(); + } + + public function test_login_fails_when_email_missing(): void + { + $response = $this->postJson($this->endpoint, [ + 'password' => 'Password123!', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_login_fails_when_password_missing(): void + { + $this->createPatientUser(); + + $response = $this->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['password']); + } + + public function test_login_fails_when_email_format_invalid(): void + { + $response = $this->postJson($this->endpoint, [ + 'email' => 'not-an-email', + 'password' => 'Password123!', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_login_is_rate_limited_after_too_many_attempts(): void + { + $this->createPatientUser(); + + for ($i = 0; $i < 5; $i++) { + $this->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + 'password' => 'WrongPassword!', + ]); + } + + $response = $this->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + 'password' => 'Password123!', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + + $this->assertGuest(); + } + + public function test_already_authenticated_user_cannot_hit_login_due_to_guest_middleware(): void + { + $user = $this->createPatientUser(); + + $response = $this->actingAs($user)->postJson($this->endpoint, [ + 'email' => 'handika@example.com', + 'password' => 'Password123!', + ]); + + $response->assertStatus(409); + } +} diff --git a/backend/tests/Feature/Api/Auth/LogoutTest.php b/backend/tests/Feature/Api/Auth/LogoutTest.php new file mode 100644 index 0000000..44eb6d6 --- /dev/null +++ b/backend/tests/Feature/Api/Auth/LogoutTest.php @@ -0,0 +1,76 @@ +create([ + 'role' => 'patient', + 'status' => 'active', + ]); + + Patient::factory()->create([ + 'user_id' => $user->id, + ]); + + return $user; + } + + public function test_authenticated_user_can_logout(): void + { + $user = $this->createPatientUser(); + + $response = $this->actingAs($user, 'web')->postJson($this->endpoint); + + $response->assertStatus(200) + ->assertJson([ + 'message' => 'Logout Successful.', + ]); + } + + public function test_logout_invalidates_session(): void + { + $user = $this->createPatientUser(); + + $this->actingAs($user, 'web')->postJson($this->endpoint); + + $this->app['auth']->forgetGuards(); + $this->flushSession(); + + $response = $this->postJson($this->endpoint); + $response->assertStatus(401); + } + + public function test_guest_cannot_logout(): void + { + $response = $this->postJson($this->endpoint); + + $response->assertStatus(401); + } + + public function test_logout_followed_by_protected_route_returns_unauthenticated(): void + { + $response = $this->getJson('/api/auth/me'); + $response->assertStatus(401); + } + + public function test_logout_clears_session_data(): void + { + $user = $this->createPatientUser(); + + $this->actingAs($user, 'web')->postJson($this->endpoint); + $this->assertGuest('web'); + } +} diff --git a/backend/tests/Feature/Api/Auth/MeTest.php b/backend/tests/Feature/Api/Auth/MeTest.php new file mode 100644 index 0000000..a77d312 --- /dev/null +++ b/backend/tests/Feature/Api/Auth/MeTest.php @@ -0,0 +1,102 @@ +create([ + 'role' => 'patient', + 'status' => 'active', + ]); + + Patient::factory()->create(array_merge([ + 'user_id' => $user->id, + 'name' => 'Handika Testing', + 'phone' => '081234567890', + ], $patientOverrides)); + + return $user; + } + + public function test_authenticated_user_can_get_own_data(): void + { + $user = $this->createPatientUser(); + + $response = $this->actingAs($user)->getJson($this->endpoint); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'email', + 'role', + 'status', + 'profile' => [ + 'patient_id', + 'name', + 'phone', + 'bpjs_number', + 'birth_place', + 'birth_date', + 'gender', + 'user_id', + ], + ], + ]) + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('data.email', $user->email) + ->assertJsonPath('data.role', 'patient') + ->assertJsonPath('data.profile.name', 'Handika Testing'); + } + + public function test_guest_cannot_access_me_endpoint(): void + { + $response = $this->getJson($this->endpoint); + + $response->assertStatus(401); + } + + public function test_me_returns_correct_user_when_multiple_users_exist(): void + { + $otherUser = $this->createPatientUser(); + $targetUser = $this->createPatientUser(); + + $response = $this->actingAs($targetUser)->getJson($this->endpoint); + + $response->assertStatus(200) + ->assertJsonPath('data.id', $targetUser->id) + ->assertJsonPath('data.email', $targetUser->email); + + $this->assertNotEquals($otherUser->id, $response->json('data.id')); + } + + public function test_me_profile_reflects_nullable_fields_as_null_when_not_filled(): void + { + $user = $this->createPatientUser([ + 'bpjs_number' => null, + 'birth_place' => null, + 'birth_date' => null, + 'gender' => null, + ]); + + $response = $this->actingAs($user)->getJson($this->endpoint); + + $response->assertStatus(200) + ->assertJsonPath('data.profile.bpjs_number', null) + ->assertJsonPath('data.profile.birth_place', null) + ->assertJsonPath('data.profile.birth_date', null) + ->assertJsonPath('data.profile.gender', null); + } +} diff --git a/backend/tests/Feature/Api/Auth/RegisterTest.php b/backend/tests/Feature/Api/Auth/RegisterTest.php new file mode 100644 index 0000000..36fc7e8 --- /dev/null +++ b/backend/tests/Feature/Api/Auth/RegisterTest.php @@ -0,0 +1,197 @@ + 'Handika Testing', + 'email' => 'handika@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'phone' => '081234567890', + ]; + + $response = $this->postJson($this->endpoint, $payload); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'message', + 'data' => [ + 'id', + 'email', + 'role', + 'status', + 'profile' => [ + 'patient_id', + 'name', + 'phone', + 'bpjs_number', + 'birth_place', + 'birth_date', + 'gender', + 'user_id', + ], + ], + ]) + ->assertJsonPath('data.email', 'handika@example.com') + ->assertJsonPath('data.role', 'patient') + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.profile.name', 'Handika Testing') + ->assertJsonPath('data.profile.phone', '081234567890'); + + $this->assertDatabaseHas('users', [ + 'email' => 'handika@example.com', + 'role' => 'patient', + ]); + + $user = User::where('email', 'handika@example.com')->first(); + + $this->assertDatabaseHas('patients', [ + 'user_id' => $user->id, + 'name' => 'Handika Testing', + 'phone' => '081234567890', + ]); + } + + public function test_register_hashes_password(): void + { + $payload = [ + 'name' => 'Handika Testing', + 'email' => 'handika@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'phone' => '081234567890', + ]; + + $this->postJson($this->endpoint, $payload); + + $user = User::where('email', 'handika@example.com')->first(); + + $this->assertNotEquals('Password123!', $user->password); + $this->assertTrue(\Illuminate\Support\Facades\Hash::check('Password123!', $user->password)); + } + + public function test_register_fails_when_email_already_taken(): void + { + User::factory()->create(['email' => 'handika@example.com']); + + $payload = [ + 'name' => 'Handika Testing', + 'email' => 'handika@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'phone' => '081234567890', + ]; + + $response = $this->postJson($this->endpoint, $payload); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_register_fails_when_password_confirmation_does_not_match(): void + { + $payload = [ + 'name' => 'Handika Testing', + 'email' => 'handika@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'WrongPassword!', + 'phone' => '081234567890', + ]; + + $response = $this->postJson($this->endpoint, $payload); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['password']); + } + + public function test_register_fails_when_password_too_short(): void + { + $payload = [ + 'name' => 'Handika Testing', + 'email' => 'handika@example.com', + 'password' => 'short', + 'password_confirmation' => 'short', + 'phone' => '081234567890', + ]; + + $response = $this->postJson($this->endpoint, $payload); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['password']); + } + + #[DataProvider('missingFieldProvider')] + public function test_register_fails_when_required_field_missing(string $field): void + { + $payload = [ + 'name' => 'Handika Testing', + 'email' => 'handika@example.com', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'phone' => '081234567890', + ]; + + unset($payload[$field]); + + $response = $this->postJson($this->endpoint, $payload); + + $response->assertStatus(422) + ->assertJsonValidationErrors([$field]); + } + + public static function missingFieldProvider(): array + { + return [ + 'missing name' => ['name'], + 'missing email' => ['email'], + 'missing password' => ['password'], + 'missing phone' => ['phone'], + ]; + } + + public function test_register_fails_when_email_format_invalid(): void + { + $payload = [ + 'name' => 'Handika Testing', + 'email' => 'not-an-email', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'phone' => '081234567890', + ]; + + $response = $this->postJson($this->endpoint, $payload); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['email']); + } + + public function test_register_does_not_create_patient_record_if_validation_fails(): void + { + $payload = [ + 'name' => 'Handika Testing', + 'email' => 'not-an-email', + 'password' => 'Password123!', + 'password_confirmation' => 'Password123!', + 'phone' => '081234567890', + ]; + + $this->postJson($this->endpoint, $payload); + + $this->assertDatabaseCount('patients', 0); + $this->assertDatabaseCount('users', 0); + } +} diff --git a/backend/tests/Feature/Auth/AuthenticationTest.php b/backend/tests/Feature/Auth/AuthenticationTest.php deleted file mode 100644 index 2236551..0000000 --- a/backend/tests/Feature/Auth/AuthenticationTest.php +++ /dev/null @@ -1,47 +0,0 @@ -create(); - - $response = $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $this->assertAuthenticated(); - $response->assertNoContent(); - } - - public function test_users_can_not_authenticate_with_invalid_password(): void - { - $user = User::factory()->create(); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'wrong-password', - ]); - - $this->assertGuest(); - } - - public function test_users_can_logout(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/logout'); - - $this->assertGuest(); - $response->assertNoContent(); - } -} diff --git a/backend/tests/Feature/Auth/EmailVerificationTest.php b/backend/tests/Feature/Auth/EmailVerificationTest.php deleted file mode 100644 index 2db7299..0000000 --- a/backend/tests/Feature/Auth/EmailVerificationTest.php +++ /dev/null @@ -1,49 +0,0 @@ -unverified()->create(); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $response = $this->actingAs($user)->get($verificationUrl); - - Event::assertDispatched(Verified::class); - $this->assertTrue($user->fresh()->hasVerifiedEmail()); - $response->assertRedirect(config('app.frontend_url').'/dashboard?verified=1'); - } - - public function test_email_is_not_verified_with_invalid_hash(): void - { - $user = User::factory()->unverified()->create(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')] - ); - - $this->actingAs($user)->get($verificationUrl); - - $this->assertFalse($user->fresh()->hasVerifiedEmail()); - } -} diff --git a/backend/tests/Feature/Auth/PasswordResetTest.php b/backend/tests/Feature/Auth/PasswordResetTest.php deleted file mode 100644 index 9b652bc..0000000 --- a/backend/tests/Feature/Auth/PasswordResetTest.php +++ /dev/null @@ -1,49 +0,0 @@ -create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class); - } - - public function test_password_can_be_reset_with_valid_token(): void - { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function (object $notification) use ($user) { - $response = $this->post('/reset-password', [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertStatus(200); - - return true; - }); - } -} diff --git a/backend/tests/Feature/Auth/RegistrationTest.php b/backend/tests/Feature/Auth/RegistrationTest.php deleted file mode 100644 index b48e150..0000000 --- a/backend/tests/Feature/Auth/RegistrationTest.php +++ /dev/null @@ -1,24 +0,0 @@ -post('/register', [ - 'name' => 'Test User', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $this->assertAuthenticated(); - $response->assertNoContent(); - } -} diff --git a/backend/tests/Feature/ExampleTest.php b/backend/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/backend/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/backend/tests/Unit/ExampleTest.php b/backend/tests/Unit/SampleTest.php similarity index 52% rename from backend/tests/Unit/ExampleTest.php rename to backend/tests/Unit/SampleTest.php index 5773b0c..9e2b25e 100644 --- a/backend/tests/Unit/ExampleTest.php +++ b/backend/tests/Unit/SampleTest.php @@ -4,12 +4,12 @@ use PHPUnit\Framework\TestCase; -class ExampleTest extends TestCase +class SampleTest extends TestCase { /** - * A basic test example. + * A basic unit test example. */ - public function test_that_true_is_true(): void + public function test_example(): void { $this->assertTrue(true); } From 8cfe84c13331062a7e906db7002925a9e15158b2 Mon Sep 17 00:00:00 2001 From: MyPC Date: Mon, 22 Jun 2026 14:34:49 +0700 Subject: [PATCH 08/16] QNH-309 and QNH-311. Create Register Page and Login Page --- frontend/.env.example | 1 + frontend/package-lock.json | 384 +++++++++++++++++- frontend/package.json | 3 + frontend/src/App.tsx | 32 +- frontend/src/api/.gitkeep | 0 frontend/src/api/axiosInstance.ts | 30 ++ frontend/src/contexts/AuthContext.tsx | 56 +++ frontend/src/hooks/useAuth.ts | 10 + frontend/src/pages/LoginPage.tsx | 300 ++++++++++++++ frontend/src/pages/RegisterPage.tsx | 282 +++++++++++++ frontend/src/routes/ProtectedRoute.tsx | 12 + frontend/src/routes/PublicRoute.tsx | 12 + frontend/src/services/authService.ts | 33 ++ frontend/src/tests/pages/LoginPage.test.tsx | 188 +++++++++ .../src/tests/pages/RegisterPage.test.tsx | 160 ++++++++ frontend/src/types/auth.ts | 54 +++ 16 files changed, 1554 insertions(+), 3 deletions(-) create mode 100644 frontend/.env.example delete mode 100644 frontend/src/api/.gitkeep create mode 100644 frontend/src/api/axiosInstance.ts create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 frontend/src/routes/ProtectedRoute.tsx create mode 100644 frontend/src/routes/PublicRoute.tsx create mode 100644 frontend/src/services/authService.ts create mode 100644 frontend/src/tests/pages/LoginPage.test.tsx create mode 100644 frontend/src/tests/pages/RegisterPage.test.tsx create mode 100644 frontend/src/types/auth.ts diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..6789bad --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8000/api \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 91d33b0..7f1b983 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.3.0", + "axios": "^1.18.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router-dom": "^7.18.0", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -19,6 +21,7 @@ "@rolldown/plugin-babel": "^0.2.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/babel__core": "^7.20.5", "@types/node": "^24.12.3", "@types/react": "^19.2.14", @@ -1463,6 +1466,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2000,6 +2017,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2062,6 +2091,24 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/babel-plugin-react-compiler": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", @@ -2152,6 +2199,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -2183,6 +2243,18 @@ "node": ">=18" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2190,6 +2262,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2251,7 +2336,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2279,6 +2363,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2306,6 +2399,20 @@ "license": "MIT", "peer": true }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.368", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", @@ -2339,6 +2446,24 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2346,6 +2471,33 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2660,6 +2812,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2674,6 +2862,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2684,6 +2881,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2710,12 +2944,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2746,6 +3031,19 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3246,6 +3544,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -3253,6 +3560,27 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -3283,7 +3611,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3497,6 +3824,15 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3536,6 +3872,44 @@ "license": "MIT", "peer": true }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3624,6 +3998,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6bd9575..f73efd6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,8 +13,10 @@ }, "dependencies": { "@tailwindcss/vite": "^4.3.0", + "axios": "^1.18.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router-dom": "^7.18.0", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -23,6 +25,7 @@ "@rolldown/plugin-babel": "^0.2.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/babel__core": "^7.20.5", "@types/node": "^24.12.3", "@types/react": "^19.2.14", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4f6543..39258cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,35 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from './contexts/AuthContext'; +import { PublicRoute } from './routes/PublicRoute'; +import { ProtectedRoute } from './routes/ProtectedRoute'; +import RegisterPage from './pages/RegisterPage'; +import LoginPage from './pages/LoginPage'; + function App() { - return <>; + return ( + + + + {/* Public routes — redirect to /dashboard if already authenticated */} + }> + } /> + } /> + + + {/* Protected routes — redirect to /login if not authenticated */} + }> + Dashboard (placeholder)} + /> + + + {/* Fallback */} + } /> + + + + ); } export default App; diff --git a/frontend/src/api/.gitkeep b/frontend/src/api/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/api/axiosInstance.ts b/frontend/src/api/axiosInstance.ts new file mode 100644 index 0000000..d47a347 --- /dev/null +++ b/frontend/src/api/axiosInstance.ts @@ -0,0 +1,30 @@ +import axios, { AxiosError } from 'axios'; + +const axiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + withCredentials: true, +}); + +axiosInstance.interceptors.request.use((config) => { + const token = sessionStorage.getItem('__auth_token__'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +axiosInstance.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 401) { + // Token expired / invalid — caller handles redirect + } + return Promise.reject(error); + } +); + +export default axiosInstance; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..1cabd7b --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,56 @@ +import { + createContext, + useState, + useEffect, + useCallback, + type ReactNode, + } from 'react'; + import type { AuthUser, AuthState, LoginResponseData } from '../types/auth'; + + interface AuthContextValue extends AuthState { + setAuth: (data: LoginResponseData) => void; + clearAuth: () => void; + } + + export const AuthContext = createContext(null); + + const TOKEN_KEY = '__auth_token__'; + + export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState( + () => sessionStorage.getItem(TOKEN_KEY) + ); + + useEffect(() => { + if (token) { + sessionStorage.setItem(TOKEN_KEY, token); + } else { + sessionStorage.removeItem(TOKEN_KEY); + } + }, [token]); + + const setAuth = useCallback((data: LoginResponseData) => { + setToken(data.token); + setUser(data.user); + }, []); + + const clearAuth = useCallback(() => { + setToken(null); + setUser(null); + }, []); + + return ( + + {children} + + ); + } \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..57304e2 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { AuthContext } from '../contexts/AuthContext'; + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2d24284 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,300 @@ +import { useState } from 'react'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { AxiosError } from 'axios'; +import { authService } from '../services/authService'; +import { useAuth } from '../hooks/useAuth'; +import type { LoginPayload, ApiValidationError, UserRole } from '../types/auth'; + +type FieldErrors = Partial>; + +const ROLE_REDIRECT: Record = { + patient: '/dashboard', + doctor: '/dashboard', + nurse: '/dashboard', + admin: '/dashboard', +}; + +export default function LoginPage() { + const navigate = useNavigate(); + const location = useLocation(); + const { setAuth } = useAuth(); + + const successMessage = (location.state as { successMessage?: string } | null) + ?.successMessage; + + const [form, setForm] = useState({ email: '', password: '' }); + const [rememberMe, setRememberMe] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + const [genericError, setGenericError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + function handleChange(e: React.ChangeEvent) { + const { name, value } = e.target; + setForm((prev) => ({ ...prev, [name]: value })); + setFieldErrors((prev) => ({ ...prev, [name]: undefined })); + setGenericError(''); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + setFieldErrors({}); + setGenericError(''); + + try { + const response = await authService.login(form); + setAuth(response.data); + navigate(ROLE_REDIRECT[response.data.user.role], { replace: true }); + } catch (err) { + const error = err as AxiosError; + + if (error.response?.status === 422 && error.response.data.errors) { + const raw = error.response.data.errors; + const mapped: FieldErrors = {}; + for (const key of Object.keys(raw) as Array) { + mapped[key] = raw[key][0]; + } + setFieldErrors(mapped); + } else if (error.response?.status === 401) { + setGenericError('Email atau password salah.'); + } else { + setGenericError('Terjadi kesalahan. Silakan coba lagi.'); + } + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+

QueueNova Health

+

Masuk ke akun Anda

+
+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {genericError && ( +
+ {genericError} +
+ )} + +
+
+ + + {fieldErrors.email && ( + + {fieldErrors.email} + + )} +
+ +
+ + + {fieldErrors.password && ( + + {fieldErrors.password} + + )} +
+ +
+ +
+ + +
+ +

+ Belum punya akun?{' '} + + Daftar sekarang + +

+
+
+ ); +} + +const styles: Record = { + container: { + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f5f7fa', + padding: '24px', + }, + card: { + backgroundColor: '#ffffff', + borderRadius: '8px', + border: '1px solid #e2e8f0', + padding: '40px', + width: '100%', + maxWidth: '400px', + }, + header: { + marginBottom: '28px', + }, + title: { + fontSize: '20px', + fontWeight: 700, + color: '#1a202c', + margin: 0, + }, + subtitle: { + fontSize: '14px', + color: '#64748b', + marginTop: '4px', + marginBottom: 0, + }, + form: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + fieldWrapper: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + label: { + fontSize: '13px', + fontWeight: 500, + color: '#374151', + }, + input: { + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + outline: 'none', + color: '#1a202c', + backgroundColor: '#ffffff', + }, + inputError: { + borderColor: '#ef4444', + }, + fieldError: { + fontSize: '12px', + color: '#ef4444', + }, + successBanner: { + padding: '10px 14px', + backgroundColor: '#f0fdf4', + border: '1px solid #bbf7d0', + borderRadius: '6px', + fontSize: '13px', + color: '#15803d', + marginBottom: '16px', + }, + genericError: { + padding: '10px 14px', + backgroundColor: '#fef2f2', + border: '1px solid #fecaca', + borderRadius: '6px', + fontSize: '13px', + color: '#b91c1c', + marginBottom: '16px', + }, + rememberRow: { + display: 'flex', + alignItems: 'center', + }, + checkboxLabel: { + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '13px', + color: '#374151', + cursor: 'pointer', + }, + checkbox: { + cursor: 'pointer', + }, + button: { + marginTop: '4px', + padding: '11px', + backgroundColor: '#2563eb', + color: '#ffffff', + border: 'none', + borderRadius: '6px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', + }, + buttonDisabled: { + backgroundColor: '#93c5fd', + cursor: 'not-allowed', + }, + footer: { + marginTop: '20px', + fontSize: '13px', + color: '#64748b', + textAlign: 'center', + }, + link: { + color: '#2563eb', + textDecoration: 'none', + fontWeight: 500, + }, +}; \ No newline at end of file diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..b88afc2 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,282 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { AxiosError } from 'axios'; +import { authService } from '../services/authService'; +import type { RegisterPayload, ApiValidationError } from '../types/auth'; + +type FieldErrors = Partial>; + +export default function RegisterPage() { + const navigate = useNavigate(); + + const [form, setForm] = useState({ + name: '', + email: '', + password: '', + password_confirmation: '', + phone: '', + }); + + const [fieldErrors, setFieldErrors] = useState({}); + const [genericError, setGenericError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + function handleChange(e: React.ChangeEvent) { + const { name, value } = e.target; + setForm((prev) => ({ ...prev, [name]: value })); + setFieldErrors((prev) => ({ ...prev, [name]: undefined })); + setGenericError(''); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + setFieldErrors({}); + setGenericError(''); + + try { + await authService.register(form); + navigate('/login', { + state: { successMessage: 'Registrasi berhasil. Silakan login.' }, + }); + } catch (err) { + const error = err as AxiosError; + + if (error.response?.status === 422 && error.response.data.errors) { + const raw = error.response.data.errors; + const mapped: FieldErrors = {}; + for (const key of Object.keys(raw) as Array) { + mapped[key] = raw[key][0]; + } + setFieldErrors(mapped); + } else { + setGenericError('Terjadi kesalahan. Silakan coba lagi.'); + } + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+

QueueNova Health

+

Buat akun baru

+
+ + {genericError && ( +
+ {genericError} +
+ )} + +
+ + + + + + + + + +

+ Sudah punya akun?{' '} + + Masuk + +

+
+
+ ); +} + +interface FieldProps { + label: string; + name: string; + type: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + error?: string; + autoComplete?: string; +} + +function Field({ label, name, type, value, onChange, error, autoComplete }: FieldProps) { + return ( +
+ + + {error && ( + + {error} + + )} +
+ ); +} + +const styles: Record = { + container: { + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f5f7fa', + padding: '24px', + }, + card: { + backgroundColor: '#ffffff', + borderRadius: '8px', + border: '1px solid #e2e8f0', + padding: '40px', + width: '100%', + maxWidth: '420px', + }, + header: { + marginBottom: '28px', + }, + title: { + fontSize: '20px', + fontWeight: 700, + color: '#1a202c', + margin: 0, + }, + subtitle: { + fontSize: '14px', + color: '#64748b', + marginTop: '4px', + marginBottom: 0, + }, + form: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + fieldWrapper: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + label: { + fontSize: '13px', + fontWeight: 500, + color: '#374151', + }, + input: { + padding: '10px 12px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + outline: 'none', + color: '#1a202c', + backgroundColor: '#ffffff', + transition: 'border-color 0.15s', + }, + inputError: { + borderColor: '#ef4444', + }, + fieldError: { + fontSize: '12px', + color: '#ef4444', + }, + genericError: { + padding: '10px 14px', + backgroundColor: '#fef2f2', + border: '1px solid #fecaca', + borderRadius: '6px', + fontSize: '13px', + color: '#b91c1c', + marginBottom: '16px', + }, + button: { + marginTop: '4px', + padding: '11px', + backgroundColor: '#2563eb', + color: '#ffffff', + border: 'none', + borderRadius: '6px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', + }, + buttonDisabled: { + backgroundColor: '#93c5fd', + cursor: 'not-allowed', + }, + footer: { + marginTop: '20px', + fontSize: '13px', + color: '#64748b', + textAlign: 'center', + }, + link: { + color: '#2563eb', + textDecoration: 'none', + fontWeight: 500, + }, +}; \ No newline at end of file diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..a9b21d8 --- /dev/null +++ b/frontend/src/routes/ProtectedRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +export function ProtectedRoute() { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/frontend/src/routes/PublicRoute.tsx b/frontend/src/routes/PublicRoute.tsx new file mode 100644 index 0000000..3faff51 --- /dev/null +++ b/frontend/src/routes/PublicRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +export function PublicRoute() { + const { isAuthenticated } = useAuth(); + + if (isAuthenticated) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts new file mode 100644 index 0000000..d12badd --- /dev/null +++ b/frontend/src/services/authService.ts @@ -0,0 +1,33 @@ +import axiosInstance from '../api/axiosInstance'; +import type { + RegisterPayload, + LoginPayload, + LoginResponseData, + AuthUser, + ApiSuccessResponse, +} from '../types/auth'; + +export const authService = { + async register( + payload: RegisterPayload + ): Promise> { + const response = await axiosInstance.post>( + '/auth/register', + payload + ); + return response.data; + }, + + async login( + payload: LoginPayload + ): Promise> { + const response = await axiosInstance.post< +ApiSuccessResponse +>('/auth/login', payload); + return response.data; + }, + + async logout(): Promise { + await axiosInstance.post('/auth/logout'); + }, +}; \ No newline at end of file diff --git a/frontend/src/tests/pages/LoginPage.test.tsx b/frontend/src/tests/pages/LoginPage.test.tsx new file mode 100644 index 0000000..7eb606b --- /dev/null +++ b/frontend/src/tests/pages/LoginPage.test.tsx @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthContext } from '../../contexts/AuthContext'; +import LoginPage from '../../pages/LoginPage'; +import { authService } from '../../services/authService'; +import type { AuthState, LoginResponseData } from '../../types/auth'; + +vi.mock('../../services/authService'); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +const mockSetAuth = vi.fn<(data: LoginResponseData) => void>(); +const mockClearAuth = vi.fn<() => void>(); + +const mockAuthContextValue = { + user: null, + token: null, + isAuthenticated: false, + setAuth: mockSetAuth, + clearAuth: mockClearAuth, + } satisfies AuthState & { + setAuth: (data: LoginResponseData) => void; + clearAuth: () => void; + }; + +function renderLoginPage() { + return render( + + + + + + ); +} + +const mockLoginResponse: { message: string; data: LoginResponseData } = { + message: 'Login berhasil.', + data: { + token: 'test-token-123', + token_type: 'Bearer', + user: { + user_id: 10, + email: 'ucok@example.com', + role: 'patient', + status: 'active', + profile: { + patient_id: 5, + name: 'Ucok Sitorus', + phone: '081234567890', + bpjs_number: null, + birth_place: null, + birth_date: null, + gender: null, + }, + }, + }, +}; + +describe('LoginPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders email, password fields and submit button', () => { + renderLoginPage(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Masuk' })).toBeInTheDocument(); + }); + + it('calls authService.login with correct payload on submit', async () => { + vi.mocked(authService.login).mockResolvedValueOnce(mockLoginResponse); + + renderLoginPage(); + + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'ucok@example.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'password123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + + await waitFor(() => { + expect(authService.login).toHaveBeenCalledWith({ + email: 'ucok@example.com', + password: 'password123', + }); + }); + }); + + it('calls setAuth and navigates to /dashboard on successful login', async () => { + vi.mocked(authService.login).mockResolvedValueOnce(mockLoginResponse); + + renderLoginPage(); + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'ucok@example.com' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'password123' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + + await waitFor(() => { + expect(mockSetAuth).toHaveBeenCalledWith(mockLoginResponse.data); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + it('displays "Email atau password salah" on 401', async () => { + const { AxiosError } = await import('axios'); + const error = new AxiosError('Unauthorized'); + error.response = { + status: 401, + data: { message: 'Email atau password salah.' }, + } as never; + + vi.mocked(authService.login).mockRejectedValueOnce(error); + + renderLoginPage(); + fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + + await waitFor(() => { + expect( + screen.getByText('Email atau password salah.') + ).toBeInTheDocument(); + }); + }); + + it('displays per-field errors on 422', async () => { + const { AxiosError } = await import('axios'); + const error = new AxiosError('Validation error'); + error.response = { + status: 422, + data: { + message: 'The given data was invalid.', + errors: { email: ['The email field is required.'] }, + }, + } as never; + + vi.mocked(authService.login).mockRejectedValueOnce(error); + + renderLoginPage(); + fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + + await waitFor(() => { + expect( + screen.getByText('The email field is required.') + ).toBeInTheDocument(); + }); + }); + + it('disables submit button while loading', async () => { + vi.mocked(authService.login).mockImplementation( + () => new Promise(() => {}) + ); + + renderLoginPage(); + fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Masuk...' })).toBeDisabled(); + }); + }); + + it('renders success message passed via location state', () => { + render( + + + + + + ); + + expect( + screen.getByText('Registrasi berhasil. Silakan login.') + ).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/tests/pages/RegisterPage.test.tsx b/frontend/src/tests/pages/RegisterPage.test.tsx new file mode 100644 index 0000000..4f13cb4 --- /dev/null +++ b/frontend/src/tests/pages/RegisterPage.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthContext } from '../../contexts/AuthContext'; +import RegisterPage from '../../pages/RegisterPage'; +import { authService } from '../../services/authService'; +import type { AuthState, LoginResponseData } from '../../types/auth'; + +vi.mock('../../services/authService'); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +const mockSetAuth = vi.fn<(data: LoginResponseData) => void>(); +const mockClearAuth = vi.fn<() => void>(); + +const mockAuthContextValue = { + user: null, + token: null, + isAuthenticated: false, + setAuth: mockSetAuth, + clearAuth: mockClearAuth, +} satisfies AuthState & { + setAuth: (data: LoginResponseData) => void; + clearAuth: () => void; +}; + +function renderRegisterPage() { + return render( + + + + + + ); +} + +describe('RegisterPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all form fields and submit button', () => { + renderRegisterPage(); + expect(screen.getByLabelText('Nama Lengkap')).toBeInTheDocument(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByLabelText('No. Telepon')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Konfirmasi Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Daftar' })).toBeInTheDocument(); + }); + + it('calls authService.register with form values on submit', async () => { + vi.mocked(authService.register).mockResolvedValueOnce({ + message: 'Registrasi berhasil.', + data: {} as never, + }); + + renderRegisterPage(); + + fireEvent.change(screen.getByLabelText('Nama Lengkap'), { + target: { value: 'Ucok Sitorus' }, + }); + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'ucok@example.com' }, + }); + fireEvent.change(screen.getByLabelText('No. Telepon'), { + target: { value: '081234567890' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'password123' }, + }); + fireEvent.change(screen.getByLabelText('Konfirmasi Password'), { + target: { value: 'password123' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + + await waitFor(() => { + expect(authService.register).toHaveBeenCalledWith({ + name: 'Ucok Sitorus', + email: 'ucok@example.com', + phone: '081234567890', + password: 'password123', + password_confirmation: 'password123', + }); + }); + }); + + it('redirects to /login with success message on 201', async () => { + vi.mocked(authService.register).mockResolvedValueOnce({ + message: 'Registrasi berhasil.', + data: {} as never, + }); + + renderRegisterPage(); + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'ucok@example.com' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/login', { + state: { successMessage: 'Registrasi berhasil. Silakan login.' }, + }); + }); + }); + + it('displays per-field errors on 422', async () => { + const { AxiosError } = await import('axios'); + const error = new AxiosError('Validation error'); + error.response = { + status: 422, + data: { + message: 'The given data was invalid.', + errors: { email: ['The email has already been taken.'] }, + }, + } as never; + + vi.mocked(authService.register).mockRejectedValueOnce(error); + + renderRegisterPage(); + fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + + await waitFor(() => { + expect( + screen.getByText('The email has already been taken.') + ).toBeInTheDocument(); + }); + }); + + it('displays generic error on network failure', async () => { + vi.mocked(authService.register).mockRejectedValueOnce(new Error('Network Error')); + + renderRegisterPage(); + fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + + await waitFor(() => { + expect( + screen.getByText('Terjadi kesalahan. Silakan coba lagi.') + ).toBeInTheDocument(); + }); + }); + + it('disables submit button while loading', async () => { + vi.mocked(authService.register).mockImplementation( + () => new Promise(() => {}) + ); + + renderRegisterPage(); + fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Mendaftar...' })).toBeDisabled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..8e1aaa6 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,54 @@ +export interface PatientProfile { + patient_id: number; + name: string; + phone: string; + bpjs_number: string | null; + birth_place: string | null; + birth_date: string | null; + gender: string | null; + } + + export type UserRole = 'patient' | 'doctor' | 'nurse' | 'admin'; + + export interface AuthUser { + user_id: number; + email: string; + role: UserRole; + status: string; + profile: PatientProfile; + } + + export interface AuthState { + user: AuthUser | null; + token: string | null; + isAuthenticated: boolean; + } + + export interface RegisterPayload { + name: string; + email: string; + password: string; + password_confirmation: string; + phone: string; + } + + export interface LoginPayload { + email: string; + password: string; + } + + export interface LoginResponseData { + token: string; + token_type: string; + user: AuthUser; + } + + export interface ApiSuccessResponse { + message: string; + data: T; + } + + export interface ApiValidationError { + message: string; + errors: Record; + } \ No newline at end of file From 6923fa47397d6639df963ebc319d539a854e9f8e Mon Sep 17 00:00:00 2001 From: MyPC Date: Mon, 22 Jun 2026 15:09:16 +0700 Subject: [PATCH 09/16] Add ADR-006 File --- docs/decisions/ADR-006-sanctum-auth-mode.md | 82 +++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/decisions/ADR-006-sanctum-auth-mode.md diff --git a/docs/decisions/ADR-006-sanctum-auth-mode.md b/docs/decisions/ADR-006-sanctum-auth-mode.md new file mode 100644 index 0000000..158e799 --- /dev/null +++ b/docs/decisions/ADR-006-sanctum-auth-mode.md @@ -0,0 +1,82 @@ +# ADR-006: Sanctum Authentication Mode — Cookie-based Session (SPA) + +## Status +Accepted + +## Date +2025-06-22 + +## Context +API contract awal (OpenAPI v2.1) mendefinisikan autentikasi menggunakan +Sanctum Bearer Token (plain-text token dikembalikan di response body, +disimpan di frontend, dan dilampirkan manual via header +`Authorization: Bearer {token}`). + +Selama implementasi Sprint 9 (AUTH-01, AUTH-02), tim melakukan diskusi +teknis dan menyepakati perubahan ke Sanctum Cookie-based Session (SPA mode). + +Dua mode Sanctum yang dipertimbangkan: + +**Mode A — Bearer Token** +- Token dikembalikan di response body (`data.token`) +- Frontend menyimpan token (React state, sessionStorage, atau localStorage) +- Setiap request melampirkan `Authorization: Bearer {token}` secara manual +- Stateless dari sisi server per-request + +**Mode B — Cookie-based Session (SPA)** +- Tidak ada token di response body +- Frontend melakukan `GET /sanctum/csrf-cookie` sebelum login untuk + mendapatkan XSRF-TOKEN dan laravel_session cookie +- Laravel mengelola session server-side +- Browser mengirim cookie otomatis di setiap request (`withCredentials: true`) +- CSRF protection dihandle oleh Sanctum via XSRF-TOKEN cookie + +## Decision +Tim memilih **Mode B: Cookie-based Session (SPA)**. + +Alasan: +1. Tidak ada token yang perlu disimpan di frontend — menghilangkan seluruh + perdebatan localStorage vs sessionStorage vs React memory +2. CSRF protection built-in dari Sanctum tanpa implementasi tambahan di FE +3. Lebih sesuai dengan use case SPA + same-origin atau subdomain deployment +4. Backend Laravel lebih natural menggunakan session daripada token management + untuk aplikasi web (bukan mobile/third-party API) + +## Consequences + +### Positif +- Frontend tidak menyimpan credential dalam bentuk apapun +- Tidak perlu token refresh strategy +- Logout cukup invalidate session di server — tidak perlu tracking token + +### Negatif / Trade-off +- Requires `GET /sanctum/csrf-cookie` sebelum setiap login (satu extra request) +- `withCredentials: true` wajib di semua request — sudah terpenuhi di + `axiosInstance` +- CORS di BE harus dikonfigurasi dengan benar (`supports_credentials: true`, + `allowed_origins` tidak boleh wildcard `*`) +- Tidak cocok untuk mobile app atau third-party API consumer di masa depan + tanpa perubahan arsitektur auth + +### Dampak ke implementasi yang sudah ada +- `axiosInstance.ts`: interceptor yang attach `Authorization: Bearer` header + harus dihapus +- `AuthContext.tsx`: tidak perlu menyimpan token, cukup menyimpan `user` object +- `authService.ts`: login harus diawali `GET /sanctum/csrf-cookie`, response + tidak mengandung token +- `types/auth.ts`: `LoginResponseData` tidak perlu field `token` dan + `token_type` +- OpenAPI contract perlu diupdate oleh BE untuk mencerminkan perubahan ini + (in progress) + +## Implementation Note +Revisi implementasi FE (axiosInstance, AuthContext, authService, types) akan +dilakukan setelah OpenAPI contract diupdate oleh BE sebagai source of truth. +Kode yang sudah ada (AUTH-01, AUTH-02) masih menggunakan Bearer Token dan +belum mencerminkan keputusan ini. + +## References +- Laravel Sanctum SPA Authentication: + https://laravel.com/docs/sanctum#spa-authentication +- OpenAPI contract v2.1 (Bearer Token — akan diupdate ke v2.2) +- Sprint 9 AUTH-01, AUTH-02 implementation (feature branches) \ No newline at end of file From 2c49557a5d5e936c5ce2112a414f5248fd46aeb3 Mon Sep 17 00:00:00 2001 From: MyPC Date: Mon, 22 Jun 2026 21:39:15 +0700 Subject: [PATCH 10/16] improve the login and registration pages --- ...nurse-medical-consultation-MAHHY3lElT4.png | Bin 0 -> 104312 bytes frontend/src/index.css | 122 ++--- frontend/src/main.tsx | 3 + frontend/src/pages/LoginPage.tsx | 392 +++++++--------- frontend/src/pages/RegisterPage.tsx | 443 +++++++++--------- .../src/tests/pages/RegisterPage.test.tsx | 27 +- frontend/src/types/auth.ts | 2 +- 7 files changed, 480 insertions(+), 509 deletions(-) create mode 100644 frontend/src/assets/images/canva-doctor-and-nurse-medical-consultation-MAHHY3lElT4.png diff --git a/frontend/src/assets/images/canva-doctor-and-nurse-medical-consultation-MAHHY3lElT4.png b/frontend/src/assets/images/canva-doctor-and-nurse-medical-consultation-MAHHY3lElT4.png new file mode 100644 index 0000000000000000000000000000000000000000..cb5d6ff20d16fa4ba3ad51193f879988adff50ff GIT binary patch literal 104312 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

Ms& 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 literal 0 HcmV?d00001 diff --git a/frontend/src/index.css b/frontend/src/index.css index 33a8327..7994525 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,7 @@ @import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + :root { --text: #6b6375; --text-h: #08060d; @@ -16,24 +19,14 @@ --heading: system-ui, 'Segoe UI', Roboto, sans-serif; --mono: ui-monospace, Consolas, monospace; - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - @media (max-width: 1024px) { font-size: 16px; } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; +.dark { + /* dark theme — prepared for future, not active yet */ + --text: #9ca3af; --text-h: #f3f4f6; --bg: #16171d; --border: #2e303a; @@ -44,8 +37,7 @@ --social-bg: rgba(47, 48, 58, 0.5); --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - + #social .button-icon { filter: invert(1) brightness(2); } @@ -63,50 +55,64 @@ box-sizing: border-box; } -body { - margin: 0; -} +@layer base { + html { + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; + body { + margin: 0; } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); + + h1, + h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); + } + + h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } + } + h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } + } + p { + margin: 0; + } + + code, + .counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); + } + + code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); + } } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..318fbf8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +document.documentElement.classList.add('light'); +document.documentElement.dataset.theme = 'light'; + createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 2d24284..9483949 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,9 +1,11 @@ +// frontend/src/pages/LoginPage.tsx import { useState } from 'react'; import { useNavigate, useLocation, Link } from 'react-router-dom'; import { AxiosError } from 'axios'; import { authService } from '../services/authService'; import { useAuth } from '../hooks/useAuth'; import type { LoginPayload, ApiValidationError, UserRole } from '../types/auth'; +import doctorsIllustration from '../assets/images/canva-doctor-and-nurse-medical-consultation-MAHHY3lElT4.png'; type FieldErrors = Partial>; @@ -24,6 +26,7 @@ export default function LoginPage() { const [form, setForm] = useState({ email: '', password: '' }); const [rememberMe, setRememberMe] = useState(false); + const [showPassword, setShowPassword] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); const [genericError, setGenericError] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -66,235 +69,184 @@ export default function LoginPage() { } return ( -

-
-
-

QueueNova Health

-

Masuk ke akun Anda

+
+ {/* LEFT PANEL */} +
+
+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {genericError && ( +
+ {genericError} +
+ )} + +

Sign in

+ +

+ Don't have an account?{' '} + + Create now + +

+ +
+ {/* Email */} +
+ + + {fieldErrors.email && ( + + {fieldErrors.email} + + )} +
+ + {/* Password */} +
+ +
+ + +
+ {fieldErrors.password && ( + + {fieldErrors.password} + + )} +
+ + {/* Remember me + Forgot password */} +
+ + + Forgot Password? + +
+ + {/* Submit */} + +
+
- {successMessage && ( -
- {successMessage} -
- )} - - {genericError && ( -
- {genericError} -
- )} - -
-
- - - {fieldErrors.email && ( - - {fieldErrors.email} - - )} + {/* RIGHT PANEL */} +
+ {/* Logo */} +
+
+ + + + +
+ QueueNova +
-
- - +
+ {/* Grey cloud blob behind illustration */} +
+ Doctor and nurse illustration - {fieldErrors.password && ( - - {fieldErrors.password} - - )}
-
- -
+

+ Welcome to QueueNova Health +

+
- - - -

- Belum punya akun?{' '} - - Daftar sekarang - -

+ + + + +
); -} - -const styles: Record = { - container: { - minHeight: '100vh', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#f5f7fa', - padding: '24px', - }, - card: { - backgroundColor: '#ffffff', - borderRadius: '8px', - border: '1px solid #e2e8f0', - padding: '40px', - width: '100%', - maxWidth: '400px', - }, - header: { - marginBottom: '28px', - }, - title: { - fontSize: '20px', - fontWeight: 700, - color: '#1a202c', - margin: 0, - }, - subtitle: { - fontSize: '14px', - color: '#64748b', - marginTop: '4px', - marginBottom: 0, - }, - form: { - display: 'flex', - flexDirection: 'column', - gap: '16px', - }, - fieldWrapper: { - display: 'flex', - flexDirection: 'column', - gap: '4px', - }, - label: { - fontSize: '13px', - fontWeight: 500, - color: '#374151', - }, - input: { - padding: '10px 12px', - fontSize: '14px', - border: '1px solid #d1d5db', - borderRadius: '6px', - outline: 'none', - color: '#1a202c', - backgroundColor: '#ffffff', - }, - inputError: { - borderColor: '#ef4444', - }, - fieldError: { - fontSize: '12px', - color: '#ef4444', - }, - successBanner: { - padding: '10px 14px', - backgroundColor: '#f0fdf4', - border: '1px solid #bbf7d0', - borderRadius: '6px', - fontSize: '13px', - color: '#15803d', - marginBottom: '16px', - }, - genericError: { - padding: '10px 14px', - backgroundColor: '#fef2f2', - border: '1px solid #fecaca', - borderRadius: '6px', - fontSize: '13px', - color: '#b91c1c', - marginBottom: '16px', - }, - rememberRow: { - display: 'flex', - alignItems: 'center', - }, - checkboxLabel: { - display: 'flex', - alignItems: 'center', - gap: '8px', - fontSize: '13px', - color: '#374151', - cursor: 'pointer', - }, - checkbox: { - cursor: 'pointer', - }, - button: { - marginTop: '4px', - padding: '11px', - backgroundColor: '#2563eb', - color: '#ffffff', - border: 'none', - borderRadius: '6px', - fontSize: '14px', - fontWeight: 600, - cursor: 'pointer', - }, - buttonDisabled: { - backgroundColor: '#93c5fd', - cursor: 'not-allowed', - }, - footer: { - marginTop: '20px', - fontSize: '13px', - color: '#64748b', - textAlign: 'center', - }, - link: { - color: '#2563eb', - textDecoration: 'none', - fontWeight: 500, - }, -}; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index b88afc2..24c7944 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -1,8 +1,10 @@ +// frontend/src/pages/RegisterPage.tsx import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { AxiosError } from 'axios'; import { authService } from '../services/authService'; import type { RegisterPayload, ApiValidationError } from '../types/auth'; +import doctorsIllustration from '../assets/images/canva-doctor-and-nurse-medical-consultation-MAHHY3lElT4.png'; type FieldErrors = Partial>; @@ -14,9 +16,10 @@ export default function RegisterPage() { email: '', password: '', password_confirmation: '', - phone: '', }); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirmation, setShowPasswordConfirmation] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); const [genericError, setGenericError] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -58,225 +61,237 @@ export default function RegisterPage() { } return ( -
-
-
-

QueueNova Health

-

Buat akun baru

-
+
+ {/* LEFT PANEL */} +
+
- {genericError && ( -
- {genericError} -
- )} + {genericError && ( +
+ {genericError} +
+ )} -
- - - - - +

Sign up

- - +

+ Already have an account?{' '} + + Sign in + +

+ +
+ + {/* Name */} +
+ + + {fieldErrors.name && ( + + {fieldErrors.name} + + )} +
+ + {/* Email */} +
+ + + {fieldErrors.email && ( + + {fieldErrors.email} + + )} +
-

- Sudah punya akun?{' '} - - Masuk - -

+ {/* Password */} +
+ +
+ + +
+ {fieldErrors.password && ( + + {fieldErrors.password} + + )} +
+ + {/* Confirm Password */} +
+ +
+ + +
+ {fieldErrors.password_confirmation && ( + + {fieldErrors.password_confirmation} + + )} +
+ + {/* Submit */} + +
+
-
- ); -} -interface FieldProps { - label: string; - name: string; - type: string; - value: string; - onChange: (e: React.ChangeEvent) => void; - error?: string; - autoComplete?: string; -} + {/* RIGHT PANEL */} +
+ {/* Logo */} +
+
+ + + + + +
+ QueueNova +
-function Field({ label, name, type, value, onChange, error, autoComplete }: FieldProps) { - return ( -
- - - {error && ( - - {error} - - )} + {/* Illustration */} +
+
+
+ Doctor and nurse illustration +
+ +

+ Welcome to QueueNova Health +

+
+ + {/* Carousel dots */} +
+ + + + + +
+
); -} - -const styles: Record = { - container: { - minHeight: '100vh', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#f5f7fa', - padding: '24px', - }, - card: { - backgroundColor: '#ffffff', - borderRadius: '8px', - border: '1px solid #e2e8f0', - padding: '40px', - width: '100%', - maxWidth: '420px', - }, - header: { - marginBottom: '28px', - }, - title: { - fontSize: '20px', - fontWeight: 700, - color: '#1a202c', - margin: 0, - }, - subtitle: { - fontSize: '14px', - color: '#64748b', - marginTop: '4px', - marginBottom: 0, - }, - form: { - display: 'flex', - flexDirection: 'column', - gap: '16px', - }, - fieldWrapper: { - display: 'flex', - flexDirection: 'column', - gap: '4px', - }, - label: { - fontSize: '13px', - fontWeight: 500, - color: '#374151', - }, - input: { - padding: '10px 12px', - fontSize: '14px', - border: '1px solid #d1d5db', - borderRadius: '6px', - outline: 'none', - color: '#1a202c', - backgroundColor: '#ffffff', - transition: 'border-color 0.15s', - }, - inputError: { - borderColor: '#ef4444', - }, - fieldError: { - fontSize: '12px', - color: '#ef4444', - }, - genericError: { - padding: '10px 14px', - backgroundColor: '#fef2f2', - border: '1px solid #fecaca', - borderRadius: '6px', - fontSize: '13px', - color: '#b91c1c', - marginBottom: '16px', - }, - button: { - marginTop: '4px', - padding: '11px', - backgroundColor: '#2563eb', - color: '#ffffff', - border: 'none', - borderRadius: '6px', - fontSize: '14px', - fontWeight: 600, - cursor: 'pointer', - }, - buttonDisabled: { - backgroundColor: '#93c5fd', - cursor: 'not-allowed', - }, - footer: { - marginTop: '20px', - fontSize: '13px', - color: '#64748b', - textAlign: 'center', - }, - link: { - color: '#2563eb', - textDecoration: 'none', - fontWeight: 500, - }, -}; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/tests/pages/RegisterPage.test.tsx b/frontend/src/tests/pages/RegisterPage.test.tsx index 4f13cb4..1cc7961 100644 --- a/frontend/src/tests/pages/RegisterPage.test.tsx +++ b/frontend/src/tests/pages/RegisterPage.test.tsx @@ -45,12 +45,11 @@ describe('RegisterPage', () => { it('renders all form fields and submit button', () => { renderRegisterPage(); - expect(screen.getByLabelText('Nama Lengkap')).toBeInTheDocument(); + expect(screen.getByLabelText('Full Name')).toBeInTheDocument(); expect(screen.getByLabelText('Email')).toBeInTheDocument(); - expect(screen.getByLabelText('No. Telepon')).toBeInTheDocument(); expect(screen.getByLabelText('Password')).toBeInTheDocument(); - expect(screen.getByLabelText('Konfirmasi Password')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Daftar' })).toBeInTheDocument(); + expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Register' })).toBeInTheDocument(); }); it('calls authService.register with form values on submit', async () => { @@ -61,29 +60,25 @@ describe('RegisterPage', () => { renderRegisterPage(); - fireEvent.change(screen.getByLabelText('Nama Lengkap'), { + fireEvent.change(screen.getByLabelText('Full Name'), { target: { value: 'Ucok Sitorus' }, }); fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ucok@example.com' }, }); - fireEvent.change(screen.getByLabelText('No. Telepon'), { - target: { value: '081234567890' }, - }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' }, }); - fireEvent.change(screen.getByLabelText('Konfirmasi Password'), { + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password123' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + fireEvent.click(screen.getByRole('button', { name: 'Register' })); await waitFor(() => { expect(authService.register).toHaveBeenCalledWith({ name: 'Ucok Sitorus', email: 'ucok@example.com', - phone: '081234567890', password: 'password123', password_confirmation: 'password123', }); @@ -100,7 +95,7 @@ describe('RegisterPage', () => { fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ucok@example.com' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + fireEvent.click(screen.getByRole('button', { name: 'Register' })); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/login', { @@ -123,7 +118,7 @@ describe('RegisterPage', () => { vi.mocked(authService.register).mockRejectedValueOnce(error); renderRegisterPage(); - fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + fireEvent.click(screen.getByRole('button', { name: 'Register' })); await waitFor(() => { expect( @@ -136,7 +131,7 @@ describe('RegisterPage', () => { vi.mocked(authService.register).mockRejectedValueOnce(new Error('Network Error')); renderRegisterPage(); - fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + fireEvent.click(screen.getByRole('button', { name: 'Register' })); await waitFor(() => { expect( @@ -151,10 +146,10 @@ describe('RegisterPage', () => { ); renderRegisterPage(); - fireEvent.click(screen.getByRole('button', { name: 'Daftar' })); + fireEvent.click(screen.getByRole('button', { name: 'Register' })); await waitFor(() => { - expect(screen.getByRole('button', { name: 'Mendaftar...' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Register...' })).toBeDisabled(); }); }); }); \ No newline at end of file diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 8e1aaa6..8d356e8 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -29,7 +29,7 @@ export interface PatientProfile { email: string; password: string; password_confirmation: string; - phone: string; + // phone: string; } export interface LoginPayload { From 69ca7d093d631b4c71316a3f23376bb4f5a99712 Mon Sep 17 00:00:00 2001 From: MyPC Date: Mon, 22 Jun 2026 22:07:48 +0700 Subject: [PATCH 11/16] improve the login and registration pages --- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/tests/pages/LoginPage.test.tsx | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 9483949..eea2750 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -99,7 +99,7 @@ export default function LoginPage() { {/* Email */}
{ renderLoginPage(); expect(screen.getByLabelText('Email')).toBeInTheDocument(); expect(screen.getByLabelText('Password')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Masuk' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument(); }); it('calls authService.login with correct payload on submit', async () => { @@ -84,7 +84,7 @@ describe('LoginPage', () => { fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { expect(authService.login).toHaveBeenCalledWith({ @@ -104,7 +104,7 @@ describe('LoginPage', () => { fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' }, }); - fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { expect(mockSetAuth).toHaveBeenCalledWith(mockLoginResponse.data); @@ -123,7 +123,7 @@ describe('LoginPage', () => { vi.mocked(authService.login).mockRejectedValueOnce(error); renderLoginPage(); - fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { expect( @@ -146,7 +146,7 @@ describe('LoginPage', () => { vi.mocked(authService.login).mockRejectedValueOnce(error); renderLoginPage(); - fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { expect( @@ -161,10 +161,10 @@ describe('LoginPage', () => { ); renderLoginPage(); - fireEvent.click(screen.getByRole('button', { name: 'Masuk' })); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { - expect(screen.getByRole('button', { name: 'Masuk...' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled(); }); }); From a7d1af91334f7e90bdb48e5e731ead7d1e304d19 Mon Sep 17 00:00:00 2001 From: MyPC Date: Tue, 23 Jun 2026 15:42:14 +0700 Subject: [PATCH 12/16] Migrasi autentikasi dari Bearer Token ke Sanctum Cookie-based --- frontend/.env.example | 3 +- frontend/src/api/axiosInstance.ts | 10 +- frontend/src/contexts/AuthContext.tsx | 93 +++++++--------- frontend/src/services/authService.ts | 15 ++- frontend/src/tests/pages/LoginPage.test.tsx | 34 +++--- .../src/tests/pages/RegisterPage.test.tsx | 9 +- frontend/src/types/auth.ts | 103 +++++++++--------- 7 files changed, 128 insertions(+), 139 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index 6789bad..0ea2969 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1,2 @@ -VITE_API_URL=http://localhost:8000/api \ No newline at end of file +VITE_API_URL=http://localhost:8000/api +VITE_API_BASE_URL=http://localhost:8000 \ No newline at end of file diff --git a/frontend/src/api/axiosInstance.ts b/frontend/src/api/axiosInstance.ts index d47a347..3587f73 100644 --- a/frontend/src/api/axiosInstance.ts +++ b/frontend/src/api/axiosInstance.ts @@ -9,19 +9,11 @@ const axiosInstance = axios.create({ withCredentials: true, }); -axiosInstance.interceptors.request.use((config) => { - const token = sessionStorage.getItem('__auth_token__'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - axiosInstance.interceptors.response.use( (response) => response, (error: AxiosError) => { if (error.response?.status === 401) { - // Token expired / invalid — caller handles redirect + // Session expired — caller handles redirect } return Promise.reject(error); } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 1cabd7b..8c7b23e 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,56 +1,39 @@ import { - createContext, - useState, - useEffect, - useCallback, - type ReactNode, - } from 'react'; - import type { AuthUser, AuthState, LoginResponseData } from '../types/auth'; - - interface AuthContextValue extends AuthState { - setAuth: (data: LoginResponseData) => void; - clearAuth: () => void; - } - - export const AuthContext = createContext(null); - - const TOKEN_KEY = '__auth_token__'; - - export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [token, setToken] = useState( - () => sessionStorage.getItem(TOKEN_KEY) - ); - - useEffect(() => { - if (token) { - sessionStorage.setItem(TOKEN_KEY, token); - } else { - sessionStorage.removeItem(TOKEN_KEY); - } - }, [token]); - - const setAuth = useCallback((data: LoginResponseData) => { - setToken(data.token); - setUser(data.user); - }, []); - - const clearAuth = useCallback(() => { - setToken(null); - setUser(null); - }, []); - - return ( - - {children} - - ); - } \ No newline at end of file + createContext, + useState, + useCallback, + type ReactNode, +} from 'react'; +import type { AuthUser, AuthState, LoginResponseData } from '../types/auth'; + +interface AuthContextValue extends AuthState { + setAuth: (data: LoginResponseData) => void; + clearAuth: () => void; +} + +export const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + + const setAuth = useCallback((data: LoginResponseData) => { + setUser(data.user); + }, []); + + const clearAuth = useCallback(() => { + setUser(null); + }, []); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index d12badd..b746785 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -7,10 +7,17 @@ import type { ApiSuccessResponse, } from '../types/auth'; +const CSRF_COOKIE_URL = `${import.meta.env.VITE_API_BASE_URL}/sanctum/csrf-cookie`; + +async function initCsrf(): Promise { + await axiosInstance.get(CSRF_COOKIE_URL, { baseURL: '' }); +} + export const authService = { async register( payload: RegisterPayload ): Promise> { + await initCsrf(); const response = await axiosInstance.post>( '/auth/register', payload @@ -21,9 +28,11 @@ export const authService = { async login( payload: LoginPayload ): Promise> { - const response = await axiosInstance.post< -ApiSuccessResponse ->('/auth/login', payload); + await initCsrf(); + const response = await axiosInstance.post>( + '/auth/login', + payload + ); return response.data; }, diff --git a/frontend/src/tests/pages/LoginPage.test.tsx b/frontend/src/tests/pages/LoginPage.test.tsx index 9eec75a..b88f88e 100644 --- a/frontend/src/tests/pages/LoginPage.test.tsx +++ b/frontend/src/tests/pages/LoginPage.test.tsx @@ -18,15 +18,14 @@ const mockSetAuth = vi.fn<(data: LoginResponseData) => void>(); const mockClearAuth = vi.fn<() => void>(); const mockAuthContextValue = { - user: null, - token: null, - isAuthenticated: false, - setAuth: mockSetAuth, - clearAuth: mockClearAuth, - } satisfies AuthState & { - setAuth: (data: LoginResponseData) => void; - clearAuth: () => void; - }; + user: null, + isAuthenticated: false, + setAuth: mockSetAuth, + clearAuth: mockClearAuth, +} satisfies AuthState & { + setAuth: (data: LoginResponseData) => void; + clearAuth: () => void; +}; function renderLoginPage() { return render( @@ -39,10 +38,8 @@ function renderLoginPage() { } const mockLoginResponse: { message: string; data: LoginResponseData } = { - message: 'Login successful.', + message: 'Login berhasil.', data: { - token: 'test-token-123', - token_type: 'Bearer', user: { user_id: 10, email: 'ucok@example.com', @@ -108,7 +105,9 @@ describe('LoginPage', () => { await waitFor(() => { expect(mockSetAuth).toHaveBeenCalledWith(mockLoginResponse.data); - expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { + replace: true, + }); }); }); @@ -164,7 +163,9 @@ describe('LoginPage', () => { fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { - expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Logging in...' }) + ).toBeDisabled(); }); }); @@ -172,7 +173,10 @@ describe('LoginPage', () => { render( diff --git a/frontend/src/tests/pages/RegisterPage.test.tsx b/frontend/src/tests/pages/RegisterPage.test.tsx index 1cc7961..59b614b 100644 --- a/frontend/src/tests/pages/RegisterPage.test.tsx +++ b/frontend/src/tests/pages/RegisterPage.test.tsx @@ -19,7 +19,6 @@ const mockClearAuth = vi.fn<() => void>(); const mockAuthContextValue = { user: null, - token: null, isAuthenticated: false, setAuth: mockSetAuth, clearAuth: mockClearAuth, @@ -128,7 +127,9 @@ describe('RegisterPage', () => { }); it('displays generic error on network failure', async () => { - vi.mocked(authService.register).mockRejectedValueOnce(new Error('Network Error')); + vi.mocked(authService.register).mockRejectedValueOnce( + new Error('Network Error') + ); renderRegisterPage(); fireEvent.click(screen.getByRole('button', { name: 'Register' })); @@ -149,7 +150,9 @@ describe('RegisterPage', () => { fireEvent.click(screen.getByRole('button', { name: 'Register' })); await waitFor(() => { - expect(screen.getByRole('button', { name: 'Register...' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Register...' }) + ).toBeDisabled(); }); }); }); \ No newline at end of file diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 8d356e8..57c2cc0 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -1,54 +1,51 @@ export interface PatientProfile { - patient_id: number; - name: string; - phone: string; - bpjs_number: string | null; - birth_place: string | null; - birth_date: string | null; - gender: string | null; - } - - export type UserRole = 'patient' | 'doctor' | 'nurse' | 'admin'; - - export interface AuthUser { - user_id: number; - email: string; - role: UserRole; - status: string; - profile: PatientProfile; - } - - export interface AuthState { - user: AuthUser | null; - token: string | null; - isAuthenticated: boolean; - } - - export interface RegisterPayload { - name: string; - email: string; - password: string; - password_confirmation: string; - // phone: string; - } - - export interface LoginPayload { - email: string; - password: string; - } - - export interface LoginResponseData { - token: string; - token_type: string; - user: AuthUser; - } - - export interface ApiSuccessResponse { - message: string; - data: T; - } - - export interface ApiValidationError { - message: string; - errors: Record; - } \ No newline at end of file + patient_id: number; + name: string; + phone: string; + bpjs_number: string | null; + birth_place: string | null; + birth_date: string | null; + gender: string | null; +} + +export type UserRole = 'patient' | 'doctor' | 'nurse' | 'admin'; + +export interface AuthUser { + user_id: number; + email: string; + role: UserRole; + status: string; + profile: PatientProfile; +} + +export interface AuthState { + user: AuthUser | null; + isAuthenticated: boolean; +} + +export interface RegisterPayload { + name: string; + email: string; + password: string; + password_confirmation: string; + phone: string; +} + +export interface LoginPayload { + email: string; + password: string; +} + +export interface LoginResponseData { + user: AuthUser; +} + +export interface ApiSuccessResponse { + message: string; + data: T; +} + +export interface ApiValidationError { + message: string; + errors: Record; +} \ No newline at end of file From c8b5d0b762e560c48da7bb56b7f2a9affc321f61 Mon Sep 17 00:00:00 2001 From: MyPC Date: Tue, 23 Jun 2026 15:48:33 +0700 Subject: [PATCH 13/16] Migrasi autentikasi dari Bearer Token ke Sanctum Cookie-based --- frontend/src/types/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 57c2cc0..031f2d4 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -28,7 +28,6 @@ export interface RegisterPayload { email: string; password: string; password_confirmation: string; - phone: string; } export interface LoginPayload { From d5190167561b88b7d1e588188dc662b27db4ab20 Mon Sep 17 00:00:00 2001 From: MyPC Date: Tue, 23 Jun 2026 16:11:36 +0700 Subject: [PATCH 14/16] Migrasi autentikasi dari Bearer Token ke Sanctum Cookie-based --- frontend/src/api/axiosInstance.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/api/axiosInstance.ts b/frontend/src/api/axiosInstance.ts index 3587f73..bd70d7b 100644 --- a/frontend/src/api/axiosInstance.ts +++ b/frontend/src/api/axiosInstance.ts @@ -1,5 +1,12 @@ import axios, { AxiosError } from 'axios'; +function getCsrfToken(): string | null { + const match = document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')); + return match ? decodeURIComponent(match.split('=')[1]) : null; +} + const axiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL, headers: { @@ -9,6 +16,14 @@ const axiosInstance = axios.create({ withCredentials: true, }); +axiosInstance.interceptors.request.use((config) => { + const token = getCsrfToken(); + if (token) { + config.headers['X-XSRF-TOKEN'] = token; + } + return config; +}); + axiosInstance.interceptors.response.use( (response) => response, (error: AxiosError) => { From aacc1240f2ee66e9b5c8bf196c0e73dc8c1932dc Mon Sep 17 00:00:00 2001 From: MyPC Date: Tue, 23 Jun 2026 19:58:15 +0700 Subject: [PATCH 15/16] QNH-313 FE - Logout Action --- frontend/src/components/LogoutButton.tsx | 77 +++++++++++ frontend/src/contexts/AuthContext.tsx | 19 +++ frontend/src/pages/DashboardPage.tsx | 0 frontend/src/routes/ProtectedRoute.tsx | 6 +- frontend/src/routes/PublicRoute.tsx | 6 +- frontend/src/services/authService.ts | 6 + frontend/src/tests/pages/LoginPage.test.tsx | 2 + .../src/tests/pages/LogoutButton.test.tsx | 121 ++++++++++++++++++ .../src/tests/pages/RegisterPage.test.tsx | 2 + 9 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/LogoutButton.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/tests/pages/LogoutButton.test.tsx diff --git a/frontend/src/components/LogoutButton.tsx b/frontend/src/components/LogoutButton.tsx new file mode 100644 index 0000000..aadd343 --- /dev/null +++ b/frontend/src/components/LogoutButton.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { AxiosError } from 'axios'; +import { authService } from '../services/authService'; +import { useAuth } from '../hooks/useAuth'; + +export function LogoutButton() { + const { clearAuth } = useAuth(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + async function handleLogout() { + setIsLoading(true); + setError(''); + + try { + await authService.logout(); + clearAuth(); + navigate('/login', { replace: true }); + } catch (err) { + const axiosError = err as AxiosError; + + if (axiosError.response?.status === 401) { + // Session already expired — clear state and redirect anyway + clearAuth(); + navigate('/login', { replace: true }); + } else { + setError('Logout failed. Please try again.'); + setIsLoading(false); + } + } + } + + return ( +
+ {error && ( + + {error} + + )} + +
+ ); +} + +const styles: Record = { + button: { + padding: '8px 16px', + backgroundColor: '#ef4444', + color: '#ffffff', + border: 'none', + borderRadius: '6px', + fontSize: '14px', + fontWeight: 600, + cursor: 'pointer', + }, + buttonDisabled: { + backgroundColor: '#fca5a5', + cursor: 'not-allowed', + }, + error: { + fontSize: '12px', + color: '#ef4444', + display: 'block', + marginBottom: '8px', + }, +}; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 8c7b23e..87f1793 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -2,19 +2,37 @@ import { createContext, useState, useCallback, + useEffect, type ReactNode, } from 'react'; import type { AuthUser, AuthState, LoginResponseData } from '../types/auth'; +import { authService } from '../services/authService'; interface AuthContextValue extends AuthState { setAuth: (data: LoginResponseData) => void; clearAuth: () => void; + isLoading: boolean; } export const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + authService + .getMe() + .then((response) => { + setUser(response.data); + }) + .catch(() => { + setUser(null); + }) + .finally(() => { + setIsLoading(false); + }); + }, []); const setAuth = useCallback((data: LoginResponseData) => { setUser(data.user); @@ -31,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: !!user, setAuth, clearAuth, + isLoading, }} > {children} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx index a9b21d8..af8e2b3 100644 --- a/frontend/src/routes/ProtectedRoute.tsx +++ b/frontend/src/routes/ProtectedRoute.tsx @@ -2,7 +2,11 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; export function ProtectedRoute() { - const { isAuthenticated } = useAuth(); + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return null; + } if (!isAuthenticated) { return ; diff --git a/frontend/src/routes/PublicRoute.tsx b/frontend/src/routes/PublicRoute.tsx index 3faff51..c14c02f 100644 --- a/frontend/src/routes/PublicRoute.tsx +++ b/frontend/src/routes/PublicRoute.tsx @@ -2,7 +2,11 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '../hooks/useAuth'; export function PublicRoute() { - const { isAuthenticated } = useAuth(); + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return null; + } if (isAuthenticated) { return ; diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index b746785..79b888b 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -39,4 +39,10 @@ export const authService = { async logout(): Promise { await axiosInstance.post('/auth/logout'); }, + + async getMe() : Promise> { + const response = + await axiosInstance.get>('/auth/me'); + return response.data; + }, }; \ No newline at end of file diff --git a/frontend/src/tests/pages/LoginPage.test.tsx b/frontend/src/tests/pages/LoginPage.test.tsx index b88f88e..af5f32f 100644 --- a/frontend/src/tests/pages/LoginPage.test.tsx +++ b/frontend/src/tests/pages/LoginPage.test.tsx @@ -20,9 +20,11 @@ const mockClearAuth = vi.fn<() => void>(); const mockAuthContextValue = { user: null, isAuthenticated: false, + isLoading: false, setAuth: mockSetAuth, clearAuth: mockClearAuth, } satisfies AuthState & { + isLoading: boolean; setAuth: (data: LoginResponseData) => void; clearAuth: () => void; }; diff --git a/frontend/src/tests/pages/LogoutButton.test.tsx b/frontend/src/tests/pages/LogoutButton.test.tsx new file mode 100644 index 0000000..9511eb4 --- /dev/null +++ b/frontend/src/tests/pages/LogoutButton.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthContext } from '../../contexts/AuthContext'; +import { LogoutButton } from '../../components/LogoutButton'; +import { authService } from '../../services/authService'; +import type { AuthState, LoginResponseData } from '../../types/auth'; + +vi.mock('../../services/authService'); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +const mockClearAuth = vi.fn<() => void>(); + +const mockAuthContextValue = { + user: null, + isAuthenticated: false, + isLoading: false, + setAuth: vi.fn<(data: LoginResponseData) => void>(), + clearAuth: mockClearAuth, +} satisfies AuthState & { + isLoading: boolean; + setAuth: (data: LoginResponseData) => void; + clearAuth: () => void; +}; + +function renderLogoutButton() { + return render( + + + + + + ); +} + +describe('LogoutButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders logout button', () => { + renderLogoutButton(); + expect( + screen.getByRole('button', { name: 'Logout' }) + ).toBeInTheDocument(); + }); + + it('calls authService.logout, clears auth, and redirects to /login on success', async () => { + vi.mocked(authService.logout).mockResolvedValueOnce(undefined); + + renderLogoutButton(); + fireEvent.click(screen.getByRole('button', { name: 'Logout' })); + + await waitFor(() => { + expect(authService.logout).toHaveBeenCalledOnce(); + expect(mockClearAuth).toHaveBeenCalledOnce(); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + }); + + it('clears auth and redirects on 401 — session already expired', async () => { + const { AxiosError } = await import('axios'); + const error = new AxiosError('Unauthorized'); + error.response = { + status: 401, + data: { message: 'Unauthenticated.' }, + } as never; + + vi.mocked(authService.logout).mockRejectedValueOnce(error); + + renderLogoutButton(); + fireEvent.click(screen.getByRole('button', { name: 'Logout' })); + + await waitFor(() => { + expect(mockClearAuth).toHaveBeenCalledOnce(); + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + }); + + it('shows generic error and does not clear auth on non-401 error', async () => { + const { AxiosError } = await import('axios'); + const error = new AxiosError('Server Error'); + error.response = { + status: 500, + data: { message: 'Server Error.' }, + } as never; + + vi.mocked(authService.logout).mockRejectedValueOnce(error); + + renderLogoutButton(); + fireEvent.click(screen.getByRole('button', { name: 'Logout' })); + + await waitFor(() => { + expect( + screen.getByText('Logout failed. Please try again.') + ).toBeInTheDocument(); + expect(mockClearAuth).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + it('disables button while loading', async () => { + vi.mocked(authService.logout).mockImplementation( + () => new Promise(() => {}) + ); + + renderLogoutButton(); + fireEvent.click(screen.getByRole('button', { name: 'Logout' })); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Logging out...' }) + ).toBeDisabled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/tests/pages/RegisterPage.test.tsx b/frontend/src/tests/pages/RegisterPage.test.tsx index 59b614b..dce6f49 100644 --- a/frontend/src/tests/pages/RegisterPage.test.tsx +++ b/frontend/src/tests/pages/RegisterPage.test.tsx @@ -20,9 +20,11 @@ const mockClearAuth = vi.fn<() => void>(); const mockAuthContextValue = { user: null, isAuthenticated: false, + isLoading: false, setAuth: mockSetAuth, clearAuth: mockClearAuth, } satisfies AuthState & { + isLoading: boolean; setAuth: (data: LoginResponseData) => void; clearAuth: () => void; }; From 487de386671de0933cb743046fa1bd1ffe52317b Mon Sep 17 00:00:00 2001 From: MyPC Date: Tue, 23 Jun 2026 21:11:10 +0700 Subject: [PATCH 16/16] Add Logout Button on Dashboard Page --- frontend/src/App.tsx | 3 ++- frontend/src/pages/DashboardPage.tsx | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39258cb..08f4b04 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { PublicRoute } from './routes/PublicRoute'; import { ProtectedRoute } from './routes/ProtectedRoute'; import RegisterPage from './pages/RegisterPage'; import LoginPage from './pages/LoginPage'; +import DashboardPage from './pages/DashboardPage'; function App() { return ( @@ -20,7 +21,7 @@ function App() { }> Dashboard (placeholder)
} + element={} /> diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index e69de29..99e0209 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,11 @@ +import { LogoutButton } from '../components/LogoutButton'; + +export default function DashboardPage() { + return ( +
+

Dashboard

+ + +
+ ); +} \ No newline at end of file