diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..ae24c68 --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,141 @@ +# .github/workflows/ci.yml +# Backend CI pipeline for PHP 8.4 with MySQL service, Composer caching +# Runs on PRs to develop and manual dispatch for main branch AWS deployments + +name: Backend CI + +on: + # Trigger on pull requests targeting develop branch + pull_request: + branches: + - develop + paths: + - 'backend/**' + - '.github/workflows/ci.yml' + + # Manual trigger for main branch (AWS deployment gate) + # All tests must pass before pushing to AWS + workflow_dispatch: + branches: + - main + +jobs: + backend-tests: + name: Backend Tests + runs-on: ubuntu-latest + + # MySQL service container for integration tests + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: backend_testing + MYSQL_USER: laravel + MYSQL_PASSWORD: laravel_password + MYSQL_ROOT_PASSWORD: root_password + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + # Redis service for cache/queue/session + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + defaults: + run: + working-directory: ./backend + + steps: + # Checkout the repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Setup PHP 8.4 with required extensions + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, pdo, pdo_mysql, xml, ctype, json, tokenizer, redis + coverage: xdebug + + # Wait for MySQL to be ready + - name: Wait for MySQL + run: | + for i in {1..30}; do + if mysqladmin ping -h 127.0.0.1 -u laravel -plaravel_password --silent; then + break + fi + echo "Waiting for MySQL..." + sleep 1 + done + + # Cache Composer dependencies for faster builds + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: | + ~/.composer/cache/files + ~/.composer/cache/repo + key: ${{ runner.os }}-composer-${{ hashFiles('backend/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + # Install Composer dependencies + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + # Generate application key + - name: Generate passport keys + run: php artisan passport:keys + + # Run database migrations for testing + - name: Run migrations + env: + DB_HOST: 127.0.0.1 + DB_DATABASE: backend_testing + DB_USERNAME: laravel + DB_PASSWORD: laravel_password + REDIS_HOST: 127.0.0.1 + run: php artisan migrate --force --env=testing + + # Run PHP CS Fixer in dry-run mode to check code style + - name: Check code style + run: | + if [ -f vendor/bin/php-cs-fixer ]; then + vendor/bin/php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.dist.php || true + fi + + # Run PHPUnit tests + # All tests must pass - failure blocks merge / deployment + - name: Run PHPUnit tests + env: + DB_HOST: 127.0.0.1 + DB_DATABASE: backend_testing + DB_USERNAME: laravel + DB_PASSWORD: laravel_password + REDIS_HOST: 127.0.0.1 + run: | + if [ -f vendor/bin/phpunit ]; then + vendor/bin/phpunit --testdox --colors=never + else + echo "phpunit not found - skipping tests" + exit 1 + fi + + + # Job-level status check + # Explicitly fail the job if any previous step failed + # This ensures merge / deployment is blocked on test failures + outputs: + tests-passed: ${{ job.status == 'success' }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3ab5311..d392405 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ vendor/ # Environment .env.local .env.*.local +.env # Logs storage/logs/ @@ -38,3 +39,4 @@ Thumbs.db bootstrap/cache/ storage/framework/cache/ +/.php-cs-fixer.cache diff --git a/backend/.editorconfig b/backend/.editorconfig index 6df8428..0a2498d 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -15,4 +15,4 @@ trim_trailing_whitespace = false indent_size = 2 [{compose,docker-compose}.{yml,yaml}] -indent_size = 4 +indent_size = 4 \ No newline at end of file diff --git a/backend/.env.docker b/backend/.env.docker index 11ce845..91df333 100644 --- a/backend/.env.docker +++ b/backend/.env.docker @@ -2,7 +2,9 @@ APP_NAME="Bank Online System" APP_ENV=local APP_KEY=base64:GApRAIeV6cXRlYg61WKVW9sz/WemoGBppXgyhgUt4DA= APP_DEBUG=true -APP_URL=http://localhost:8000 +APP_URL=http://127.0.0.1:8000 + +APP_FRONTEND_URL=http://localhost:5173 APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/backend/.env.example b/backend/.env.example index d09634a..a4e153b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,8 +1,10 @@ APP_NAME="Bank Online System" APP_ENV=local -APP_KEY=base64:GApRAIeV6cXRlYg61WKVW9sz/WemoGBppXgyhgUt4DA= +APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=http://127.0.0.1:8000 + +APP_FRONTEND_URL=http://localhost:5173 APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/backend/.env.testing b/backend/.env.testing new file mode 100644 index 0000000..847693a --- /dev/null +++ b/backend/.env.testing @@ -0,0 +1,73 @@ +APP_NAME="Bank Online System" +APP_ENV=local +APP_KEY=base64:8l4We7vCbeN9iLHUXIQ4UjZhUtAYTCeyN37gjCqoQsc= +APP_DEBUG=true +APP_URL=http://localhost:8000 + +PASSPORT_CLIENT_ID=bank-client-id +PASSPORT_CLIENT_SECRET=bank-client-secret +PASSPORT_REDIRECT_URI=http://localhost:5173/auth/callback +PASSPORT_AUTHORIZE_URL=http://localhost:5173/oauth/authorize +PASSPORT_TOKEN_URL=http://localhost:8000/oauth/token +PASSPORT_USER_ENDPOINT=http://localhost:8000/api/user + +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=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=backend_testing +DB_USERNAME=laravel +DB_PASSWORD=laravel_password + +SESSION_DRIVER=cookie +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=localhost +SANCTUM_STATEFUL_DOMAINS=localhost:5174 + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_DRIVER=redis +CACHE_STORE=redis + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=predis +REDIS_HOST=redis +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/.gitignore b/backend/.gitignore index 83d894f..43fab78 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -5,6 +5,7 @@ .env.production .phpactor.json .phpunit.result.cache +.php-cs-fixer.cache /.codex /.cursor/ /.idea diff --git a/backend/.php-cs-fixer.dist.php b/backend/.php-cs-fixer.dist.php new file mode 100644 index 0000000..7af594d --- /dev/null +++ b/backend/.php-cs-fixer.dist.php @@ -0,0 +1,62 @@ +in([ + __DIR__ . '/app', + __DIR__ . '/config', + __DIR__ . '/database', + __DIR__ . '/routes', + __DIR__ . '/tests', + ]) + ->exclude([ + 'bootstrap', + 'storage', + 'vendor', + ]) + ->name('*.php') + ->notName('*.blade.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + '@Symfony' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => [ + 'elements' => ['arrays', 'arguments', 'parameters'], + ], + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => [ + 'default' => 'align_single_space_minimal', + ], + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'method' => 'one', + ], + ], + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'single_trait_insert_per_statement' => true, + 'declare_strict_types' => false, + 'blank_line_after_opening_tag' => false, + 'strict_comparison' => true, + 'cast_spaces' => true, + 'concat_space' => ['spacing' => 'one'], + 'types_spaces' => true, + 'modernize_strpos' => true, + ]) + ->setFinder($finder) + ->setRiskyAllowed(true) + ->setUsingCache(true); diff --git a/backend/Dockerfile b/backend/Dockerfile index 437dd71..73c7cf0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,20 +8,9 @@ RUN docker-php-ext-install pdo pdo_mysql RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer -COPY ./docker/nginx.conf /etc/nginx/http.d/default.conf -COPY ./docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf -COPY ./docker/supervisord.conf /etc/supervisord.conf - -RUN mkdir -p storage/logs bootstrap/cache && chmod -R 775 storage bootstrap/cache - -#COPY ./docker/entrypoint.sh /entrypoint.sh -#RUN chmod +x /entrypoint.sh - COPY docker/entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/entrypoint.sh EXPOSE 80 -#ENTRYPOINT ["/entrypoint.sh"] - ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/backend/app/Console/Commands/Generator/ServiceCommand.php b/backend/app/Console/Commands/Generator/ServiceCommand.php index 2708d6d..aaf4936 100644 --- a/backend/app/Console/Commands/Generator/ServiceCommand.php +++ b/backend/app/Console/Commands/Generator/ServiceCommand.php @@ -49,7 +49,7 @@ protected function makeDirectory(string $path): void { $directory = dirname($path); - if (!$this->filesystem->isDirectory($directory)) { + if (! $this->filesystem->isDirectory($directory)) { $this->filesystem->makeDirectory($directory, 0755, true, true); } } @@ -57,7 +57,7 @@ protected function makeDirectory(string $path): void protected function buildClass(string $name): string { $namespace = 'App\\Services'; - $extends = 'Service'; + $extends = 'Service'; $className = class_basename($name); @@ -71,7 +71,7 @@ protected function buildClass(string $name): string " ['4'], + self::MASTERCARD => ['51', '52', '53', '54', '55'], + }; + } + + public function label(): string + { + return match ($this) { + self::VISA => 'Visa', + self::MASTERCARD => 'Mastercard', + }; + } + + public function cvvLength(): int + { + return 3; + } +} diff --git a/backend/app/Enums/Card/CardType.php b/backend/app/Enums/Card/CardType.php new file mode 100644 index 0000000..624ea81 --- /dev/null +++ b/backend/app/Enums/Card/CardType.php @@ -0,0 +1,21 @@ + 'Karta debetowa', + self::CREDIT => 'Karta kredytowa', + self::PREPAID => 'Karta przedpłacona', + self::VIRTUAL => 'Karta wirtualna', + }; + } +} diff --git a/backend/app/Exceptions/Handler.php b/backend/app/Exceptions/Handler.php index 28da11c..2e94d1c 100644 --- a/backend/app/Exceptions/Handler.php +++ b/backend/app/Exceptions/Handler.php @@ -2,31 +2,29 @@ namespace App\Exceptions; -use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Http\Exceptions\ThrottleRequestsException; +use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Laravel\Passport\Exceptions\MissingScopeException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; -use Throwable; class Handler extends ExceptionHandler { public function register(): void { - $this->reportable(function (Throwable $e) { - // + $this->reportable(function (\Throwable $e) { }); } - public function render($request, Throwable $e): Response + public function render($request, \Throwable $e): Response { if ($request->is('api*')) { return $this->handleApiException($e); @@ -35,91 +33,98 @@ public function render($request, Throwable $e): Response return parent::render($request, $e); } - private function handleApiException(Throwable $e): JsonResponse + private function handleApiException(\Throwable $e): JsonResponse { - // MissingScopeException (Passport) if ($e instanceof MissingScopeException) { return response()->json([ - 'status' => 'error', - 'message' => 'Brak wymaganych uprawnień.', - 'code' => 403 + 'status' => 'error', + 'message' => 'Insufficient permissions to access this resource.', + 'code' => 403, + ], 403); + } + + if ($e instanceof HttpException) { + return response()->json([ + 'status' => 'error', + 'message' => $e->getMessage(), + 'code' => 403, ], 403); } - // AuthenticationException (brak autoryzacji) if ($e instanceof AuthenticationException || $e instanceof UnauthorizedHttpException) { return response()->json([ - 'status' => 'error', - 'message' => 'Brak autoryzacji.', - 'code' => 401 + 'status' => 'error', + 'message' => 'Unauthenticated. Please provide valid credentials.', + 'code' => 401, ], 401); } - // AuthorizationException (brak dostępu) if ($e instanceof AuthorizationException || $e instanceof AccessDeniedHttpException) { return response()->json([ - 'status' => 'error', - 'message' => 'Brak uprawnień do tego zasobu.', - 'code' => 403 + 'status' => 'error', + 'message' => 'No permissions for this resource.', + 'code' => 403, ], 403); } - // ValidationException (błędy walidacji) if ($e instanceof ValidationException) { + $errors = $e->errors(); + $count = collect($errors)->flatten()->count(); + $firstMessage = collect($errors)->flatten()->first(); + + $message = $count > 1 + ? $firstMessage . ' (and ' . ($count - 1) . ' more error' . ($count > 2 ? 's' : '') . ')' + : $firstMessage; + return response()->json([ - 'status' => 'error', - 'message' => 'Błąd walidacji danych.', - 'errors' => $e->errors(), - 'code' => 422 + 'status' => 'error', + 'message' => $message, + 'errors' => $errors, + 'code' => 422, ], 422); } - // ModelNotFoundException (model nie znaleziony) if ($e instanceof ModelNotFoundException) { return response()->json([ - 'status' => 'error', - 'message' => 'Żądany zasób nie został znaleziony.', - 'code' => 404 + 'status' => 'error', + 'message' => 'Resource not found.', + 'code' => 404, ], 404); } - // NotFoundHttpException (endpoint nie istnieje) if ($e instanceof NotFoundHttpException) { return response()->json([ - 'status' => 'error', - 'message' => 'Endpoint nie istnieje.', - 'code' => 404 + 'status' => 'error', + 'message' => 'Endpoint not found.', + 'code' => 404, ], 404); } - // ThrottleRequestsException (zbyt wiele zapytań) if ($e instanceof ThrottleRequestsException) { return response()->json([ - 'status' => 'error', - 'message' => 'Zbyt wiele żądań. Spróbuj ponownie później.', - 'code' => 429 + 'status' => 'error', + 'message' => 'How many requests you have made. Please try again later.', + 'code' => 429, ], 429); } - // Domyślny błąd $statusCode = method_exists($e, 'getStatusCode') ? $e->getStatusCode() : 500; - // W trybie debug pokaż więcej informacji if (config('app.debug')) { return response()->json([ - 'status' => 'error', + 'status' => 'error', 'message' => $e->getMessage(), - 'code' => $statusCode, - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => collect($e->getTrace())->take(5)->toArray() + 'code' => $statusCode, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => collect($e->getTrace())->take(5)->toArray(), ], $statusCode >= 100 && $statusCode < 600 ? $statusCode : 500); } return response()->json([ - 'status' => 'error', - 'message' => $e->getMessage() ?: 'Wystąpił wewnętrzny błąd serwera.', - 'code' => $statusCode + 'status' => 'error', + 'message' => $e->getMessage() ?: 'Internal Server Error', + 'code' => $statusCode, ], $statusCode >= 100 && $statusCode < 600 ? $statusCode : 500); } } diff --git a/backend/app/Http/Concerns/WithPagination.php b/backend/app/Http/Concerns/WithPagination.php new file mode 100644 index 0000000..aafa968 --- /dev/null +++ b/backend/app/Http/Concerns/WithPagination.php @@ -0,0 +1,69 @@ +integer('per_page', 20); + + return max(1, min(100, $perPage)); + } + + protected function currentPage(): int + { + return request()->integer('page', 1); + } + + protected function paginate(EloquentBuilder|QueryBuilder|Relation $query): LengthAwarePaginator + { + return $query->paginate( + perPage: $this->perPage(), + page: $this->currentPage(), + ); + } + + /** + * @return LengthAwarePaginator + */ + protected function simplePaginate(EloquentBuilder|QueryBuilder|Relation $query): Paginator + { + return $query->simplePaginate( + perPage: $this->perPage(), + page: $this->currentPage(), + ); + } + + protected function paginationParams(): array + { + return [ + 'per_page' => $this->perPage(), + 'page' => $this->currentPage(), + ]; + } + + public static function paginationQueryParams(): array + { + return [ + 'per_page' => [ + 'type' => 'integer', + 'description' => 'Ilość elementów na stronę (1-100).', + 'default' => 20, + 'example' => 10, + ], + 'page' => [ + 'type' => 'integer', + 'description' => 'Numer strony.', + 'default' => 1, + 'example' => 2, + ], + ]; + } +} diff --git a/backend/app/Http/Controllers/Account/AccountController.php b/backend/app/Http/Controllers/Account/AccountController.php new file mode 100644 index 0000000..5b19a60 --- /dev/null +++ b/backend/app/Http/Controllers/Account/AccountController.php @@ -0,0 +1,130 @@ +id()) + ->with('cards') + ->paginate( + perPage: $this->perPage(), + page: $this->currentPage(), + ); + + return AccountResource::collection($accounts); + } + + /** + * Saldo. + * + * Wyświetlanie salda według kont w którym jest waluta. + */ + #[AuthorizeToken(['accounts-view'], anyScope: true)] + public function balance(): JsonResponse + { + $balances = auth()->user()->accounts() + ->select('currency') + ->selectRaw('SUM(balance) as total_balance') + ->groupBy('currency') + ->get(); + + return response()->json([ + 'balances' => BalanceResource::collection($balances), + /* @example 2500.00 */ + 'total' => number_format($balances->where('currency', 'PLN')->sum('total_balance') / 100, 2, '.', ''), + ]); + } + + /** + * Tworzy nowe konto bankowe dla użytkownika. + */ + #[AuthorizeToken(['accounts-manage'], anyScope: true)] + #[BodyParameter('name', description: 'Nazwa konta', type: 'string', example: 'Konto oszczędnościowe')] + #[BodyParameter('currency', description: 'Waluta konta', type: 'string', example: 'PLN')] + #[BodyParameter('type', description: 'Typ konta', type: 'string', example: 'current')] + #[BodyParameter('with_card', description: 'Czy utworzyć kartę płatniczą', type: 'boolean', example: true)] + public function store(StoreAccountRequest $request): JsonResponse + { + $account = $this->accountService->createAccount( + userId: auth()->id(), + name: $request->name, + currency: $request->currency ?? 'PLN', + type: $request->type ?? 'current', + withCard: $request->with_card ?? true, + ); + + return new AccountResource($account->load('cards')) + ->response() + ->setStatusCode(201); + } + + /** + * Wyświetla szczegóły konta bankowego, w tym powiązane karty. + */ + #[AuthorizeToken(['accounts-details'], anyScope: true)] + #[PathParameter('account', description: 'Account being viewed', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + public function show(Account $account): AccountResource + { + return new AccountResource($account->load('cards')); + } + + /** + * Aktualizuje nazwę konta bankowego. + */ + #[AuthorizeToken(['accounts-manage'], anyScope: true)] + #[PathParameter('account', description: 'ID konta bankowego', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + #[BodyParameter('name', description: 'Nowa nazwa konta', type: 'string', example: 'Konto oszczędnościowe')] + public function update(UpdateAccountRequest $request, Account $account): AccountResource + { + $account = $this->accountService->updateName($account, $request->name); + + return new AccountResource($account->load('cards')); + } + + /** + * Usuwa konto bankowe, zamykając je. + */ + #[AuthorizeToken(['accounts-manage'], anyScope: true)] + #[PathParameter('account', description: 'ID konta bankowego', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + public function destroy(Account $account): JsonResponse + { + $this->accountService->closeAccount($account); + + return response()->json(['message' => 'Account closed.'], 200); + } +} diff --git a/backend/app/Http/Controllers/Account/Card/CardController.php b/backend/app/Http/Controllers/Account/Card/CardController.php new file mode 100644 index 0000000..bb195ce --- /dev/null +++ b/backend/app/Http/Controllers/Account/Card/CardController.php @@ -0,0 +1,133 @@ +user_id !== auth()->id()) { + throw new AccessDeniedHttpException('Account not found or access denied.'); + } + + $cards = $account->cards()->paginate( + perPage: $this->perPage(), + page: $this->currentPage(), + ); + + return CardResource::collection($cards); + } + + /** + * Tworzy nową kartę płatniczą dla konta. + */ + #[AuthorizeToken(['cards-manage'], anyScope: true)] + #[PathParameter('account', description: 'ID konta', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + public function store(Account $account): JsonResponse + { + if ($account->user_id !== auth()->id()) { + throw new AccessDeniedHttpException('Account not found or access denied.'); + } + + $card = $this->cardService->createCard($account->id); + + return new CardResource($card) + ->response() + ->setStatusCode(201); + } + + /** + * Wyświetla szczegóły karty płatniczej. + */ + #[AuthorizeToken(['cards-details'], anyScope: true)] + #[PathParameter('card', description: 'ID karty', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + public function show(Card $card): CardResource + { + if ($card->account->user_id !== auth()->id()) { + throw new AccessDeniedHttpException('Card not found or access denied.'); + } + + return new CardResource($card); + } + + /** + * Blokuje kartę płatniczą. + */ + #[AuthorizeToken(['cards-manage'], anyScope: true)] + #[PathParameter('card', description: 'ID karty', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + public function block(Card $card): CardResource + { + if ($card->account->user_id !== auth()->id()) { + throw new AccessDeniedHttpException('Card not found or access denied.'); + } + + $card = $this->cardService->blockCard($card); + + return new CardResource($card); + } + + /** + * Odblokowuje kartę płatniczą. + */ + #[AuthorizeToken(['cards-manage'], anyScope: true)] + #[PathParameter('card', description: 'ID karty', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + public function unblock(Card $card): CardResource + { + if ($card->account->user_id !== auth()->id()) { + throw new AccessDeniedHttpException('Card not found or access denied.'); + } + + $card = $this->cardService->unblockCard($card); + + return new CardResource($card); + } + + /** + * Odblokowuje kartę płatniczą. + */ + #[AuthorizeToken(['cards-manage'], anyScope: true)] + #[PathParameter('card', description: 'ID karty', type: 'string', format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000')] + public function destroy(Card $card) + { + if ($card->account->user_id !== auth()->id()) { + throw new AccessDeniedHttpException('Card not found or access denied.'); + } + + $this->cardService->deleteCard($card); + + return response()->json(null, 200); + } +} diff --git a/backend/app/Http/Controllers/Account/Flik/FlikCodeController.php b/backend/app/Http/Controllers/Account/Flik/FlikCodeController.php new file mode 100644 index 0000000..6522413 --- /dev/null +++ b/backend/app/Http/Controllers/Account/Flik/FlikCodeController.php @@ -0,0 +1,14 @@ +validate([ - 'email' => $request->user()->email, + 'email' => $request->user()->email, 'password' => $request->password, ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); + throw ValidationException::withMessages(['password' => __('auth.password')]); } $request->session()->put('auth.password_confirmed_at', time()); diff --git a/backend/app/Http/Controllers/Auth/NewPasswordController.php b/backend/app/Http/Controllers/Auth/NewPasswordController.php index fc16e14..beee828 100644 --- a/backend/app/Http/Controllers/Auth/NewPasswordController.php +++ b/backend/app/Http/Controllers/Auth/NewPasswordController.php @@ -32,8 +32,8 @@ public function create(Request $request): View public function store(Request $request): RedirectResponse { $request->validate([ - 'token' => ['required'], - 'email' => ['required', 'email'], + 'token' => ['required'], + 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); @@ -44,18 +44,18 @@ public function store(Request $request): RedirectResponse $request->only('email', 'password', 'password_confirmation', 'token'), function (User $user) use ($request) { $user->forceFill([ - 'password' => Hash::make($request->password), + 'password' => Hash::make($request->password), 'remember_token' => Str::random(60), ])->save(); event(new PasswordReset($user)); - } + }, ); // If the password was successfully reset, we will redirect the user back to // the application's home authenticated view. If there is an error we can // redirect them back to where they came from with their error message. - return $status == Password::PASSWORD_RESET + return Password::PASSWORD_RESET === $status ? redirect()->route('login')->with('status', __($status)) : back()->withInput($request->only('email')) ->withErrors(['email' => __($status)]); diff --git a/backend/app/Http/Controllers/Auth/PasswordController.php b/backend/app/Http/Controllers/Auth/PasswordController.php index 6916409..4d0db32 100644 --- a/backend/app/Http/Controllers/Auth/PasswordController.php +++ b/backend/app/Http/Controllers/Auth/PasswordController.php @@ -17,7 +17,7 @@ public function update(Request $request): RedirectResponse { $validated = $request->validateWithBag('updatePassword', [ 'current_password' => ['required', 'current_password'], - 'password' => ['required', Password::defaults(), 'confirmed'], + 'password' => ['required', Password::defaults(), 'confirmed'], ]); $request->user()->update([ diff --git a/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php b/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php index 1bc9c11..13a4d08 100644 --- a/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ b/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -34,10 +34,10 @@ public function store(Request $request): RedirectResponse // 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') + $request->only('email'), ); - return $status == Password::RESET_LINK_SENT + return Password::RESET_LINK_SENT === $status ? back()->with('status', __($status)) : back()->withInput($request->only('email')) ->withErrors(['email' => __($status)]); diff --git a/backend/app/Http/Controllers/Auth/RegisteredUserController.php b/backend/app/Http/Controllers/Auth/RegisteredUserController.php index 44a3930..51e2845 100644 --- a/backend/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/backend/app/Http/Controllers/Auth/RegisteredUserController.php @@ -31,14 +31,14 @@ public function create(): View public function store(Request $request): RedirectResponse { $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class], 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, + 'name' => $request->name, + 'email' => $request->email, 'password' => Hash::make($request->password), ]); diff --git a/backend/app/Http/Controllers/Auth/VerifyEmailController.php b/backend/app/Http/Controllers/Auth/VerifyEmailController.php index 784765e..aed2efd 100644 --- a/backend/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/backend/app/Http/Controllers/Auth/VerifyEmailController.php @@ -15,13 +15,13 @@ class VerifyEmailController extends Controller public function __invoke(EmailVerificationRequest $request): RedirectResponse { if ($request->user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + return redirect()->intended(route('dashboard', absolute: false) . '?verified=1'); } if ($request->user()->markEmailAsVerified()) { event(new Verified($request->user())); } - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + return redirect()->intended(route('dashboard', absolute: false) . '?verified=1'); } } diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php index 8677cd5..3aa2580 100644 --- a/backend/app/Http/Controllers/Controller.php +++ b/backend/app/Http/Controllers/Controller.php @@ -4,5 +4,4 @@ abstract class Controller { - // } diff --git a/backend/app/Http/Controllers/Notification/NotificationController.php b/backend/app/Http/Controllers/Notification/NotificationController.php new file mode 100644 index 0000000..990c0cf --- /dev/null +++ b/backend/app/Http/Controllers/Notification/NotificationController.php @@ -0,0 +1,77 @@ +user() + ->notifications() + ->latest() + ->paginate( + perPage: $this->perPage(), + page: $this->currentPage(), + ); + + return NotificationResource::collection( + $notifications, + ); + } + + /** + * Nie przeczytane powiadomienia. + */ + public function unreadCount(Request $request): JsonResponse + { + return response()->json([ + 'count' => $request->user()->unreadNotifications()->count(), + ]); + } + + /** + * Odczytwanie powiadomienia. + */ + #[PathParameter('notificationId', description: 'Identyfikator powiadomienia', type: 'int', example: '123e4567-e89b-12d3-a456-426614174000')] + public function markAsRead(string $notificationId, Request $request): JsonResponse + { + $notification = $request->user() + ->notifications() + ->where('id', $notificationId) + ->firstOrFail(); + + $notification->markAsRead(); + + return response()->json(['message' => 'Notification marked as read.']); + } + + /** + * Odczytać wszystkie powiadomienia. + */ + public function markAllAsRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['message' => 'All notifications marked as read.']); + } +} diff --git a/backend/app/Http/Controllers/User/ProfileController.php b/backend/app/Http/Controllers/User/ProfileController.php index 5736efe..cec1985 100644 --- a/backend/app/Http/Controllers/User/ProfileController.php +++ b/backend/app/Http/Controllers/User/ProfileController.php @@ -3,32 +3,35 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Http\Requests\User\RegisterUserRequest; use App\Http\Requests\User\UpdateProfileRequest; use App\Models\User; -use Illuminate\Auth\Events\Registered; +use App\Services\Guardian\GuardianService; +use Dedoc\Scramble\Attributes\BodyParameter; +use Dedoc\Scramble\Attributes\PathParameter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; -use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationException; use Laravel\Passport\Attributes\AuthorizeToken; -use Illuminate\Validation\Rules; /** * Zarządzanie profilem użytkownika. * - * @tags Profile + * @tags Użytkownik */ -#[AuthorizeToken(['user-profile'], anyScope: true)] class ProfileController extends Controller { + public function __construct( + private readonly GuardianService $guardianService, + ) { + } + /** - * Informacja o profilu użytkownika. - * - * Zwraca dane aktualnego zalogowanego użytkownika. - * - * @unauthenticated - */ + * Informacja o profilu użytkownika. + * + * Zwraca dane aktualnego zalogowanego użytkownika. + */ + #[AuthorizeToken(['user-profile'], anyScope: true)] public function show(Request $request): JsonResponse { return response()->json($request->user()->toArray()); @@ -39,9 +42,40 @@ public function show(Request $request): JsonResponse * * Aktualizuje dane profilowe użytkownika. */ + #[AuthorizeToken(['user-profile-manage'], anyScope: true)] + #[BodyParameter('name', description: 'Nowa nazwa użytkownika', example: 'Jan Kowalski')] + #[BodyParameter('email', description: 'Nowy adres email', example: 'jan.kowalski@example.com')] + #[BodyParameter('guardian_email', description: 'E-mail opiekuna, który powinien zatwierdzić że należysz do niego.', example: 'opiekun@example.com')] + #[BodyParameter('guardian_delete', description: 'Usuwanie e-mail opiekuna, jeśli dziecko jest pełnoletnie.', example: false)] + #[BodyParameter('date_of_birth', description: 'Data urodzenia użytkownika', example: '2000-01-01')] + #[BodyParameter('password', description: 'Nowe hasło (min. 8 znaków, max. 255 znaków, musi być potwierdzone)', example: 'P@ssw0rd123!@')] + #[BodyParameter('password_confirmation', description: 'Potwierdzenie nowego hasła', example: 'P@ssw0rd123!@')] + #[BodyParameter('old_password', description: 'Aktualne hasło (wymagane przy zmianie hasła)', example: 'NewP@ssw0rd123!@')] public function update(UpdateProfileRequest $request): JsonResponse { - $request->user()->update($request->validated()); + $validated = $request->validated(); + $user = $request->user(); + + if (isset($validated['guardian_email']) && ! $user->guardian_id) { + $guardian = User::query()->where('email', $validated['guardian_email'])->first(); + + $validated['guardian_id'] = $guardian->id; + + $this->guardianService->sendGuardianRequest($user, $guardian); + + unset($validated['guardian_email']); + } + + if (isset($validated['guardian_delete']) && $user->guardian_id && $user->isAdult()) { + $validated['guardian_id'] = null; + $validated['guardian_approved_at'] = null; + } + + if (isset($validated['date_of_birth']) && $user->date_of_birth) { + throw ValidationException::withMessages(['date_of_birth' => ['Date of birth cannot be changed after it has been set.']]); + } + + $user->update($validated); return response()->json([ 'message' => 'Profile updated successfully', @@ -50,10 +84,11 @@ public function update(UpdateProfileRequest $request): JsonResponse } /** - * Usuń konto. - * - * Usuwa konto zalogowanego użytkownika - na zawsze! - */ + * Usuń konto. + * + * Usuwa konto zalogowanego użytkownika - na zawsze! + */ + #[AuthorizeToken(['user-profile-manage'], anyScope: true)] public function destroy(Request $request): JsonResponse { $request->user()->delete(); @@ -77,36 +112,94 @@ public function revokeToken(Request $request): JsonResponse * Rejestracja użytkownika w systemie. * * Tworzy nowe konto użytkownika. + * + * @unauthenticated */ - public function create(Request $request): User + public function create(RegisterUserRequest $request): User { - $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], - 'password' => [ - 'required', - 'string', - 'confirmed', - Password::min(8) - ->max(255) - ->letters() - ->mixedCase() - ->numbers() - ->symbols() - ->uncompromised(), - ], - ]); + $validated = $request->validated(); + + unset($validated['password_confirmation']); + + // Email verified, because it doesn't work locally :) + $validated['email_verified_at'] = now(); + + return User::create($validated); + } + + /** + * Zatwierdzanie opiekuństwa, które dziecko wysłało. + * + * Zatwierdzenie opiekuństwa nad kontem dziecka. + */ + #[PathParameter('wardId', description: 'Identyfikator użytkownika podopiecznego.', type: 'int', example: '123e4567-e89b-12d3-a456-426614174000')] + public function approveGuardian(string $wardId): JsonResponse + { + $guardian = auth()->user(); + $ward = User::query()->where('id', $wardId)->first(); + + if (! $ward) { + return response()->json([ + 'message' => 'User not found.', + ], 404); + } - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), + if (! $guardian->isAdult()) { + throw ValidationException::withMessages(['guardian' => ['Guardian must be at least 18 years old.']]); + } + + if (isset($ward->guardian_approved_at) && $ward->guardian_approved_at) { + return response()->json([ + 'message' => 'Guardian is approved.', + ], 422); + } + + if ($guardian->id === $ward->id) { + return response()->json([ + 'message' => 'You cannot approve yourself as your own guardian.', + ], 422); + } + + $this->guardianService->approveGuardian($guardian, $ward); + + return response()->json([ + 'message' => 'Guardian approval confirmed.', ]); + } + + /** + * Odrzucenie opiekuństwa, które dziecko wysłało. + * + * Odrzucenie opiekuństwa nad kontem dziecka. + */ + #[PathParameter('wardId', description: 'Identyfikator użytkownika podopiecznego.', type: 'int', example: '123e4567-e89b-12d3-a456-426614174000')] + public function rejectGuardian(string $wardId): JsonResponse + { + $guardian = auth()->user(); + $ward = User::query()->where('id', $wardId)->first(); + + if (! $ward) { + return response()->json([ + 'message' => 'User not found.', + ], 404); + } + + if (isset($ward->guardian_approved_at) && $ward->guardian_approved_at) { + return response()->json([ + 'message' => 'Guardian is approved.', + ], 422); + } - event(new Registered($user)); + if ($guardian->id === $ward->id) { + return response()->json([ + 'message' => 'You cannot reject yourself as your own guardian.', + ], 422); + } - Auth::login($user); + $this->guardianService->rejectGuardian($guardian, $ward); - return $user; + return response()->json([ + 'message' => 'Guardian request rejected.', + ]); } } diff --git a/backend/app/Http/Middleware/Adult/AdultCheckMiddleware.php b/backend/app/Http/Middleware/Adult/AdultCheckMiddleware.php new file mode 100644 index 0000000..6ff4799 --- /dev/null +++ b/backend/app/Http/Middleware/Adult/AdultCheckMiddleware.php @@ -0,0 +1,34 @@ +user()->toArray(); + + $isAdult = $user['is_adult'] ?? false; + $hasGuardian = isset($user['guardian']['id']) && $user['guardian']['id'] && $user['guardian']['guardian_approved_at']; + $hasDateOfBirth = isset($user['date_of_birth']) && $user['date_of_birth']; + + if (! $hasDateOfBirth) { + throw new AccessDeniedHttpException('Access denied. You must set date of birth.'); + } + + if (! $isAdult && ! $hasGuardian) { + throw new AccessDeniedHttpException('Access denied. You must be 18 or older, or have a legal guardian assigned to your account.'); + } + + return $next($request); + } +} diff --git a/backend/app/Http/Middleware/ForceJsonResponse.php b/backend/app/Http/Middleware/ForceJsonResponse.php index 35ea080..b24679b 100644 --- a/backend/app/Http/Middleware/ForceJsonResponse.php +++ b/backend/app/Http/Middleware/ForceJsonResponse.php @@ -2,7 +2,6 @@ namespace App\Http\Middleware; -use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -11,9 +10,9 @@ class ForceJsonResponse /** * Handle an incoming request. * - * @param Closure(Request): (Response) $next + * @param \Closure(Request): (Response) $next */ - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, \Closure $next): Response { $request->headers->set('Accept', 'application/json'); diff --git a/backend/app/Http/Requests/Account/StoreAccountRequest.php b/backend/app/Http/Requests/Account/StoreAccountRequest.php new file mode 100644 index 0000000..0576550 --- /dev/null +++ b/backend/app/Http/Requests/Account/StoreAccountRequest.php @@ -0,0 +1,51 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'currency' => ['sometimes', 'string', 'in:PLN'], + 'type' => ['sometimes', 'string', 'in:current,savings'], + 'with_card' => ['sometimes', 'boolean'], + ]; + } + + public function queryParameters(): array + { + return [ + 'name' => [ + 'description' => 'Nazwa konta', + 'example' => 'Konto oszczędnościowe', + ], + 'currency' => [ + 'example' => 'PLN', + ], + 'type' => [ + 'example' => 'savings', + ], + 'with_card' => [ + 'example' => true, + ], + ]; + } +} diff --git a/backend/app/Http/Requests/Account/UpdateAccountRequest.php b/backend/app/Http/Requests/Account/UpdateAccountRequest.php new file mode 100644 index 0000000..432bf0b --- /dev/null +++ b/backend/app/Http/Requests/Account/UpdateAccountRequest.php @@ -0,0 +1,39 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } + + public function queryParameters(): array + { + return [ + 'name' => [ + 'description' => 'Nazwa konta', + 'example' => 'Konto oszczędnościowe', + ], + ]; + } +} diff --git a/backend/app/Http/Requests/Auth/LoginRequest.php b/backend/app/Http/Requests/Auth/LoginRequest.php index 711e0a1..0196b8b 100644 --- a/backend/app/Http/Requests/Auth/LoginRequest.php +++ b/backend/app/Http/Requests/Auth/LoginRequest.php @@ -28,7 +28,7 @@ public function authorize(): bool public function rules(): array { return [ - 'email' => ['required', 'string', 'email'], + 'email' => ['required', 'string', 'email'], 'password' => ['required', 'string'], ]; } @@ -45,9 +45,7 @@ public function authenticate(): void if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { RateLimiter::hit($this->throttleKey()); - throw ValidationException::withMessages([ - 'email' => trans('auth.failed'), - ]); + throw ValidationException::withMessages(['email' => trans('auth.failed')]); } RateLimiter::clear($this->throttleKey()); @@ -68,12 +66,7 @@ public function ensureIsNotRateLimited(): void $seconds = RateLimiter::availableIn($this->throttleKey()); - throw ValidationException::withMessages([ - 'email' => trans('auth.throttle', [ - 'seconds' => $seconds, - 'minutes' => ceil($seconds / 60), - ]), - ]); + throw ValidationException::withMessages(['email' => trans('auth.throttle', ['seconds' => $seconds, 'minutes' => ceil($seconds / 60)])]); } /** @@ -81,6 +74,6 @@ public function ensureIsNotRateLimited(): void */ public function throttleKey(): string { - return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip()); } } diff --git a/backend/app/Http/Requests/ProfileUpdateRequest.php b/backend/app/Http/Requests/ProfileUpdateRequest.php index e2202dd..2986fd0 100644 --- a/backend/app/Http/Requests/ProfileUpdateRequest.php +++ b/backend/app/Http/Requests/ProfileUpdateRequest.php @@ -17,7 +17,7 @@ class ProfileUpdateRequest extends FormRequest public function rules(): array { return [ - 'name' => ['required', 'string', 'max:255'], + 'name' => ['required', 'string', 'max:255'], 'email' => [ 'required', 'string', diff --git a/backend/app/Http/Requests/User/RegisterUserRequest.php b/backend/app/Http/Requests/User/RegisterUserRequest.php new file mode 100644 index 0000000..85633e1 --- /dev/null +++ b/backend/app/Http/Requests/User/RegisterUserRequest.php @@ -0,0 +1,59 @@ +|string> + */ + public function rules(): array + { + return [ + /* @example Jan Kowalski */ + 'name' => ['required', 'string', 'max:255'], + /* @example jan.kowalski@example.com */ + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + /* @example "2000-01-01" */ + 'date_of_birth' => ['sometimes', 'date', 'date_format:Y-m-d', 'before_or_equal:today'], + /* @example "P@ssw0rd123!@" */ + 'password' => [ + 'required', + 'string', + 'confirmed', + new Password(8) + ->max(255) + ->letters() + ->mixedCase() + ->numbers() + ->symbols(), + ], + /* @example "P@ssw0rd123!@" */ + 'password_confirmation' => ['sometimes', 'string', 'same:password'], + ]; + } + + public function bodyParameters(): array + { + return [ + 'password_confirmation' => [ + 'description' => 'Must match the password field.', + 'example' => 'P@ssw0rd123!@', + ], + ]; + } +} diff --git a/backend/app/Http/Requests/User/UpdateProfileRequest.php b/backend/app/Http/Requests/User/UpdateProfileRequest.php index b5e86bf..0b90c62 100644 --- a/backend/app/Http/Requests/User/UpdateProfileRequest.php +++ b/backend/app/Http/Requests/User/UpdateProfileRequest.php @@ -2,9 +2,11 @@ namespace App\Http\Requests\User; +use App\Rules\Adult\AdultGuardian; use App\Rules\Password\CurrentPassword; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; class UpdateProfileRequest extends FormRequest @@ -24,9 +26,25 @@ public function authorize(): bool */ public function rules(): array { + $user = $this->user(); + return [ + /* @example Jan Kowalski */ 'name' => ['sometimes', 'string', 'max:255'], - 'email' => ['sometimes', 'email', 'max:255', 'unique:users,email,' . $this->user()->id], + /* @example jan.kowalski@example.com */ + 'email' => ['sometimes', 'email', 'max:255', 'unique:users,email,' . $user->id], + /* @example "2000-01-01" */ + 'date_of_birth' => ['sometimes', 'date', 'date_format:Y-m-d', 'before_or_equal:today'], + /* @example guardian@example.com */ + 'guardian_email' => [ + 'nullable', + 'email', + 'max:255', + Rule::exists('users', 'email'), + Rule::notIn([$user->email]), + new AdultGuardian(), + ], + /* @example "P@ssw0rd123!@" */ 'password' => [ 'sometimes', 'string', @@ -36,32 +54,14 @@ public function rules(): array ->letters() ->mixedCase() ->numbers() - ->symbols() - ->uncompromised(), + ->symbols(), ], + /* @example "P@ssw0rd123!@" */ + 'password_confirmation' => ['sometimes', 'string', 'same:password'], + /* @example "O&l*dP@ssw0rd123!@" */ 'old_password' => ['required_with:password', 'string', 'max:255', new CurrentPassword()], - ]; - } - - public function bodyParameters(): array - { - return [ - 'name' => [ - 'description' => 'Nowa nazwa użytkownika', - 'example' => 'Jan Kowalski', - ], - 'email' => [ - 'description' => 'Nowy adres email', - 'example' => 'jan@example.com', - ], - 'password' => [ - 'description' => 'Nowe hasło (min. 8 znaków, max. 255 znaków, musi być potwierdzone)', - 'example' => 'newpassword123', - ], - 'password_confirmation' => [ - 'description' => 'Potwierdzenie nowego hasła', - 'example' => 'newpassword123', - ] + /* @example false */ + 'guardian_delete' => ['sometimes', 'boolean'], ]; } } diff --git a/backend/app/Http/Resources/Account/AccountResource.php b/backend/app/Http/Resources/Account/AccountResource.php new file mode 100644 index 0000000..38772bd --- /dev/null +++ b/backend/app/Http/Resources/Account/AccountResource.php @@ -0,0 +1,39 @@ + $this->id, + /* @example "Konto oszczędnościowe" */ + 'name' => $this->name, + /* @example "98123456780000000012345678" */ + 'account_number' => $this->account_number, + /* @example "2500.00" */ + 'balance' => number_format($this->balance / 100, 2, '.', ''), + /* @example "PLN" */ + 'currency' => $this->currency, + /* @example "savings" */ + 'type' => $this->type, + 'cards' => CardResource::collection($this->whenLoaded('cards')), + /* @example "2024-06-01T12:00:00.000Z" */ + 'created_at' => $this->created_at?->toISOString(), + /* @example "2024-06-01T12:00:00.000Z" */ + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/Account/BalanceResource.php b/backend/app/Http/Resources/Account/BalanceResource.php new file mode 100644 index 0000000..ca37180 --- /dev/null +++ b/backend/app/Http/Resources/Account/BalanceResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + /* @example PLN */ + 'currency' => $this->currency, + /* @example 2500.00 */ + 'balance' => number_format($this->total_balance / 100, 2, '.', ''), + ]; + } +} diff --git a/backend/app/Http/Resources/Account/Card/CardResource.php b/backend/app/Http/Resources/Account/Card/CardResource.php new file mode 100644 index 0000000..b7bf6cd --- /dev/null +++ b/backend/app/Http/Resources/Account/Card/CardResource.php @@ -0,0 +1,50 @@ + $this->id, + /* @example "**** **** **** 1234" */ + 'card_number' => $this->when( + $this->card_number, + $this->maskCardNumber($this->card_number), + ), + /* @example "1234" */ + 'card_last_four' => $this->when( + $this->card_number, + substr($this->card_number, -4), + ), + /* @example 123 */ + 'cvv' => $this->cvv, + /* @example 12 */ + 'exp_month' => $this->exp_month, + /* @example 2024 */ + 'exp_year' => $this->exp_year, + /* @example "Visa" */ + 'network' => $this->network, + /* @example "credit" */ + 'type' => $this->type, + /* @example "active" */ + 'status' => $this->status, + /* @example "2024-06-01T12:00:00.000Z" */ + 'created_at' => $this->created_at?->toISOString(), + /* @example "2024-06-01T12:00:00.000Z" */ + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/Notification/NotificationResource.php b/backend/app/Http/Resources/Notification/NotificationResource.php new file mode 100644 index 0000000..6c6aeb6 --- /dev/null +++ b/backend/app/Http/Resources/Notification/NotificationResource.php @@ -0,0 +1,29 @@ + $this->id, + /* @example "approved" */ + 'type' => $this->data['type'] ?? $this->type, + /* @example "Powiadomienie" */ + 'title' => $this->data['title'] ?? 'Powiadomienie', + /* @example "Treść powiadomienia" */ + 'body' => $this->data['body'] ?? '', + /* @example {} */ + 'data' => $this->data, + /* @example "2026-05-13T10:30:00.000000Z" */ + 'read_at' => $this->read_at?->toISOString(), + /* @example "2026-05-13T10:30:00.000000Z" */ + 'created_at' => $this->created_at->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Responses/AuthorizationViewResponse.php b/backend/app/Http/Responses/AuthorizationViewResponse.php index dbdaece..025c6cc 100644 --- a/backend/app/Http/Responses/AuthorizationViewResponse.php +++ b/backend/app/Http/Responses/AuthorizationViewResponse.php @@ -12,7 +12,7 @@ class AuthorizationViewResponse implements AuthorizationViewResponseContract, Re public function __construct($view = 'passport::authorize', array $parameters = []) { - $this->view = $view; + $this->view = $view; $this->parameters = $parameters; } diff --git a/backend/app/Models/Account/Account.php b/backend/app/Models/Account/Account.php new file mode 100644 index 0000000..9b4a350 --- /dev/null +++ b/backend/app/Models/Account/Account.php @@ -0,0 +1,83 @@ + + */ + protected function casts(): array + { + return [ + 'balance' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function cards(): HasMany + { + return $this->hasMany(Card::class); + } + + public function sentTransactions(): HasMany + { + return $this->hasMany(Transaction::class, 'from_account_id'); + } + + public function receivedTransactions(): HasMany + { + return $this->hasMany(Transaction::class, 'to_account_id'); + } + + public function transactions() + { + return Transaction::where('from_account_id', $this->id) + ->orWhere('to_account_id', $this->id); + } + + public function getBalanceInZlotyAttribute(): float + { + return $this->balance / 100; + } + + public function getFormattedBalanceAttribute(): string + { + return number_format($this->balance / 100, 2, ',', ' ') . ' ' . $this->currency; + } + + public function deposit(int $amountInCents): void + { + $this->balance += $amountInCents; + $this->save(); + } + + public function withdraw(int $amountInCents): void + { + if ($this->balance < $amountInCents) { + throw new \Exception('Insufficient funds.'); + } + $this->balance -= $amountInCents; + $this->save(); + } +} diff --git a/backend/app/Models/Account/Card/Card.php b/backend/app/Models/Account/Card/Card.php new file mode 100644 index 0000000..6d4b0ec --- /dev/null +++ b/backend/app/Models/Account/Card/Card.php @@ -0,0 +1,49 @@ + + */ + protected function casts(): array + { + return [ + 'exp_month' => 'integer', + 'exp_year' => 'integer', + 'cvv' => 'integer', + ]; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(Transaction::class, 'from_card_id'); + } + + public function flikCodes(): HasMany + { + return $this->hasMany(FlikCode::class); + } +} diff --git a/backend/app/Models/Account/Flik/FlikCode.php b/backend/app/Models/Account/Flik/FlikCode.php new file mode 100644 index 0000000..5e57e71 --- /dev/null +++ b/backend/app/Models/Account/Flik/FlikCode.php @@ -0,0 +1,50 @@ + + */ + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + ]; + } + + public function card(): BelongsTo + { + return $this->belongsTo(Card::class); + } + + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'used_in_transaction_id'); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isActive(): bool + { + return 'active' === $this->status && ! $this->isExpired(); + } +} diff --git a/backend/app/Models/Account/Transaction/Transaction.php b/backend/app/Models/Account/Transaction/Transaction.php new file mode 100644 index 0000000..732399a --- /dev/null +++ b/backend/app/Models/Account/Transaction/Transaction.php @@ -0,0 +1,75 @@ + + */ + protected function casts(): array + { + return [ + 'amount' => 'integer', + ]; + } + + public function isExpenseFor(string $accountId): bool + { + return $this->from_account_id === $accountId; + } + + public function isIncomeFor(string $accountId): bool + { + return $this->to_account_id === $accountId; + } + + public function signedAmountFor(string $accountId): int + { + if ($this->isExpenseFor($accountId)) { + return -$this->amount; + } + + if ($this->isIncomeFor($accountId)) { + return +$this->amount; + } + + return 0; + } + + public function fromAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'from_account_id'); + } + + public function toAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'to_account_id'); + } + + public function card(): BelongsTo + { + return $this->belongsTo(Card::class, 'from_card_id'); + } + + public function flikCode(): HasOne + { + return $this->hasOne(FlikCode::class, 'used_in_transaction_id'); + } +} diff --git a/backend/app/Models/Passport/Client.php b/backend/app/Models/Passport/Client.php index 078698f..8ceacc8 100644 --- a/backend/app/Models/Passport/Client.php +++ b/backend/app/Models/Passport/Client.php @@ -7,13 +7,8 @@ class Client extends BaseClient { - /** - * Określa, czy klient może pominąć ekran autoryzacji. - * Dla własnych aplikacji (frontend) zwracamy 'true'. - */ public function skipsAuthorization(Authenticatable $user, array $scopes = []): bool { - // Dla klientów first-party pomijamy ekran autoryzacji return $this->firstParty(); } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 9b4707e..0e3f399 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -2,23 +2,33 @@ namespace App\Models; - use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Models\Account\Account; +use App\Models\Account\Card\Card; +use App\Models\Account\Flik\FlikCode; +use App\Models\Account\Transaction\Transaction; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Laravel\Passport\HasApiTokens; use Laravel\Passport\Contracts\OAuthenticatable; +use Laravel\Passport\HasApiTokens; - -#[Fillable(['name', 'email', 'password'])] +#[Fillable(['id', 'name', 'email', 'date_of_birth', 'guardian_id', 'guardian_approved_at', 'password'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable implements OAuthenticatable, MustVerifyEmail { /** @use HasFactory */ - use HasFactory, Notifiable, HasApiTokens; + use HasFactory; + use Notifiable; + use HasApiTokens; + use HasUuids; /** * Get the attributes that should be cast. @@ -28,8 +38,96 @@ class User extends Authenticatable implements OAuthenticatable, MustVerifyEmail protected function casts(): array { return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'id' => 'string', + 'name' => 'string', + 'email' => 'string', + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'date_of_birth' => 'datetime:Y-m-d', + 'guardian_approved_at' => 'datetime', ]; } + + public function guardian(): BelongsTo + { + return $this->belongsTo(User::class, 'guardian_id'); + } + + public function wards(): HasMany + { + return $this->hasMany(User::class, 'guardian_id'); + } + + public function isAdult(): bool + { + if (! $this->date_of_birth) { + return false; + } + + return $this->date_of_birth->age >= 18; + } + + public function requiresGuardian(): bool + { + return ! $this->isAdult(); + } + + public function accounts(): HasMany + { + return $this->hasMany(Account::class); + } + + public function cards(): HasManyThrough + { + return $this->hasManyThrough(Card::class, Account::class); + } + + public function transactions() + { + return Transaction::whereHas('fromAccount', function ($q) { + $q->where('user_id', $this->id); + })->orWhereHas('toAccount', function ($q) { + $q->where('user_id', $this->id); + }); + } + + public function flikCodes(): HasManyThrough + { + return $this->hasManyThrough(FlikCode::class, Card::class, 'account_id', 'card_id', 'id', 'id'); + } + + public function sentTransactions() + { + return $this->hasManyThrough(Transaction::class, Account::class, 'user_id', 'from_account_id'); + } + + public function receivedTransactions() + { + return $this->hasManyThrough(Transaction::class, Account::class, 'user_id', 'to_account_id'); + } + + /** + * @property array{id: string, name: string, email: string, guardian_approved_at: string|null}|null $guardian + * @property bool $is_adult + */ + public function toArray(): array + { + $array = parent::toArray(); + + $array['is_adult'] = $this->isAdult(); + + if (isset($array['guardian_id']) && $array['guardian_id']) { + $guardian = $this->guardian()->first(['id', 'name', 'email']); + $array['guardian'] = $guardian?->toArray(); + $array['guardian']['guardian_approved_at'] = $array['guardian_approved_at']; + + unset($array['guardian_id'], $array['guardian_approved_at']); + + return $array; + } + + unset($array['guardian_id']); + + return $array; + } } diff --git a/backend/app/Notifications/Guardian/GuardianApprovedNotification.php b/backend/app/Notifications/Guardian/GuardianApprovedNotification.php new file mode 100644 index 0000000..6b26793 --- /dev/null +++ b/backend/app/Notifications/Guardian/GuardianApprovedNotification.php @@ -0,0 +1,53 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + public function toDatabase(object $notifiable): array + { + return [ + 'type' => 'guardian_approved', + 'title' => 'Opiekun potwierdzony!', + 'body' => "{$this->guardian->name} zatwierdził opiekę nad Twoim kontem.", + 'guardian_id' => $this->guardian->id, + 'guardian_name' => $this->guardian->name, + 'action' => 'view_account', + ]; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + ]; + } +} diff --git a/backend/app/Notifications/Guardian/GuardianRejectedNotification.php b/backend/app/Notifications/Guardian/GuardianRejectedNotification.php new file mode 100644 index 0000000..aa7dbe2 --- /dev/null +++ b/backend/app/Notifications/Guardian/GuardianRejectedNotification.php @@ -0,0 +1,53 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + public function toDatabase(object $notifiable): array + { + return [ + 'type' => 'guardian_rejected', + 'title' => 'Opiekun odrzucił prośbę', + 'body' => "{$this->guardian->name} odrzucił prośbę o opiekę.", + 'guardian_id' => $this->guardian->id, + 'guardian_name' => $this->guardian->name, + 'action' => 'find_new_guardian', + ]; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + ]; + } +} diff --git a/backend/app/Notifications/Guardian/GuardianRequestNotification.php b/backend/app/Notifications/Guardian/GuardianRequestNotification.php new file mode 100644 index 0000000..b89573e --- /dev/null +++ b/backend/app/Notifications/Guardian/GuardianRequestNotification.php @@ -0,0 +1,58 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + public function toDatabase(object $notifiable): array + { + return [ + 'type' => 'guardian_request', + 'title' => 'Nowa prośba o opiekę', + 'body' => "{$this->ward->name} prosi o potwierdzenie opieki.", + 'ward_id' => $this->ward->id, + 'ward_name' => $this->ward->name, + 'ward_email' => $this->ward->email, + 'action' => 'approve_guardian', + 'action_url' => '/guardian/approve/' . $this->ward->id, + 'ward_date_of_birth' => $this->ward->date_of_birth?->format('Y-m-d'), + 'ward_age' => $this->ward->date_of_birth?->age, + 'ward_email' => $this->ward->email, + ]; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + ]; + } +} diff --git a/backend/app/Notifications/Guardian/GuardianRequestSentNotification.php b/backend/app/Notifications/Guardian/GuardianRequestSentNotification.php new file mode 100644 index 0000000..3075ca7 --- /dev/null +++ b/backend/app/Notifications/Guardian/GuardianRequestSentNotification.php @@ -0,0 +1,53 @@ + + */ + public function via(object $notifiable): array + { + return ['database']; + } + + public function toDatabase(object $notifiable): array + { + return [ + 'type' => 'guardian_request_sent', + 'title' => 'Prośba wysłana', + 'body' => "Wysłano prośbę do {$this->guardian->name}. Oczekuje na zatwierdzenie.", + 'guardian_id' => $this->guardian->id, + 'guardian_name' => $this->guardian->name, + 'status' => 'pending', + ]; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + ]; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 465627e..c05bf78 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,22 +2,39 @@ namespace App\Providers; -use App\Http\Responses\AuthorizationViewResponse; +use Dedoc\Scramble\Scramble; +use Dedoc\Scramble\Support\Generator\OpenApi; +use Dedoc\Scramble\Support\Generator\SecurityScheme; +use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow; use Illuminate\Support\ServiceProvider; -use Laravel\Passport\Contracts\AuthorizationViewResponse as AuthorizationViewResponseContract; use Laravel\Passport\Passport; class AppServiceProvider extends ServiceProvider { public function register(): void { - $this->app->bind(AuthorizationViewResponseContract::class, function () { - return new AuthorizationViewResponse('vendor.passport.authorize'); - }); } public function boot(): void { + Passport::authorizationView('vendor.passport.authorize'); + + Scramble::registerApi('docs'); + + Scramble::configure() + ->withDocumentTransformers(function (OpenApi $openApi) { + $openApi->secure( + SecurityScheme::http('bearer', 'JWT'), + ); + + $openApi->secure( + SecurityScheme::oauth2() + ->flow('password', function (OAuthFlow $flow) { + $flow->tokenUrl(config('app.url') . '/oauth/token'); + }), + ); + }); + Passport::tokensExpireIn(now()->addDays(15)); Passport::refreshTokensExpireIn(now()->addDays(30)); @@ -25,66 +42,69 @@ public function boot(): void Passport::tokensCan([ // Podstawowe informacje o użytkowniku - 'user-profile' => 'Odczyt podstawowych danych profilu', + 'user-profile' => 'Odczyt podstawowych danych profilu', + 'user-profile-manage' => 'Edycja podstawowych danych profilu, oraz usuwanie i edycja konta.', // Informacje finansowe - 'balance-view' => 'Odczyt bieżącego salda konta', + 'balance-view' => 'Odczyt bieżącego salda konta', 'balance-history' => 'Odczyt historii salda', // Karty - 'cards-view' => 'Odczyt listy kart', - 'cards-details' => 'Odczyt szczegółów karty (ostatnie 4 cyfry, typ, status)', + 'cards-view' => 'Odczyt listy kart', + 'cards-details' => 'Odczyt szczegółów karty (ostatnie 4 cyfry, typ, status)', 'cards-transactions' => 'Odczyt transakcji kartą', - 'cards-manage' => 'Zarządzanie kartami (blokada, odblokowanie, limity)', + 'cards-manage' => 'Zarządzanie kartami (blokada, odblokowanie, limity)', // Transakcje - 'transactions-view' => 'Odczyt historii transakcji', - 'transactions-details' => 'Odczyt szczegółów transakcji', - 'transactions-transfer' => 'Wykonywanie przelewów', + 'transactions-view' => 'Odczyt historii transakcji', + 'transactions-details' => 'Odczyt szczegółów transakcji', + 'transactions-transfer' => 'Wykonywanie przelewów', 'transactions-scheduled' => 'Odczyt zaplanowanych przelewów', - 'transactions-schedule' => 'Planowanie przelewów', + 'transactions-schedule' => 'Planowanie przelewów', // Konta - 'accounts-view' => 'Odczyt listy kont bankowych', - 'accounts-details' => 'Odczyt szczegółów konta (numer, typ, waluta)', + 'accounts-view' => 'Odczyt listy kont bankowych', + 'accounts-details' => 'Odczyt szczegółów konta (numer, typ, waluta)', 'accounts-statement' => 'Pobieranie wyciągów bankowych', + 'accounts-manage' => 'Zarządzanie kontami (tworzenia, edycja, usuwanie)', // Blik / Płatności mobilne 'blik-generate' => 'Generowanie kodu BLIK', - 'blik-confirm' => 'Potwierdzanie transakcji BLIK', + 'blik-confirm' => 'Potwierdzanie transakcji BLIK', // Przelewy 'transfer-internal' => 'Przelew wewnętrzny (na konto w tym samym banku)', 'transfer-external' => 'Przelew zewnętrzny (na konto w innym banku)', - 'transfer-express' => 'Przelew ekspresowy', - 'transfer-foreign' => 'Przelew zagraniczny', + 'transfer-express' => 'Przelew ekspresowy', + 'transfer-foreign' => 'Przelew zagraniczny', // Ustawienia i bezpieczeństwo - 'settings-view' => 'Odczyt ustawień konta', - 'settings-update' => 'Aktualizacja ustawień konta', - 'notifications-view' => 'Odczyt powiadomień', + 'settings-view' => 'Odczyt ustawień konta', + 'settings-update' => 'Aktualizacja ustawień konta', + 'notifications-view' => 'Odczyt powiadomień', 'notifications-manage' => 'Zarządzanie powiadomieniami', // Limity i uprawnienia - 'limits-view' => 'Odczyt limitów transakcyjnych', + 'limits-view' => 'Odczyt limitów transakcyjnych', 'limits-update' => 'Modyfikacja limitów transakcyjnych', // Beneficjenci (zaufani odbiorcy) - 'beneficiaries-view' => 'Odczyt listy zaufanych odbiorców', + 'beneficiaries-view' => 'Odczyt listy zaufanych odbiorców', 'beneficiaries-manage' => 'Zarządzanie zaufanymi odbiorcami', // Kredyty / Pożyczki - 'loans-view' => 'Odczyt listy kredytów', - 'loans-details' => 'Odczyt szczegółów kredytu', + 'loans-view' => 'Odczyt listy kredytów', + 'loans-details' => 'Odczyt szczegółów kredytu', 'loans-payments' => 'Odczyt harmonogramu spłat', // Produkty inwestycyjne - 'investments-view' => 'Odczyt produktów inwestycyjnych', + 'investments-view' => 'Odczyt produktów inwestycyjnych', 'investments-details' => 'Odczyt szczegółów inwestycji', ]); Passport::defaultScopes([ 'user-profile', + 'user-profile-manage', 'balance-view', 'balance-history', diff --git a/backend/app/Rules/Adult/AdultGuardian.php b/backend/app/Rules/Adult/AdultGuardian.php new file mode 100644 index 0000000..122e5bd --- /dev/null +++ b/backend/app/Rules/Adult/AdultGuardian.php @@ -0,0 +1,38 @@ +user(); + + if ($requester && $requester->isAdult()) { + $fail('You are already an adult. Guardian is not required.'); + + return; + } + + $guardian = User::query()->where('email', $value)->first(); + + if (! $guardian) { + $fail('Guardian with this email does not exist.'); + + return; + } + + if (! $guardian->date_of_birth || $guardian->date_of_birth->age < 18) { + $fail('Guardian must be at least 18 years old.'); + } + } +} diff --git a/backend/app/Rules/Password/CurrentPassword.php b/backend/app/Rules/Password/CurrentPassword.php index 31d20dd..875f284 100644 --- a/backend/app/Rules/Password/CurrentPassword.php +++ b/backend/app/Rules/Password/CurrentPassword.php @@ -2,7 +2,6 @@ namespace App\Rules\Password; -use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Facades\Hash; use Illuminate\Translation\PotentiallyTranslatedString; @@ -12,11 +11,11 @@ class CurrentPassword implements ValidationRule /** * Run the validation rule. * - * @param Closure(string, ?string=): PotentiallyTranslatedString $fail + * @param \Closure(string, ?string=): PotentiallyTranslatedString $fail */ - public function validate(string $attribute, mixed $value, Closure $fail): void + public function validate(string $attribute, mixed $value, \Closure $fail): void { - if (!Hash::check($value, auth()->user()->password)) { + if (! Hash::check($value, auth()->user()->password)) { $fail('The provided password is incorrect.'); } } diff --git a/backend/app/Services/Account/AccountService.php b/backend/app/Services/Account/AccountService.php new file mode 100644 index 0000000..7c76b09 --- /dev/null +++ b/backend/app/Services/Account/AccountService.php @@ -0,0 +1,88 @@ + $userId, + 'name' => $name, + 'account_number' => $this->generateAccountNumber(), + 'balance' => 0, + 'currency' => $currency, + 'type' => $type, + ]); + + if ($withCard) { + $this->cardService->createCard($account->id, $cardNetwork, $cardType); + } + + return $account->load('cards'); + }); + } + + /** + * Update account name only. + */ + public function updateName(Account $account, string $name): Account + { + $account->update(['name' => $name]); + + return $account->fresh(); + } + + /** + * Close account (only if balance is zero). + */ + public function closeAccount(Account $account): void + { + if ($account->balance > 0) { + throw new \Exception('Cannot close account with non-zero balance.'); + } + + DB::transaction(function () use ($account) { + $account->cards()->delete(); + $account->delete(); + }); + } + + /** + * Generate unique account number. + */ + private function generateAccountNumber(): string + { + do { + $number = $this->generate(); + } while (Account::where('account_number', $number)->exists()); + + return $number; + } +} diff --git a/backend/app/Services/Account/Card/CardService.php b/backend/app/Services/Account/Card/CardService.php new file mode 100644 index 0000000..df089ef --- /dev/null +++ b/backend/app/Services/Account/Card/CardService.php @@ -0,0 +1,68 @@ +generateExpiryDate(); + + do { + $cardNumber = $this->generateCardNumber($network); + } while (Card::where('card_number', $cardNumber)->exists()); + + return Card::create([ + 'account_id' => $accountId, + 'card_number' => $cardNumber, + 'exp_month' => $expiry['month'], + 'exp_year' => $expiry['year'], + 'cvv' => $this->generateCvv($network), + 'network' => $network->value, + 'status' => 'active', + 'type' => $type->value, + ]); + } + + /** + * Block a card. + */ + public function blockCard(Card $card): Card + { + $card->update(['status' => 'blocked']); + + return $card->fresh(); + } + + /** + * Unblock a card. + */ + public function unblockCard(Card $card): Card + { + $card->update(['status' => 'active']); + + return $card->fresh(); + } + + /** + * Delete a card. + */ + public function deleteCard(Card $card): void + { + $card->delete(); + } +} diff --git a/backend/app/Services/Account/Flik/FlikCodeService.php b/backend/app/Services/Account/Flik/FlikCodeService.php new file mode 100644 index 0000000..59f672d --- /dev/null +++ b/backend/app/Services/Account/Flik/FlikCodeService.php @@ -0,0 +1,9 @@ +calculateChecksum($tmpChecksumStr); @@ -30,7 +28,7 @@ public function generate(): string private function generateClientNumber(): string { $random = ''; - for ($i = 0; $i < 16; $i++) { + for ($i = 0; $i < 16; ++$i) { $random .= random_int(0, 9); } @@ -38,24 +36,24 @@ private function generateClientNumber(): string } /** - * Oblicza sumę kontrolną modulo 97 (standard IBAN) + * Oblicza sumę kontrolną modulo 97 (standard IBAN). */ private function calculateChecksum(string $ibanBase): string { $remainder = 0; - $digits = str_split($ibanBase); + $digits = str_split($ibanBase); foreach ($digits as $digit) { - $remainder = (int)(($remainder . $digit) % 97); + $remainder = (int) (($remainder . $digit) % 97); } $checksum = 98 - $remainder; - return str_pad((string)$checksum, 2, '0', STR_PAD_LEFT); + return str_pad((string) $checksum, 2, '0', STR_PAD_LEFT); } /** - * Konwertuj NRB na format IBAN + * Konwertuj NRB na format IBAN. */ public function toIBAN(string $nrb): string { @@ -63,7 +61,7 @@ public function toIBAN(string $nrb): string } /** - * Formatuj numer do wyświetlenia (co 4 cyfry spacja) + * Formatuj numer do wyświetlenia (co 4 cyfry spacja). */ public function format(string $nrb): string { diff --git a/backend/app/Services/Generator/CardNumberGeneratorService.php b/backend/app/Services/Generator/CardNumberGeneratorService.php new file mode 100644 index 0000000..aaae2c3 --- /dev/null +++ b/backend/app/Services/Generator/CardNumberGeneratorService.php @@ -0,0 +1,154 @@ +prefix(); + $prefix = $prefixes[array_rand($prefixes)]; + $prefixLength = strlen($prefix); + + $randomLength = 15 - $prefixLength; + + $number = $prefix; + for ($i = 0; $i < $randomLength; ++$i) { + $number .= random_int(0, 9); + } + + return $number . $this->calculateLuhnChecksum($number); + } + + /** + * Generate CVV for given network. + */ + public function generateCvv(CardNetwork $network = CardNetwork::VISA): string + { + $length = $network->cvvLength(); + + return str_pad((string) random_int(0, 10 ** $length - 1), $length, '0', STR_PAD_LEFT); + } + + /** + * Generate expiry date (4 years from now). + */ + public function generateExpiryDate(): array + { + $now = now(); + $expiryDate = $now->copy()->addYears(4); + + return [ + 'month' => (int) $expiryDate->format('m'), + 'year' => (int) $expiryDate->format('Y'), + ]; + } + + /** + * Generate full card data. + */ + public function generateCardData( + CardNetwork $network = CardNetwork::VISA, + CardType $type = CardType::DEBIT, + ): array { + $expiry = $this->generateExpiryDate(); + + return [ + 'card_number' => $this->generateCardNumber($network), + 'cvv' => $this->generateCvv($network), + 'exp_month' => $expiry['month'], + 'exp_year' => $expiry['year'], + 'network' => $network->value, + 'type' => $type->value, + ]; + } + + /** + * Validate card number using Luhn algorithm. + */ + public function isValidCardNumber(string $number): bool + { + $number = str_replace(' ', '', $number); + + if (16 !== strlen($number) || ! is_numeric($number)) { + return false; + } + + $checksum = (int) $number[15]; + $payload = substr($number, 0, 15); + + return $this->calculateLuhnChecksum($payload) === (string) $checksum; + } + + /** + * Detect card network from number. + */ + public function detectNetwork(string $number): ?CardNetwork + { + $number = str_replace(' ', '', $number); + + foreach (CardNetwork::cases() as $network) { + foreach ($network->prefix() as $prefix) { + if (str_starts_with($number, $prefix)) { + return $network; + } + } + } + + return null; + } + + /** + * Mask card number for display. + */ + public function maskCardNumber(string $number): string + { + $number = str_replace(' ', '', $number); + + return substr($number, 0, 4) . ' **** **** ' . substr($number, -4); + } + + /** + * Format card number (groups of 4). + */ + public function formatCardNumber(string $number): string + { + $number = str_replace(' ', '', $number); + + return trim(chunk_split($number, 4, ' ')); + } + + /** + * Calculate Luhn checksum digit. + */ + private function calculateLuhnChecksum(string $payload): string + { + $digits = str_split($payload); + $sum = 0; + $double = true; + + for ($i = count($digits) - 1; $i >= 0; --$i) { + $digit = (int) $digits[$i]; + + if ($double) { + $digit *= 2; + if ($digit > 9) { + $digit -= 9; + } + } + + $sum += $digit; + $double = ! $double; + } + + $checksum = (10 - ($sum % 10)) % 10; + + return (string) $checksum; + } +} diff --git a/backend/app/Services/Guardian/GuardianService.php b/backend/app/Services/Guardian/GuardianService.php new file mode 100644 index 0000000..08dbbd4 --- /dev/null +++ b/backend/app/Services/Guardian/GuardianService.php @@ -0,0 +1,46 @@ +notify(new GuardianRequestNotification($ward)); + + // Ward send info + $ward->notify(new GuardianRequestSentNotification($guardian)); + } + + public function approveGuardian(User $guardian, User $ward): void + { + $ward->update([ + 'guardian_id' => $guardian->id, + 'guardian_approved_at' => now(), + ]); + + $ward->notify(new GuardianApprovedNotification($guardian)); + } + + public function rejectGuardian(User $guardian, User $ward): void + { + if ($ward->guardian_id === $guardian->id) { + $ward->update(['guardian_id' => null]); + } + + $ward->update([ + 'guardian_id' => $guardian->id, + 'guardian_approved_at' => now(), + ]); + + $ward->notify(new GuardianRejectedNotification($guardian)); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index c52b05b..e23edb2 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -15,7 +15,6 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; - return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', @@ -32,6 +31,7 @@ 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + 'adult' => \App\Http\Middleware\Adult\AdultCheckMiddleware::class, ]); $middleware->group('web', [ @@ -52,50 +52,12 @@ \Illuminate\Http\Middleware\HandleCors::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, -// \App\Http\Middleware\ForceJsonResponse::class, ]); }) -// ->withSingletons([ -// \Illuminate\Contracts\Debug\ExceptionHandler::class => \App\Exceptions\Handler::class, -// ]) -// ->withSingletons([ -// Illuminate\Contracts\Debug\ExceptionHandler::class => Illuminate\Foundation\Exceptions\Handler::class, -// ]) + ->withSingletons([ + \Illuminate\Contracts\Debug\ExceptionHandler::class => \App\Exceptions\Handler::class, + ]) ->withExceptions(function (Exceptions $exceptions) { -// $exceptions->renderable(function (ValidationException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (AuthenticationException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (AuthorizationException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (ModelNotFoundException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (NotFoundHttpException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (UnauthorizedHttpException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (AccessDeniedHttpException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (ThrottleRequestsException $e) { -// return Handler::handleException($e); -// }); -// -// $exceptions->renderable(function (MissingScopeException $e) { -// return Handler::handleException($e); -// }); + }) ->create(); diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php index fc94ae6..cc1e4af 100644 --- a/backend/bootstrap/providers.php +++ b/backend/bootstrap/providers.php @@ -1,7 +1,9 @@ =5.3", + "react/stream": "^1.2" }, "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." + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" }, "type": "library", "autoload": { "psr-4": { - "Faker\\": "src/Faker/" + "Clue\\React\\NDJson\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7405,57 +7391,75 @@ ], "authors": [ { - "name": "François Zaninotto" + "name": "Christian Lück", + "email": "christian@clue.engineering" } ], - "description": "Faker is a PHP library that generates fake data for you.", + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", "keywords": [ - "data", - "faker", - "fixtures" + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" ], "support": { - "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" }, - "time": "2024-11-21T13:46:39+00:00" + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" }, { - "name": "filp/whoops", - "version": "2.18.4", + "name": "composer/pcre", + "version": "3.3.2", "source": { "type": "git", - "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "psr/log": "^1.0.1 || ^2.0 || ^3.0" + "php": "^7.4 || ^8.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" + "conflict": { + "phpstan/phpstan": "<1.11.10" }, - "suggest": { - "symfony/var-dumper": "Pretty print complex values better with var-dumper available", - "whoops/soap": "Formats errors as SOAP responses" + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" }, "type": "library", "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, "branch-alias": { - "dev-master": "2.7-dev" + "dev-main": "3.x-dev" } }, "autoload": { "psr-4": { - "Whoops\\": "src/Whoops/" + "Composer\\Pcre\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -7464,116 +7468,143 @@ ], "authors": [ { - "name": "Filipe Dobreira", - "homepage": "https://github.com/filp", - "role": "Developer" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "php error handling for cool kids", - "homepage": "https://filp.github.io/whoops/", + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", "keywords": [ - "error", - "exception", - "handling", - "library", - "throwable", - "whoops" + "PCRE", + "preg", + "regex", + "regular expression" ], "support": { - "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.4" + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" }, "funding": [ { - "url": "https://github.com/denis-sokolov", + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" } ], - "time": "2025-08-08T12:00:00+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { - "name": "hamcrest/hamcrest-php", - "version": "v2.1.1", + "name": "composer/semver", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { - "php": "^7.4|^8.0" - }, - "replace": { - "cordoval/hamcrest-php": "*", - "davedevelopment/hamcrest-php": "*", - "kodova/hamcrest-php": "*" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "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" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-main": "3.x-dev" } }, "autoload": { - "classmap": [ - "hamcrest" - ] + "psr-4": { + "Composer\\Semver\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "description": "This is the PHP port of Hamcrest Matchers", + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", "keywords": [ - "test" + "semantic", + "semver", + "validation", + "versioning" ], "support": { - "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" }, - "time": "2025-04-30T06:54:44+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" }, { - "name": "laravel/agent-detector", - "version": "v2.0.2", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/laravel/agent-detector.git", - "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", - "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "php": "^8.2.0" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "laravel/pint": "^1.24.0", - "pestphp/pest": "^3.8.5|^4.1.0", - "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", - "phpstan/phpstan": "^2.1.26", - "rector/rector": "^2.1.7", - "symfony/var-dumper": "^7.3.3" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "Laravel\\AgentDetector\\": "src/" + "Composer\\XdebugHandler\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -7582,66 +7613,83 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" } ], - "description": "Detect if code is running in an AI agent or automated development environment", - "homepage": "https://github.com/laravel/agent-detector", + "description": "Restarts a process without Xdebug.", "keywords": [ - "Agent", - "ai", - "automation", - "claude", - "cursor", - "detection", - "devin", - "php" + "Xdebug", + "performance" ], "support": { - "issues": "https://github.com/laravel/agent-detector/issues", - "source": "https://github.com/laravel/agent-detector" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, - "time": "2026-04-29T18:32:34+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" }, { - "name": "laravel/breeze", - "version": "v2.4.1", + "name": "ergebnis/agent-detector", + "version": "1.2.0", "source": { "type": "git", - "url": "https://github.com/laravel/breeze.git", - "reference": "28cefeaf6af20177ddf5cc7b93e87e4ad79d533f" + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/breeze/zipball/28cefeaf6af20177ddf5cc7b93e87e4ad79d533f", - "reference": "28cefeaf6af20177ddf5cc7b93e87e4ad79d533f", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", "shasum": "" }, "require": { - "illuminate/console": "^11.0|^12.0|^13.0", - "illuminate/filesystem": "^11.0|^12.0|^13.0", - "illuminate/support": "^11.0|^12.0|^13.0", - "illuminate/validation": "^11.0|^12.0|^13.0", - "php": "^8.2.0", - "symfony/console": "^7.0|^8.0" + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { - "laravel/framework": "^11.0|^12.0|^13.0", - "orchestra/testbench-core": "^9.0|^10.0|^11.0", - "phpstan/phpstan": "^2.0" + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" }, "type": "library", "extra": { - "laravel": { - "providers": [ - "Laravel\\Breeze\\BreezeServiceProvider" - ] + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" } }, "autoload": { "psr-4": { - "Laravel\\Breeze\\": "src/" + "Ergebnis\\AgentDetector\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7650,70 +7698,44 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } ], - "description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.", - "keywords": [ - "auth", - "laravel" - ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", "support": { - "issues": "https://github.com/laravel/breeze/issues", - "source": "https://github.com/laravel/breeze" + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" }, - "time": "2026-03-10T19:59:01+00:00" + "time": "2026-05-07T08:19:07+00:00" }, { - "name": "laravel/pail", - "version": "v1.2.6", + "name": "evenement/evenement", + "version": "v3.0.2", "source": { "type": "git", - "url": "https://github.com/laravel/pail.git", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", "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" + "php": ">=7.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" + "phpunit/phpunit": "^9 || ^6" }, "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Pail\\PailServiceProvider" - ] - }, - "branch-alias": { - "dev-main": "1.x-dev" - } - }, "autoload": { "psr-4": { - "Laravel\\Pail\\": "src/" + "Evenement\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7722,83 +7744,61 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - }, - { - "name": "Nuno Maduro", - "email": "enunomaduro@gmail.com" + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" } ], - "description": "Easily delve into your Laravel application's log files directly from the command line.", - "homepage": "https://github.com/laravel/pail", + "description": "Événement is a very simple event dispatching library for PHP", "keywords": [ - "dev", - "laravel", - "logs", - "php", - "tail" + "event-dispatcher", + "event-emitter" ], "support": { - "issues": "https://github.com/laravel/pail/issues", - "source": "https://github.com/laravel/pail" + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" }, - "time": "2026-02-09T13:44:54+00:00" + "time": "2023-08-08T05:53:35+00:00" }, { - "name": "laravel/pao", - "version": "v1.0.6", + "name": "fakerphp/faker", + "version": "v1.24.1", "source": { "type": "git", - "url": "https://github.com/laravel/pao.git", - "reference": "02f62a64c2b60af44a418ee490fee193590d8269" + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pao/zipball/02f62a64c2b60af44a418ee490fee193590d8269", - "reference": "02f62a64c2b60af44a418ee490fee193590d8269", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", "shasum": "" }, "require": { - "laravel/agent-detector": "^2.0.0", - "php": "^8.3" + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" }, "conflict": { - "laravel/framework": "<12.0.0", - "nunomaduro/collision": "<8.9.3", - "pestphp/pest": "<4.6.3 || >=6.0.0", - "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + "fzaninotto/faker": "*" }, "require-dev": { - "brianium/paratest": "^7.20.0", - "laravel/pint": "^1.29.1", - "orchestra/testbench": "^10.11.0 || ^11.1.0", - "pestphp/pest": "^4.6.3 || ^5.0.0", - "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", - "phpstan/phpstan": "^2.1.51", - "rector/rector": "^2.4.2", - "symfony/process": "^7.4.8 || ^8.1.0", - "symfony/var-dumper": "^7.4.8 || ^8.0.8" + "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" }, - "type": "library", - "extra": { - "pest": { - "plugins": [ - "Laravel\\Pao\\Drivers\\Pest\\Plugin" - ] - }, - "laravel": { - "providers": [ - "Laravel\\Pao\\Laravel\\ServiceProvider" - ] - } + "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": { - "files": [ - "src/Autoload.php" - ], "psr-4": { - "Laravel\\Pao\\": "src/" + "Faker\\": "src/Faker/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7807,68 +7807,53 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" + "name": "François Zaninotto" } ], - "description": "Agent-optimized output for PHP testing tools", + "description": "Faker is a PHP library that generates fake data for you.", "keywords": [ - "Agent", - "PHPStan", - "ai", - "dev", - "paratest", - "pest", - "php", - "phpunit", - "testing" + "data", + "faker", + "fixtures" ], "support": { - "issues": "https://github.com/laravel/pao/issues", - "source": "https://github.com/laravel/pao" + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" }, - "time": "2026-04-27T22:37:26+00:00" + "time": "2024-11-21T13:46:39+00:00" }, { - "name": "laravel/pint", - "version": "v1.29.1", + "name": "fidry/cpu-core-counter", + "version": "1.3.0", "source": { "type": "git", - "url": "https://github.com/laravel/pint.git", - "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", - "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "ext-tokenizer": "*", - "ext-xml": "*", - "php": "^8.2.0" + "php": "^7.2 || ^8.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" + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" }, - "bin": [ - "builds/pint" - ], - "type": "project", + "type": "library", "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Seeders\\": "database/seeders/", - "Database\\Factories\\": "database/factories/" + "Fidry\\CpuCoreCounter\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7877,234 +7862,1306 @@ ], "authors": [ { - "name": "Nuno Maduro", - "email": "enunomaduro@gmail.com" + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" } ], - "description": "An opinionated code formatter for PHP.", - "homepage": "https://laravel.com", + "description": "Tiny utility to get the number of CPU cores.", "keywords": [ - "dev", - "format", - "formatter", - "lint", - "linter", - "php" + "CPU", + "core" ], "support": { - "issues": "https://github.com/laravel/pint/issues", - "source": "https://github.com/laravel/pint" + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, - "time": "2026-04-20T15:26:14+00:00" + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" }, { - "name": "mockery/mockery", - "version": "1.6.12", + "name": "filp/whoops", + "version": "2.18.4", "source": { "type": "git", - "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", - "php": ">=7.3" - }, - "conflict": { - "phpunit/phpunit": "<8.0" + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.6.17", - "symplify/easy-coding-standard": "^12.1.14" + "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": { - "files": [ - "library/helpers.php", - "library/Mockery.php" - ], "psr-4": { - "Mockery\\": "library/Mockery" + "Whoops\\": "src/Whoops/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "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", + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", "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", + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", "keywords": [ - "BDD", - "TDD", + "error", + "exception", + "handling", "library", - "mock", - "mock objects", - "mockery", - "stub", - "test", - "test double", - "testing" + "throwable", + "whoops" ], "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" + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" }, - "time": "2024-05-16T03:13:13+00:00" + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" }, { - "name": "nunomaduro/collision", - "version": "v8.9.4", + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.1", "source": { "type": "git", - "url": "https://github.com/nunomaduro/collision.git", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065", + "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.1.1", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.8.0", + "infection/infection": "^0.32.6", + "justinrainbow/json-schema": "^6.8.0", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", + "symfony/polyfill-php85": "^1.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-04-12T17:00:09+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/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" + }, + { + "name": "laravel/breeze", + "version": "v2.4.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/breeze.git", + "reference": "28cefeaf6af20177ddf5cc7b93e87e4ad79d533f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/breeze/zipball/28cefeaf6af20177ddf5cc7b93e87e4ad79d533f", + "reference": "28cefeaf6af20177ddf5cc7b93e87e4ad79d533f", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/filesystem": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "illuminate/validation": "^11.0|^12.0|^13.0", + "php": "^8.2.0", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^11.0|^12.0|^13.0", + "orchestra/testbench-core": "^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Breeze\\BreezeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Breeze\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/breeze/issues", + "source": "https://github.com/laravel/breeze" + }, + "time": "2026-03-10T19:59:01+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "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-02-09T13:44:54+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/02f62a64c2b60af44a418ee490fee193590d8269", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.0", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench": "^10.11.0 || ^11.1.0", + "pestphp/pest": "^4.6.3 || ^5.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", + "phpstan/phpstan": "^2.1.51", + "rector/rector": "^2.4.2", + "symfony/process": "^7.4.8 || ^8.1.0", + "symfony/var-dumper": "^7.4.8 || ^8.0.8" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-04-27T22:37:26+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": "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": "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": "12.5.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "876099a072646c7745f673d7aeab5382c4439691" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.1" + }, + "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": "12.5.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/12.5.6" + }, + "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": "2026-04-15T08:23:17+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.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", + "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/6.0.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-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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", + "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/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "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": "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/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", - "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "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" + "php": ">=8.3" }, "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" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { - "laravel": { - "providers": [ - "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" - ] - }, "branch-alias": { - "dev-8.x": "8.x-dev" + "dev-main": "8.0-dev" } }, "autoload": { - "files": [ - "./src/Adapters/Phpunit/Autoload.php" - ], - "psr-4": { - "NunoMaduro\\Collision\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nuno Maduro", - "email": "enunomaduro@gmail.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Cli error handling for console/command-line PHP applications.", + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", "keywords": [ - "artisan", - "cli", - "command-line", - "console", - "dev", - "error", - "handling", - "laravel", - "laravel-zero", - "php", - "symfony" + "timer" ], "support": { - "issues": "https://github.com/nunomaduro/collision/issues", - "source": "https://github.com/nunomaduro/collision" + "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/8.0.0" }, "funding": [ { - "url": "https://www.paypal.com/paypalme/enunomaduro", - "type": "custom" - }, - { - "url": "https://github.com/nunomaduro", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://www.patreon.com/nunomaduro", - "type": "patreon" } ], - "time": "2026-04-21T14:04:20+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.4", + "name": "phpunit/phpunit", + "version": "12.5.24", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", "shasum": "" }, "require": { "ext-dom": "*", + "ext-json": "*", "ext-libxml": "*", - "ext-phar": "*", + "ext-mbstring": "*", + "ext-xml": "*", "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.6", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.0", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, + "bin": [ + "phpunit" + ], "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "12.5-dev" } }, "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], "classmap": [ "src/" ] @@ -8114,520 +9171,557 @@ "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" + "role": "lead" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" }, "funding": [ { - "url": "https://github.com/theseer", - "type": "github" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2024-03-03T12:33:53+00:00" + "time": "2026-05-01T04:21:04+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "react/cache", + "version": "v1.2.0", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Cache\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" }, { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Library for handling version information and constraints", + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" }, - "time": "2022-02-21T01:04:05+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "12.5.6", + "name": "react/child-process", + "version": "v0.6.7", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "876099a072646c7745f673d7aeab5382c4439691" + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", - "reference": "876099a072646c7745f673d7aeab5382c4439691", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", - "php": ">=8.3", - "phpunit/php-text-template": "^5.0", - "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", - "sebastian/version": "^6.0", - "theseer/tokenizer": "^2.0.1" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" - }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "12.5.x-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\ChildProcess\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "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/12.5.6" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" }, { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", - "type": "tidelift" + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2026-04-15T08:23:17+00:00" + "time": "2025-12-23T15:25:20+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "name": "react/dns", + "version": "v1.14.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Dns\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "description": "Async DNS resolver for ReactPHP", "keywords": [ - "filesystem", - "iterator" + "async", + "dns", + "dns-resolver", + "reactphp" ], "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/6.0.1" + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" }, "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" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2025-11-18T19:34:28+00:00" }, { - "name": "phpunit/php-invoker", - "version": "6.0.0", + "name": "react/event-loop", + "version": "v1.6.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=5.3.0" }, "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" }, "suggest": { - "ext-pcntl": "*" + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\EventLoop\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", "keywords": [ - "process" + "asynchronous", + "event-loop" ], "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/6.0.0" + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2025-02-07T04:58:58+00:00" + "time": "2025-11-17T20:46:25+00:00" }, { - "name": "phpunit/php-text-template", - "version": "5.0.0", + "name": "react/promise", + "version": "v3.3.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "description": "A lightweight implementation of CommonJS Promises/A for PHP", "keywords": [ - "template" + "promise", + "promises" ], "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/5.0.0" + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2025-02-07T04:59:16+00:00" + "time": "2025-08-19T18:57:03+00:00" }, { - "name": "phpunit/php-timer", - "version": "8.0.0", + "name": "react/socket", + "version": "v1.17.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", "shasum": "" }, "require": { - "php": ">=8.3" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "8.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Socket\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", "keywords": [ - "timer" + "Connection", + "Socket", + "async", + "reactphp", + "stream" ], "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/8.0.0" + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2025-02-07T04:59:38+00:00" + "time": "2025-11-19T20:47:34+00:00" }, { - "name": "phpunit/phpunit", - "version": "12.5.24", + "name": "react/stream", + "version": "v1.4.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", "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.3", - "phpunit/php-code-coverage": "^12.5.6", - "phpunit/php-file-iterator": "^6.0.1", - "phpunit/php-invoker": "^6.0.0", - "phpunit/php-text-template": "^5.0.0", - "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.6", - "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.0", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", - "sebastian/object-enumerator": "^7.0.0", - "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", - "sebastian/version": "^6.0.0", - "staabm/side-effects-detector": "^1.0.5" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" }, - "bin": [ - "phpunit" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "12.5-dev" - } + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" }, + "type": "library", "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Stream\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", "keywords": [ - "phpunit", - "testing", - "xunit" + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" ], - "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" }, "funding": [ { - "url": "https://phpunit.de/sponsoring.html", - "type": "other" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2026-05-01T04:21:04+00:00" + "time": "2024-06-11T12:45:25+00:00" }, { "name": "sebastian/cli-parser", @@ -9578,6 +10672,293 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/filesystem", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", + "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "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 basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.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:51:42+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "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 an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.0.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-30T15:14:47+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "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\\Php81\\": "" + }, + "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.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "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 way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.0.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-30T15:14:47+00:00" + }, { "name": "theseer/tokenizer", "version": "2.0.1", diff --git a/backend/config/app.php b/backend/config/app.php index 423eed5..218c6c3 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -1,7 +1,8 @@ [ ...array_filter( - explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + explode(',', (string) env('APP_PREVIOUS_KEYS', '')), ), ], @@ -120,7 +121,6 @@ 'maintenance' => [ 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), - 'store' => env('APP_MAINTENANCE_STORE', 'database'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], - ]; diff --git a/backend/config/auth.php b/backend/config/auth.php index 24d8311..a93af87 100644 --- a/backend/config/auth.php +++ b/backend/config/auth.php @@ -1,9 +1,10 @@ [ - 'guard' => env('AUTH_GUARD', 'web'), + 'guard' => env('AUTH_GUARD', 'web'), 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), ], @@ -39,12 +40,12 @@ 'guards' => [ 'web' => [ - 'driver' => 'session', + 'driver' => 'session', 'provider' => 'users', ], 'api' => [ - 'driver' => 'passport', + 'driver' => 'passport', 'provider' => 'users', ], ], @@ -69,7 +70,7 @@ 'providers' => [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ @@ -100,8 +101,8 @@ 'passwords' => [ 'users' => [ 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, 'throttle' => 60, ], ], @@ -118,5 +119,4 @@ */ 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - ]; diff --git a/backend/config/cache.php b/backend/config/cache.php index c68acdf..b775a48 100644 --- a/backend/config/cache.php +++ b/backend/config/cache.php @@ -1,9 +1,10 @@ [ - 'array' => [ - 'driver' => 'array', + 'driver' => 'array', 'serialize' => false, ], 'database' => [ - 'driver' => 'database', - 'connection' => env('DB_CACHE_CONNECTION'), - 'table' => env('DB_CACHE_TABLE', 'cache'), + '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'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), ], 'file' => [ - 'driver' => 'file', - 'path' => storage_path('framework/cache/data'), + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data'), ], 'memcached' => [ - 'driver' => 'memcached', + 'driver' => 'memcached', 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), - 'sasl' => [ + 'sasl' => [ env('MEMCACHED_USERNAME'), env('MEMCACHED_PASSWORD'), ], @@ -65,25 +65,25 @@ ], 'servers' => [ [ - 'host' => env('MEMCACHED_HOST', '127.0.0.1'), - 'port' => env('MEMCACHED_PORT', 11211), + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), 'weight' => 100, ], ], ], 'redis' => [ - 'driver' => 'redis', - 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + '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'), + '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'), ], @@ -98,7 +98,6 @@ 'array', ], ], - ], /* @@ -112,7 +111,7 @@ | */ - 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'), /* |-------------------------------------------------------------------------- @@ -126,5 +125,4 @@ */ 'serializable_classes' => false, - ]; diff --git a/backend/config/cors.php b/backend/config/cors.php index 5fc46ad..9b05c6f 100644 --- a/backend/config/cors.php +++ b/backend/config/cors.php @@ -1,7 +1,8 @@ 0, 'supports_credentials' => true, - ]; diff --git a/backend/config/database.php b/backend/config/database.php index 64709ce..55b8124 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -1,10 +1,11 @@ [ - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', + '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', + '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' => '', + '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([ + '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' => '', + '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([ + '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' => '', + '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'), + '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' => '', + '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'), ], - ], /* @@ -128,7 +127,7 @@ */ 'migrations' => [ - 'table' => 'migrations', + 'table' => 'migrations', 'update_date_on_publish' => true, ], @@ -144,41 +143,38 @@ */ 'redis' => [ - 'client' => env('REDIS_CLIENT', 'phpredis'), 'options' => [ - 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + '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), + '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), + '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), + '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), + '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 index 37d8fca..c12e4f3 100644 --- a/backend/config/filesystems.php +++ b/backend/config/filesystems.php @@ -1,7 +1,8 @@ [ - 'local' => [ 'driver' => 'local', - 'root' => storage_path('app/private'), - 'serve' => true, - 'throw' => false, + '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', + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/') . '/storage', 'visibility' => 'public', - 'throw' => false, - 'report' => false, + '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'), + '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, + 'throw' => false, + 'report' => false, ], - ], /* @@ -76,5 +75,4 @@ 'links' => [ public_path('storage') => storage_path('app/public'), ], - ]; diff --git a/backend/config/logging.php b/backend/config/logging.php index b09cb25..26c7eaa 100644 --- a/backend/config/logging.php +++ b/backend/config/logging.php @@ -1,12 +1,13 @@ [ 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), - 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), ], /* @@ -51,82 +52,79 @@ */ 'channels' => [ - 'stack' => [ - 'driver' => 'stack', - 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + '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'), + '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), + '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'), + '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), + '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'), + '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, + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, 'handler_with' => [ 'stream' => 'php://stderr', ], - 'formatter' => env('LOG_STDERR_FORMATTER'), + 'formatter' => env('LOG_STDERR_FORMATTER'), 'processors' => [PsrLogMessageProcessor::class], ], 'syslog' => [ - 'driver' => 'syslog', - 'level' => env('LOG_LEVEL', 'debug'), - 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + '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'), + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, ], 'null' => [ - 'driver' => 'monolog', + 'driver' => 'monolog', 'handler' => NullHandler::class, ], 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], - ], - ]; diff --git a/backend/config/mail.php b/backend/config/mail.php index e32e88d..f4ff05a 100644 --- a/backend/config/mail.php +++ b/backend/config/mail.php @@ -1,7 +1,8 @@ [ - '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, + '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)), ], @@ -67,12 +67,12 @@ 'sendmail' => [ 'transport' => 'sendmail', - 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), ], 'log' => [ 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), + 'channel' => env('MAIL_LOG_CHANNEL'), ], 'array' => [ @@ -81,7 +81,7 @@ 'failover' => [ 'transport' => 'failover', - 'mailers' => [ + 'mailers' => [ 'smtp', 'log', ], @@ -90,13 +90,12 @@ 'roundrobin' => [ 'transport' => 'roundrobin', - 'mailers' => [ + 'mailers' => [ 'ses', 'postmark', ], 'retry_after' => 60, ], - ], /* @@ -112,7 +111,6 @@ 'from' => [ 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), ], - ]; diff --git a/backend/config/passport.php b/backend/config/passport.php index 11747e2..0b46d33 100644 --- a/backend/config/passport.php +++ b/backend/config/passport.php @@ -1,7 +1,8 @@ [ - '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), + '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, + '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'), + '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, + '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, ], @@ -82,13 +82,12 @@ ], 'failover' => [ - 'driver' => 'failover', + 'driver' => 'failover', 'connections' => [ 'database', 'deferred', ], ], - ], /* @@ -104,7 +103,7 @@ 'batching' => [ 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'job_batches', + 'table' => 'job_batches', ], /* @@ -121,9 +120,8 @@ */ 'failed' => [ - 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'database' => env('DB_CONNECTION', 'sqlite'), - 'table' => 'failed_jobs', + 'table' => 'failed_jobs', ], - ]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php index 04a819e..442acc4 100644 --- a/backend/config/sanctum.php +++ b/backend/config/sanctum.php @@ -1,9 +1,10 @@ [ 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, - 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, - 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, ], - ]; diff --git a/backend/config/scramble.php b/backend/config/scramble.php index f7fdc9d..6712bd3 100644 --- a/backend/config/scramble.php +++ b/backend/config/scramble.php @@ -41,7 +41,7 @@ /* * Define the title of the documentation's website. App name is used when this config is `null`. */ - 'title' => null, + 'title' => config('app.name'), /* * Define the theme of the documentation. Available options are `light`, `dark`, and `system`. @@ -93,7 +93,20 @@ */ 'servers' => null, - /** + 'examples' => [ + 'use_faker' => true, + ], + + 'security' => [ + 'bearer' => [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + 'description' => 'Wprowadź token JWT: **Bearer {token}**', + ], + ], + + /* * Determines how Scramble stores the descriptions of enum cases. * Available options: * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. @@ -104,7 +117,7 @@ */ 'enum_cases_description_strategy' => 'description', - /** + /* * Determines how Scramble stores the names of enum cases. * Available options: * - 'names' – Case names are stored in the `x-enumNames` enum schema extension. @@ -113,7 +126,7 @@ */ 'enum_cases_names_strategy' => false, - /** + /* * When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated * OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until * OpenAPI 3.2 is released and query string structure can be described properly. @@ -130,8 +143,8 @@ 'flatten_deep_query_parameters' => true, 'middleware' => [ - 'web', - RestrictedDocsAccess::class, + // 'web', + // RestrictedDocsAccess::class, ], 'extensions' => [], diff --git a/backend/config/services.php b/backend/config/services.php index 6a90eb8..3a8d891 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -1,7 +1,8 @@ [ - 'key' => env('AWS_ACCESS_KEY_ID'), + 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], @@ -31,8 +32,7 @@ 'slack' => [ 'notifications' => [ 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), - 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), ], ], - ]; diff --git a/backend/config/session.php b/backend/config/session.php index f574482..575c4bd 100644 --- a/backend/config/session.php +++ b/backend/config/session.php @@ -1,9 +1,10 @@ env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + Str::slug((string) env('APP_NAME', 'laravel')) . '-session', ), /* @@ -229,5 +230,4 @@ */ 'serialization' => 'json', - ]; diff --git a/backend/database/factories/Account/AccountFactory.php b/backend/database/factories/Account/AccountFactory.php new file mode 100644 index 0000000..1a6cfa2 --- /dev/null +++ b/backend/database/factories/Account/AccountFactory.php @@ -0,0 +1,41 @@ +faker->randomElement(['PLN', 'USD', 'EURO']); + + return [ + 'user_id' => User::factory(), + 'name' => match ($currency) { + 'PLN' => $this->faker->randomElement(['Konto osobiste', 'Konto oszczędnościowe', 'Konto premium', 'SuperKonto']), + 'USD' => 'Konto walutowe USD', + 'EURO' => 'Konto walutowe EUR', + }, + 'account_number' => $this->generate(), + 'balance' => $this->faker->numberBetween(100, 1500000), + 'currency' => $currency, + 'type' => $this->faker->randomElement(['current', 'savings']), + ]; + } + + public function withCards(int $count = 1): static + { + return $this->afterCreating(function (Account $account) use ($count) { + Card::factory()->count($count)->create([ + 'account_id' => $account->id, + ]); + }); + } +} diff --git a/backend/database/factories/Account/Card/CardFactory.php b/backend/database/factories/Account/Card/CardFactory.php new file mode 100644 index 0000000..ddecf35 --- /dev/null +++ b/backend/database/factories/Account/Card/CardFactory.php @@ -0,0 +1,32 @@ + + */ +class CardFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'account_id' => Account::factory(), + 'card_number' => $this->faker->creditCardNumber(), + 'cvv' => $this->faker->randomNumber(3), + 'exp_month' => $this->faker->date('m'), + 'exp_year' => $this->faker->date('Y'), + 'status' => $this->faker->randomElement(['active', 'inactive', 'blocked']), + 'type' => $this->faker->randomElement(['debit', 'credit', 'prepaid', 'virtual']), + 'network' => $this->faker->randomElement(['Visa', 'MasterCard', 'American Express', 'Discover']), + ]; + } +} diff --git a/backend/database/factories/Account/Flik/FlikCodeFactory.php b/backend/database/factories/Account/Flik/FlikCodeFactory.php new file mode 100644 index 0000000..d79310b --- /dev/null +++ b/backend/database/factories/Account/Flik/FlikCodeFactory.php @@ -0,0 +1,28 @@ + + */ +class FlikCodeFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'card_id' => Card::factory(), + 'code' => $this->faker->unique()->regexify('[0-9]{6}'), + 'expires_at' => $this->faker->dateTimeBetween('+1 minut', '+2 minut'), + 'status' => $this->faker->randomElement(['active', 'used', 'expired']), + ]; + } +} diff --git a/backend/database/factories/Account/Transaction/TransactionFactory.php b/backend/database/factories/Account/Transaction/TransactionFactory.php new file mode 100644 index 0000000..d32c763 --- /dev/null +++ b/backend/database/factories/Account/Transaction/TransactionFactory.php @@ -0,0 +1,36 @@ + + */ +class TransactionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $fromAccountUser = User::factory()->create(); + $toAccountUser = User::factory()->create(); + $fromCardUser = $fromAccountUser->cards()->factory()->create(); + + return [ + 'from_account_id' => $fromAccountUser, + 'to_account_id' => $toAccountUser, + 'from_card_id' => $fromCardUser, + 'amount' => $this->faker->randomFloat(2, 1, 1000), + 'description' => $this->faker->sentence(), + 'type' => $this->faker->randomElement(['transfer', 'payment', 'withdrawal']), + 'status' => $this->faker->randomElement(['pending', 'completed', 'failed']), + 'reference' => $this->faker->uuid(), + ]; + } +} diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php index 1a5bb59..cfde827 100644 --- a/backend/database/factories/UserFactory.php +++ b/backend/database/factories/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Account\Account; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -26,11 +27,15 @@ class UserFactory extends Factory public function definition(): array { return [ - 'name' => fake()->name(), - 'email' => static::$email ??= fake()->unique()->safeEmail(), + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + 'date_of_birth' => fake()->dateTimeBetween( + now()->subYears(30), + now()->subYears(13), + )->format('Y-m-d'), ]; } @@ -43,4 +48,13 @@ public function unverified(): static 'email_verified_at' => null, ]); } + + public function withAccount(int $count = 1): static + { + return $this->afterCreating(function (User $account) use ($count) { + Account::factory()->count($count)->create([ + 'user_id' => $account->id, + ]); + }); + } } 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..913183f 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 @@ -1,24 +1,30 @@ id(); + $table->uuid('id')->primary(); $table->string('name'); $table->string('email')->unique(); + $table->date('date_of_birth')->nullable(); + $table->uuid('guardian_id')->nullable(); + $table->timestamp('guardian_approved_at')->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); $table->timestamps(); + + $table->foreign('guardian_id') + ->references('id') + ->on('users') + ->onDelete('set null'); }); Schema::create('password_reset_tokens', function (Blueprint $table) { @@ -29,7 +35,7 @@ public function up(): void Schema::create('sessions', function (Blueprint $table) { $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('user_id')->nullable()->index(); $table->string('ip_address', 45)->nullable(); $table->text('user_agent')->nullable(); $table->longText('payload'); 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 index 06dc7a5..676d272 100644 --- a/backend/database/migrations/0001_01_01_000001_create_cache_table.php +++ b/backend/database/migrations/0001_01_01_000001_create_cache_table.php @@ -1,11 +1,9 @@ char('id', 80)->primary(); - $table->foreignId('user_id')->index(); + $table->foreignUuid('user_id')->index(); $table->foreignUuid('client_id'); $table->text('scopes')->nullable(); $table->boolean('revoked'); diff --git a/backend/database/migrations/2026_05_05_200012_create_oauth_access_tokens_table.php b/backend/database/migrations/2026_05_05_200012_create_oauth_access_tokens_table.php index 3e50f7f..15cea58 100644 --- a/backend/database/migrations/2026_05_05_200012_create_oauth_access_tokens_table.php +++ b/backend/database/migrations/2026_05_05_200012_create_oauth_access_tokens_table.php @@ -1,11 +1,9 @@ char('id', 80)->primary(); - $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('user_id')->nullable()->index(); $table->foreignUuid('client_id'); $table->string('name')->nullable(); $table->text('scopes')->nullable(); diff --git a/backend/database/migrations/2026_05_05_200013_create_oauth_refresh_tokens_table.php b/backend/database/migrations/2026_05_05_200013_create_oauth_refresh_tokens_table.php index afb3c55..521e251 100644 --- a/backend/database/migrations/2026_05_05_200013_create_oauth_refresh_tokens_table.php +++ b/backend/database/migrations/2026_05_05_200013_create_oauth_refresh_tokens_table.php @@ -1,11 +1,9 @@ char('id', 80)->primary(); - $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('user_id')->nullable()->index(); $table->foreignUuid('client_id')->index(); $table->char('user_code', 8)->unique(); $table->text('scopes'); diff --git a/backend/database/migrations/2026_05_09_170729_accounts.php b/backend/database/migrations/2026_05_09_170729_accounts.php new file mode 100644 index 0000000..9809e9d --- /dev/null +++ b/backend/database/migrations/2026_05_09_170729_accounts.php @@ -0,0 +1,81 @@ +uuid('id')->primary(); + $table->uuid('user_id'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->string('name'); + $table->string('account_number', 32)->unique(); + $table->bigInteger('balance')->default(0); + $table->string('currency', 5)->default('PLN'); + $table->string('type', 20)->default('current'); + $table->timestamps(); + }); + + Schema::create('cards', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('account_id')->constrained()->onDelete('cascade'); + $table->string('card_number', 16)->unique(); + $table->unsignedTinyInteger('exp_month'); + $table->unsignedSmallInteger('exp_year'); + $table->string('cvv', 4); + $table->string('status', 20)->default('active'); + $table->string('type', 20)->default('debit'); + $table->string('network', 20)->default('VISA'); + $table->timestamps(); + + $table->index('account_id'); + }); + + Schema::create('transactions', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('from_account_id')->nullable()->constrained('accounts')->onDelete('set null'); + $table->foreignUuid('to_account_id')->nullable()->constrained('accounts')->onDelete('set null'); + $table->foreignUuid('from_card_id')->nullable()->constrained('cards')->onDelete('set null'); + $table->bigInteger('amount'); + $table->string('description')->nullable(); + $table->string('type', 30); + $table->string('status', 20)->default('pending'); + $table->string('reference', 64)->nullable(); + $table->timestamps(); + + $table->index('from_account_id'); + $table->index('to_account_id'); + $table->index('from_card_id'); + $table->index('reference'); + }); + + Schema::create('flik_codes', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('card_id')->constrained()->onDelete('cascade'); + $table->string('code', 6); + $table->timestamp('expires_at'); + $table->string('status', 20)->default('active'); + $table->foreignUuid('used_in_transaction_id')->nullable()->constrained('transactions')->onDelete('set null'); + $table->timestamps(); + + $table->index('code'); + $table->index('card_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('flik_codes'); + Schema::dropIfExists('transactions'); + Schema::dropIfExists('cards'); + Schema::dropIfExists('accounts'); + } +}; diff --git a/backend/database/migrations/2026_05_09_173714_create_notifications_table.php b/backend/database/migrations/2026_05_09_173714_create_notifications_table.php new file mode 100644 index 0000000..fe54f90 --- /dev/null +++ b/backend/database/migrations/2026_05_09_173714_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->string('notifiable_type'); + $table->uuid('notifiable_id'); + $table->index(['notifiable_type', 'notifiable_id']); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index e0615ce..7aac343 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -17,14 +17,15 @@ public function run(): void { // User::factory(10)->create(); -// User::factory()->create([ -// 'name' => 'Test User', -// 'email' => 'test@example.com', -// 'password' => 'password', -// ]); + // User::factory()->create([ + // 'name' => 'Test User', + // 'email' => 'test@example.com', + // 'password' => 'password', + // ]); $this->call([ PassportClientSeeder::class, + UserSeeder::class, ]); } } diff --git a/backend/database/seeders/PassportClientSeeder.php b/backend/database/seeders/PassportClientSeeder.php index f86aebe..d7a6023 100644 --- a/backend/database/seeders/PassportClientSeeder.php +++ b/backend/database/seeders/PassportClientSeeder.php @@ -2,76 +2,66 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; -use Laravel\Passport\Client; -use Laravel\Passport\ClientRepository; class PassportClientSeeder extends Seeder { public function run(): void { - $clientId = config('passport.client_id', 'bank-client-id'); + $clientId = config('passport.client_id', 'bank-client-id'); $clientSecret = config('passport.client_secret', 'bank-client-secret'); - $nameApp = config('app.name', 'Bank Online System'); - $redirectUri = config('passport.redirect_uri', ''); - $grantTypes = ['authorization_code', 'refresh_token', 'password', 'client_credentials']; - - if (!$users = User::query()->where('email', 'test@example.com')->get()) { - $users = User::factory()->create([ - 'password' => Hash::make('password'), - 'email' => 'test@example.com' - ]); - } - - foreach ($users as $user) { - $this->command->info(' User created: ' . $user->email); - $this->command->info(' Password: password'); - } + $nameApp = config('app.name', 'Bank Online System'); + $redirectUris = [ + config('passport.redirect_uri', ''), + 'http://localhost:8000/auth/callback', + 'http://localhost:5174/auth/callback', + 'http://127.0.0.1:8000/auth/callback', + ]; + $grantTypes = ['authorization_code', 'refresh_token', 'password']; $exists = DB::table('oauth_clients') - ->where('id', $clientId) - ->orWhere('name', $nameApp) - ->exists(); + ->where('id', $clientId) + ->orWhere('name', $nameApp) + ->exists(); if ($exists) { - $this->command->info('⚠️ Passport client already exists. Updating...'); + $this->command->info('⚠️ Passport client already exists. Updating...'); DB::table('oauth_clients') ->where('id', $clientId) ->orWhere('name', $nameApp) ->update([ - 'secret' => Hash::make($clientSecret), - 'redirect_uris' => json_encode([$redirectUri]), - 'updated_at' => now(), - 'grant_types' => json_encode($grantTypes), + 'secret' => Hash::make($clientSecret), + 'redirect_uris' => json_encode($redirectUris), + 'updated_at' => now(), + 'grant_types' => json_encode($grantTypes), ]); $this->command->info('✅ Passport client updating!'); - $this->command->info(' Client ID: ' . $clientId); + $this->command->info(' Client ID: ' . $clientId); $this->command->info(' Client Secret: ' . $clientSecret); - $this->command->info(' Redirect URI: ' . $redirectUri); + $this->command->info(' Redirect URI: ' . implode(', ', $redirectUris)); + $this->command->info(' Grant types: ' . implode(', ', $grantTypes)); return; } DB::table('oauth_clients')->insert([ - 'id' => $clientId, - 'name' => $nameApp, - 'secret' => Hash::make($clientSecret), - 'provider' => 'users', - 'redirect_uris' => json_encode([$redirectUri]), - 'grant_types' => json_encode($grantTypes), - 'revoked' => 0, - 'created_at' => now(), - 'updated_at' => now(), + 'id' => $clientId, + 'name' => $nameApp, + 'secret' => Hash::make($clientSecret), + 'provider' => 'users', + 'redirect_uris' => json_encode($redirectUris), + 'grant_types' => json_encode($grantTypes), + 'revoked' => 0, ]); $this->command->info('✅ Passport client created!'); $this->command->info(' Client ID: ' . $clientId); $this->command->info(' Client Secret: ' . $clientSecret); - $this->command->info(' Redirect URI: ' . $redirectUri); + $this->command->info(' Redirect URI: ' . implode(', ', $redirectUris)); + $this->command->info(' Grant types: ' . implode(', ', $grantTypes)); } } diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php new file mode 100644 index 0000000..a3dda1f --- /dev/null +++ b/backend/database/seeders/UserSeeder.php @@ -0,0 +1,64 @@ + 'Marek Nowak', 'email' => 'marek@example.com'], + ['name' => 'Anna Kowalska', 'email' => 'anna@example.com'], + ['name' => 'Jan Wiśniewski', 'email' => 'jan@example.com'], + ['name' => 'Katarzyna Zielińska', 'email' => 'kasia@example.com'], + ['name' => 'Piotr Wójcik', 'email' => 'piotr@example.com'], + ['name' => 'Magdalena Szymańska', 'email' => 'magda@example.com'], + ['name' => 'Tomasz Kamiński', 'email' => 'tomasz@example.com'], + ['name' => 'Agnieszka Lewandowska', 'email' => 'aga@example.com'], + ['name' => 'Krzysztof Dąbrowski', 'email' => 'krzysztof@example.com'], + ['name' => 'Test User', 'email' => 'test@example.com'], + ]; + + $created = 0; + $existing = 0; + + foreach ($usersData as $data) { + $user = User::query()->where('email', $data['email'])->first(); + + if ($user) { + $this->command->warn('⚠️ User already exists: ' . $user->email); + $this->command->line(' Password: password'); + ++$existing; + + continue; + } + + $user = User::factory()->create([ + 'name' => $data['name'], + 'email' => $data['email'], + ]); + + $accountsCount = 5; + Account::factory()->count($accountsCount)->withCards(rand(1, 3))->create([ + 'user_id' => $user->id, + ]); + + $this->command->info('✅ User created: ' . $user->email); + $this->command->line(' Password: password'); + ++$created; + } + + $this->command->newLine(); + $this->command->info('📊 Summary:'); + $this->command->line(' Created: ' . $created); + $this->command->line(' Existing: ' . $existing); + $this->command->line(' Total: ' . count($usersData)); + } +} diff --git a/backend/docker/entrypoint.sh b/backend/docker/entrypoint.sh index e47fcf6..83575e4 100755 --- a/backend/docker/entrypoint.sh +++ b/backend/docker/entrypoint.sh @@ -3,9 +3,19 @@ set -e echo "🚀 Starting Laravel application..." -# Prepare PHP-FPM directories -mkdir -p /var/run/php-fpm /var/log/php-fpm +# Copy conf file to container +echo "📁 Copying configuration files..." +cp -f docker/nginx.conf /etc/nginx/http.d/default.conf +cp -f docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf +cp -f docker/supervisord.conf /etc/supervisord.conf + +# PHP-FPM && Logs +mkdir -p /var/run/php-fpm /var/log/php-fpm /var/log/nginx chown -R nobody:nobody /var/run/php-fpm /var/log/php-fpm +chown -R nginx:nginx /var/log/nginx + +# Conf file for laravel. +mkdir -p storage/logs bootstrap/cache && chmod -R 775 storage bootstrap/cache # Check if composer.json exists if [ ! -f "composer.json" ]; then @@ -21,6 +31,11 @@ if [ ! -f "vendor/autoload.php" ]; then fi # Generate app key if not exists +if [ ! -f .env ]; then + echo "📄 Creating .env from .env.example..." + cp .env.example .env +fi + if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "" ]; then echo "🔑 Generating APP_KEY..." php artisan key:generate --force diff --git a/backend/docker/nginx.conf b/backend/docker/nginx.conf index ca3b7c1..1ecd4ee 100644 --- a/backend/docker/nginx.conf +++ b/backend/docker/nginx.conf @@ -18,6 +18,9 @@ server { charset utf-8; + error_log /var/log/nginx/error.log; + fastcgi_intercept_errors on; + location / { try_files $uri $uri/ /index.php?$query_string; } @@ -30,8 +33,13 @@ server { location ~ \.php$ { fastcgi_pass php_fpm; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param QUERY_STRING $query_string; include fastcgi_params; fastcgi_hide_header X-Powered-By; + + fastcgi_buffer_size 128k; + fastcgi_buffers 4 256k; + fastcgi_busy_buffers_size 256k; } location ~ /\.(?!well-known).* { diff --git a/backend/docker/php-fpm.conf b/backend/docker/php-fpm.conf index 20caae1..880f423 100644 --- a/backend/docker/php-fpm.conf +++ b/backend/docker/php-fpm.conf @@ -17,3 +17,7 @@ request_terminate_timeout = 300 ; Environment variables env[DB_HOST] = mariadb env[REDIS_HOST] = redis + +catch_workers_output = yes +php_admin_value[error_log] = /var/log/php-fpm/php-fpm-error.log +php_admin_flag[log_errors] = on diff --git a/backend/phpunit.xml b/backend/phpunit.xml index e7f0a48..9f3637a 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -23,8 +23,8 @@ - - + + diff --git a/backend/resources/views/vendor/passport/authorize.blade.php b/backend/resources/views/vendor/passport/authorize.blade.php index aad01ef..6591957 100644 --- a/backend/resources/views/vendor/passport/authorize.blade.php +++ b/backend/resources/views/vendor/passport/authorize.blade.php @@ -3,57 +3,316 @@ - Autoryzacja - Bank Online - + Autoryzacja - {{ config('app.name') }} + + + - -
-
-

Bank Online System

-

Autoryzacja aplikacji

+ +
+
+
+ security +
+
+

Aplikacja żądająca dostępu

+

{{ $client->name }}

+
-
-

- {{ $client->name }} chce uzyskać dostęp do Twojego konta. -

- - @if(count($scopes) > 0) -
-

Uprawnienia:

-
    - @foreach($scopes as $scope) -
  • {{ $scope->description }}
  • - @endforeach -
+
+
+ warning_amber +

Ta aplikacja chce uzyskać dostęp do Twojego konta bankowego. Przejrzyj i wybierz uprawnienia, które chcesz przyznać.

+
+ +

Wybierz uprawnienia

+ + @if(count($scopes) === 0 || (count($scopes) === 1 && $scopes[0]->id === '*')) +
+ admin_panel_settings +

Pełny dostęp — Aplikacja żąda dostępu do wszystkich funkcji. Możesz ograniczyć uprawnienia odznaczając wybrane pozycje.

+
+ + @php + $allScopes = \Laravel\Passport\Passport::scopes(); + @endphp +
+ @foreach($allScopes as $scope) + + @endforeach +
+ @else +
+ @foreach($scopes as $scope) + + @endforeach
@endif -
-
- -
- @csrf - - - - -
- - -
- @csrf - @method('DELETE') - - - - -
+ + +
+
+ @csrf + + + + + +
+ +
+ @csrf + @method('DELETE') + + + + +
+
+ +
+ + diff --git a/backend/routes/api.php b/backend/routes/api.php index 22d15f7..99d9373 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,14 +1,30 @@ group(function () { + return response()->json([ + 'message' => 'Access denied.', + ]); +}); + Route::middleware(['auth:api'])->group(function () { - Route::prefix('/user')->group(function() { - Route::middleware('guest')->withoutMiddleware(['auth:api'])->group(function () { + Route::post('/guardian/approve/{wardId}', [ProfileController::class, 'approveGuardian']); + Route::post('/guardian/reject/{wardId}', [ProfileController::class, 'rejectGuardian']); + + Route::get('/notifications', [NotificationController::class, 'index']); + Route::get('/notifications/unread-count', [NotificationController::class, 'unreadCount']); + Route::patch('/notifications/{notificationId}/read', [NotificationController::class, 'markAsRead']); + Route::patch('/notifications/read-all', [NotificationController::class, 'markAllAsRead']); + + Route::prefix('/user')->group(function () { + Route::middleware('guest')->withoutMiddleware(['auth:api', 'verified'])->group(function () { Route::post('/register', [ProfileController::class, 'create']); }); @@ -22,9 +38,19 @@ Route::middleware([CheckToken::using('user-balance')])->get('/balance', function (Request $request) { return [ - 'balance' => 0.00, + 'balance' => 0.00, 'currency' => 'PLN', ]; }); }); + + Route::middleware(['adult'])->group(function () { + Route::get('accounts/balance', [AccountController::class, 'balance']); + Route::apiResource('accounts', AccountController::class); + Route::apiResource('accounts.cards', CardController::class)->only(['index', 'store']); + Route::get('cards/{card}', [CardController::class, 'show']); + Route::patch('cards/{card}/block', [CardController::class, 'block']); + Route::patch('cards/{card}/unblock', [CardController::class, 'unblock']); + Route::delete('cards/{card}', [CardController::class, 'destroy']); + }); }); diff --git a/backend/routes/auth.php b/backend/routes/auth.php index 3926ecf..efe5ab7 100644 --- a/backend/routes/auth.php +++ b/backend/routes/auth.php @@ -1,5 +1,4 @@ name('password.update'); - Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) + Route::get('logout', [AuthenticatedSessionController::class, 'destroy']) ->name('logout'); }); diff --git a/backend/routes/console.php b/backend/routes/console.php index 3c9adf1..216e7e5 100644 --- a/backend/routes/console.php +++ b/backend/routes/console.php @@ -1,5 +1,4 @@ route('dashboard'); -}); - -Route::get('/docs', function () { - return view('vendor.redoc.redoc'); + return redirect()->route('login'); }); Route::get('/dashboard', function () { return view('dashboard'); -})->middleware(['auth', 'verified'])->name('dashboard'); +})->middleware(['auth'])->name('dashboard'); Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); @@ -21,4 +16,4 @@ Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); }); -require __DIR__.'/auth.php'; +require __DIR__ . '/auth.php'; diff --git a/backend/tests/Feature/Account/AccountTest.php b/backend/tests/Feature/Account/AccountTest.php new file mode 100644 index 0000000..37717b6 --- /dev/null +++ b/backend/tests/Feature/Account/AccountTest.php @@ -0,0 +1,252 @@ +user = User::factory()->create([ + 'date_of_birth' => now()->subYears(30), + ]); + Account::query()->delete(); + Card::query()->delete(); + } + + public function testListsAllUserAccounts(): void + { + Account::factory()->count(3)->create(['user_id' => $this->user->id]); + + User::factory() + ->withAccount(2) + ->create([ + 'date_of_birth' => now()->subYears(30), + ]); + + Passport::actingAs($this->user, ['accounts-view']); + + $response = $this->getJson('/api/accounts'); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data') + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'name', + 'account_number', + 'balance', + 'currency', + 'type', + 'cards', + 'created_at', + 'updated_at', + ], + ], + 'links', + 'meta', + ]); + } + + public function testCreatesNewAccount(): void + { + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->postJson('/api/accounts', [ + 'name' => 'Konto oszczędnościowe', + 'currency' => 'PLN', + 'type' => 'savings', + 'with_card' => true, + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.name', 'Konto oszczędnościowe') + ->assertJsonPath('data.currency', 'PLN') + ->assertJsonPath('data.type', 'savings') + ->assertJsonPath('data.balance', '0.00') + ->assertJsonStructure([ + 'data' => [ + 'id', + 'name', + 'account_number', + 'balance', + 'currency', + 'type', + 'cards', + 'created_at', + 'updated_at', + ], + ]); + + $this->assertDatabaseHas('accounts', [ + 'user_id' => $this->user->id, + 'name' => 'Konto oszczędnościowe', + ]); + } + + public function testCreatesAccountWithoutCard(): void + { + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->postJson('/api/accounts', [ + 'name' => 'Konto bez karty', + 'with_card' => false, + ]); + + $response->assertStatus(201) + ->assertJsonCount(0, 'data.cards'); + } + + public function testShowsAccountDetails(): void + { + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Passport::actingAs($this->user, ['accounts-view', 'accounts-details']); + + $response = $this->getJson("/api/accounts/{$account->id}"); + + $response->assertStatus(200) + ->assertJsonPath('data.id', $account->id) + ->assertJsonPath('data.name', $account->name) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'name', + 'account_number', + 'balance', + 'currency', + 'type', + 'cards', + 'created_at', + 'updated_at', + ], + ]); + } + + public function testUpdatesAccountName(): void + { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'name' => 'Stara nazwa', + ]); + + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->patchJson("/api/accounts/{$account->id}", [ + 'name' => 'Nowa nazwa', + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Nowa nazwa'); + + $this->assertDatabaseHas('accounts', [ + 'id' => $account->id, + 'name' => 'Nowa nazwa', + ]); + } + + public function testCannotUpdateAccountNumberOrBalance(): void + { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'account_number' => '12345678901234567890123456', + 'balance' => 100000, // 1000.00 PLN + ]); + + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->patchJson("/api/accounts/{$account->id}", [ + 'name' => 'Nowa nazwa', + 'account_number' => '99999999999999999999999999', + 'balance' => 999999, + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('accounts', [ + 'id' => $account->id, + 'account_number' => '12345678901234567890123456', + 'balance' => 100000, + ]); + } + + public function testClosesAccountWithZeroBalance(): void + { + Account::query()->each(fn ($account) => $account->delete()); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'balance' => 0, + ]); + + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->deleteJson("/api/accounts/{$account->id}"); + + $response->assertStatus(200) + ->assertJson(['message' => 'Account closed.']); + + $this->assertDatabaseMissing('accounts', ['id' => $account->id]); + } + + public function testCannotCloseAccountWithBalance(): void + { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'balance' => 50000, + ]); + + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->deleteJson("/api/accounts/{$account->id}"); + + $response->assertStatus(500); // lub 422 zależnie od implementacji + + $this->assertDatabaseHas('accounts', ['id' => $account->id]); + } + + public function testRequiresAuthentication(): void + { + $response = $this->getJson('/api/accounts'); + + $response->assertStatus(401); + } + + public function testValidatesStoreRequest(): void + { + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->postJson('/api/accounts', [ + 'name' => '', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + } + + public function testValidatesUpdateRequest(): void + { + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Passport::actingAs($this->user, ['accounts-view', 'accounts-manage']); + + $response = $this->patchJson("/api/accounts/{$account->id}", [ + 'name' => '', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + } +} diff --git a/backend/tests/Feature/Account/Card/CardTest.php b/backend/tests/Feature/Account/Card/CardTest.php new file mode 100644 index 0000000..a85352e --- /dev/null +++ b/backend/tests/Feature/Account/Card/CardTest.php @@ -0,0 +1,307 @@ +user = User::factory()->create([ + 'date_of_birth' => now()->subYears(30), + ]); + $this->account = Account::factory()->create(['user_id' => $this->user->id]); + } + + public function testListsCardsForAccount(): void + { + Card::factory()->count(3)->create(['account_id' => $this->account->id]); + + Passport::actingAs($this->user, ['cards-view']); + + $response = $this->getJson("/api/accounts/{$this->account->id}/cards"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data') + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'card_number', + 'card_last_four', + 'exp_month', + 'exp_year', + 'network', + 'type', + 'status', + 'created_at', + 'updated_at', + ], + ], + 'links', + 'meta', + ]); + } + + public function testListsOnlyCardsForGivenAccount(): void + { + Card::factory()->count(2)->create(['account_id' => $this->account->id]); + + $otherAccount = Account::factory()->create(['user_id' => $this->user->id]); + Card::factory()->count(3)->create(['account_id' => $otherAccount->id]); + + Passport::actingAs($this->user, ['cards-view']); + + $response = $this->getJson("/api/accounts/{$this->account->id}/cards"); + + $response->assertStatus(200) + ->assertJsonCount(2, 'data'); + } + + public function testCreatesNewCardForAccount(): void + { + Passport::actingAs($this->user, ['cards-view', 'cards-manage']); + + $response = $this->postJson("/api/accounts/{$this->account->id}/cards"); + + $response->assertStatus(201) + ->assertJsonPath('data.status', 'active') + ->assertJsonPath('data.type', 'debit') + ->assertJsonStructure([ + 'data' => [ + 'id', + 'card_number', + 'card_last_four', + 'exp_month', + 'exp_year', + 'network', + 'type', + 'status', + 'created_at', + 'updated_at', + ], + ]); + + $this->assertDatabaseHas('cards', [ + 'account_id' => $this->account->id, + 'status' => 'active', + ]); + } + + public function testCreatesCardWithValidNumber(): void + { + Passport::actingAs($this->user, ['cards-manage']); + + $response = $this->postJson("/api/accounts/{$this->account->id}/cards"); + + $response->assertStatus(201); + + $cardNumber = $response->json('data.card_number'); + + // Format: 4532 **** **** 9012 + $this->assertMatchesRegularExpression( + '/^\d{4} \*{4} \*{4} \d{4}$/', + $cardNumber, + ); + } + + public function testCreatesCardWithFutureExpiryDate(): void + { + Passport::actingAs($this->user, ['cards-manage']); + + $response = $this->postJson("/api/accounts/{$this->account->id}/cards"); + + $response->assertStatus(201); + + $expYear = $response->json('data.exp_year'); + $currentYear = (int) now()->format('Y'); + + $this->assertGreaterThanOrEqual($currentYear, $expYear); + } + + public function testMasksCardNumberInResponse(): void + { + Card::factory()->create([ + 'account_id' => $this->account->id, + 'card_number' => '4532123456789012', + ]); + + Passport::actingAs($this->user, ['cards-view']); + + $response = $this->getJson("/api/accounts/{$this->account->id}/cards"); + + $response->assertStatus(200) + ->assertJsonPath('data.0.card_number', '4532 **** **** 9012') + ->assertJsonPath('data.0.card_last_four', '9012'); + } + + public function testNeverExposesCvvInResponse(): void + { + Card::factory()->create([ + 'account_id' => $this->account->id, + 'cvv' => '123', + ]); + + Passport::actingAs($this->user, ['cards-view']); + + $response = $this->getJson("/api/accounts/{$this->account->id}/cards"); + + $response->assertStatus(200) + ->assertJsonMissing(['cvv']); + } + + public function testShowsCardDetails(): void + { + $card = Card::factory()->create([ + 'account_id' => $this->account->id, + 'card_number' => '5214123456789012', + ]); + + Passport::actingAs($this->user, ['cards-details']); + + $response = $this->getJson("/api/cards/{$card->id}"); + + $response->assertStatus(200) + ->assertJsonPath('data.id', $card->id) + ->assertJsonPath('data.card_number', '5214 **** **** 9012') + ->assertJsonPath('data.card_last_four', '9012') + ->assertJsonStructure([ + 'data' => [ + 'id', + 'card_number', + 'card_last_four', + 'exp_month', + 'exp_year', + 'network', + 'type', + 'status', + 'created_at', + 'updated_at', + ], + ]); + } + + public function testBlocksCard(): void + { + $card = Card::factory()->create([ + 'account_id' => $this->account->id, + 'status' => 'active', + ]); + + Passport::actingAs($this->user, ['cards-manage']); + + $response = $this->patchJson("/api/cards/{$card->id}/block"); + + $response->assertStatus(200) + ->assertJsonPath('data.status', 'blocked'); + + $this->assertDatabaseHas('cards', [ + 'id' => $card->id, + 'status' => 'blocked', + ]); + } + + public function testUnblocksCard(): void + { + $card = Card::factory()->create([ + 'account_id' => $this->account->id, + 'status' => 'blocked', + ]); + + Passport::actingAs($this->user, ['cards-manage']); + + $response = $this->patchJson("/api/cards/{$card->id}/unblock"); + + $response->assertStatus(200) + ->assertJsonPath('data.status', 'active'); + + $this->assertDatabaseHas('cards', [ + 'id' => $card->id, + 'status' => 'active', + ]); + } + + public function testCannotAccessCardFromOtherUser(): void + { + $otherUser = User::factory()->create(); + $otherAccount = Account::factory()->create(['user_id' => $otherUser->id]); + $card = Card::factory()->create(['account_id' => $otherAccount->id]); + + Passport::actingAs($this->user); + + $response = $this->getJson("/api/cards/{$card->id}"); + + $response->assertStatus(403); // lub 404 + } + + public function testCannotBlockCardFromOtherUser(): void + { + $otherUser = User::factory()->create(); + $otherAccount = Account::factory()->create(['user_id' => $otherUser->id]); + $card = Card::factory()->create(['account_id' => $otherAccount->id]); + + Passport::actingAs($this->user); + + $response = $this->patchJson("/api/cards/{$card->id}/block"); + + $response->assertStatus(403); + } + + public function testRequiresAuthentication(): void + { + $card = Card::factory()->create(['account_id' => $this->account->id]); + + $this->getJson("/api/accounts/{$this->account->id}/cards") + ->assertStatus(401); + + $this->postJson("/api/accounts/{$this->account->id}/cards") + ->assertStatus(401); + + $this->getJson("/api/cards/{$card->id}") + ->assertStatus(401); + } + + public function testReturns404ForNonExistentCard(): void + { + Passport::actingAs($this->user); + + $response = $this->getJson('/api/cards/99999999-9999-9999-9999-999999999999'); + + $response->assertStatus(404); + } + + public function testReturns404ForNonExistentAccount(): void + { + Passport::actingAs($this->user); + + $response = $this->getJson('/api/accounts/99999999-9999-9999-9999-999999999999/cards'); + + $response->assertStatus(404); + } + + public function testPaginatesCardsList(): void + { + Card::factory()->count(25)->create(['account_id' => $this->account->id]); + + Passport::actingAs($this->user, ['cards-view']); + + $response = $this->getJson("/api/accounts/{$this->account->id}/cards?per_page=10"); + + $response->assertStatus(200) + ->assertJsonCount(10, 'data') + ->assertJsonPath('meta.total', 25) + ->assertJsonPath('meta.per_page', 10); + } +} diff --git a/backend/tests/Feature/Auth/AuthenticationTest.php b/backend/tests/Feature/Auth/AuthenticationTest.php index 13dcb7c..5369a3f 100644 --- a/backend/tests/Feature/Auth/AuthenticationTest.php +++ b/backend/tests/Feature/Auth/AuthenticationTest.php @@ -10,19 +10,19 @@ class AuthenticationTest extends TestCase { use RefreshDatabase; - public function test_login_screen_can_be_rendered(): void + public function testLoginScreenCanBeRendered(): void { $response = $this->get('/login'); $response->assertStatus(200); } - public function test_users_can_authenticate_using_the_login_screen(): void + public function testUsersCanAuthenticateUsingTheLoginScreen(): void { $user = User::factory()->create(); $response = $this->post('/login', [ - 'email' => $user->email, + 'email' => $user->email, 'password' => 'password', ]); @@ -30,23 +30,23 @@ public function test_users_can_authenticate_using_the_login_screen(): void $response->assertRedirect(route('dashboard', absolute: false)); } - public function test_users_can_not_authenticate_with_invalid_password(): void + public function testUsersCanNotAuthenticateWithInvalidPassword(): void { $user = User::factory()->create(); $this->post('/login', [ - 'email' => $user->email, + 'email' => $user->email, 'password' => 'wrong-password', ]); $this->assertGuest(); } - public function test_users_can_logout(): void + public function testUsersCanLogout(): void { $user = User::factory()->create(); - $response = $this->actingAs($user)->post('/logout'); + $response = $this->actingAs($user)->get('/logout'); $this->assertGuest(); $response->assertRedirect('/'); diff --git a/backend/tests/Feature/Auth/EmailVerificationTest.php b/backend/tests/Feature/Auth/EmailVerificationTest.php index 705570b..688423f 100644 --- a/backend/tests/Feature/Auth/EmailVerificationTest.php +++ b/backend/tests/Feature/Auth/EmailVerificationTest.php @@ -13,7 +13,7 @@ class EmailVerificationTest extends TestCase { use RefreshDatabase; - public function test_email_verification_screen_can_be_rendered(): void + public function testEmailVerificationScreenCanBeRendered(): void { $user = User::factory()->unverified()->create(); @@ -22,7 +22,7 @@ public function test_email_verification_screen_can_be_rendered(): void $response->assertStatus(200); } - public function test_email_can_be_verified(): void + public function testEmailCanBeVerified(): void { $user = User::factory()->unverified()->create(); @@ -31,24 +31,24 @@ public function test_email_can_be_verified(): void $verificationUrl = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] + ['id' => $user->id, 'hash' => sha1($user->email)], ); $response = $this->actingAs($user)->get($verificationUrl); Event::assertDispatched(Verified::class); $this->assertTrue($user->fresh()->hasVerifiedEmail()); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + $response->assertRedirect(route('dashboard', absolute: false) . '?verified=1'); } - public function test_email_is_not_verified_with_invalid_hash(): void + public function testEmailIsNotVerifiedWithInvalidHash(): void { $user = User::factory()->unverified()->create(); $verificationUrl = URL::temporarySignedRoute( 'verification.verify', now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')] + ['id' => $user->id, 'hash' => sha1('wrong-email')], ); $this->actingAs($user)->get($verificationUrl); diff --git a/backend/tests/Feature/Auth/PasswordConfirmationTest.php b/backend/tests/Feature/Auth/PasswordConfirmationTest.php index ff85721..322c2cc 100644 --- a/backend/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/backend/tests/Feature/Auth/PasswordConfirmationTest.php @@ -10,7 +10,7 @@ class PasswordConfirmationTest extends TestCase { use RefreshDatabase; - public function test_confirm_password_screen_can_be_rendered(): void + public function testConfirmPasswordScreenCanBeRendered(): void { $user = User::factory()->create(); @@ -19,7 +19,7 @@ public function test_confirm_password_screen_can_be_rendered(): void $response->assertStatus(200); } - public function test_password_can_be_confirmed(): void + public function testPasswordCanBeConfirmed(): void { $user = User::factory()->create(); @@ -31,7 +31,7 @@ public function test_password_can_be_confirmed(): void $response->assertSessionHasNoErrors(); } - public function test_password_is_not_confirmed_with_invalid_password(): void + public function testPasswordIsNotConfirmedWithInvalidPassword(): void { $user = User::factory()->create(); diff --git a/backend/tests/Feature/Auth/PasswordResetTest.php b/backend/tests/Feature/Auth/PasswordResetTest.php index aa50350..1355b6b 100644 --- a/backend/tests/Feature/Auth/PasswordResetTest.php +++ b/backend/tests/Feature/Auth/PasswordResetTest.php @@ -12,14 +12,14 @@ class PasswordResetTest extends TestCase { use RefreshDatabase; - public function test_reset_password_link_screen_can_be_rendered(): void + public function testResetPasswordLinkScreenCanBeRendered(): void { $response = $this->get('/forgot-password'); $response->assertStatus(200); } - public function test_reset_password_link_can_be_requested(): void + public function testResetPasswordLinkCanBeRequested(): void { Notification::fake(); @@ -30,7 +30,7 @@ public function test_reset_password_link_can_be_requested(): void Notification::assertSentTo($user, ResetPassword::class); } - public function test_reset_password_screen_can_be_rendered(): void + public function testResetPasswordScreenCanBeRendered(): void { Notification::fake(); @@ -39,7 +39,7 @@ public function test_reset_password_screen_can_be_rendered(): void $this->post('/forgot-password', ['email' => $user->email]); Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get('/reset-password/'.$notification->token); + $response = $this->get('/reset-password/' . $notification->token); $response->assertStatus(200); @@ -47,7 +47,7 @@ public function test_reset_password_screen_can_be_rendered(): void }); } - public function test_password_can_be_reset_with_valid_token(): void + public function testPasswordCanBeResetWithValidToken(): void { Notification::fake(); @@ -57,9 +57,9 @@ public function test_password_can_be_reset_with_valid_token(): void Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { $response = $this->post('/reset-password', [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', 'password_confirmation' => 'password', ]); diff --git a/backend/tests/Feature/Auth/PasswordUpdateTest.php b/backend/tests/Feature/Auth/PasswordUpdateTest.php index ca28c6c..fa92f00 100644 --- a/backend/tests/Feature/Auth/PasswordUpdateTest.php +++ b/backend/tests/Feature/Auth/PasswordUpdateTest.php @@ -11,7 +11,7 @@ class PasswordUpdateTest extends TestCase { use RefreshDatabase; - public function test_password_can_be_updated(): void + public function testPasswordCanBeUpdated(): void { $user = User::factory()->create(); @@ -19,8 +19,8 @@ public function test_password_can_be_updated(): void ->actingAs($user) ->from('/profile') ->put('/password', [ - 'current_password' => 'password', - 'password' => 'new-password', + 'current_password' => 'password', + 'password' => 'new-password', 'password_confirmation' => 'new-password', ]); @@ -31,7 +31,7 @@ public function test_password_can_be_updated(): void $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); } - public function test_correct_password_must_be_provided_to_update_password(): void + public function testCorrectPasswordMustBeProvidedToUpdatePassword(): void { $user = User::factory()->create(); @@ -39,8 +39,8 @@ public function test_correct_password_must_be_provided_to_update_password(): voi ->actingAs($user) ->from('/profile') ->put('/password', [ - 'current_password' => 'wrong-password', - 'password' => 'new-password', + 'current_password' => 'wrong-password', + 'password' => 'new-password', 'password_confirmation' => 'new-password', ]); diff --git a/backend/tests/Feature/Auth/RegistrationTest.php b/backend/tests/Feature/Auth/RegistrationTest.php index 1489d0e..c6a4676 100644 --- a/backend/tests/Feature/Auth/RegistrationTest.php +++ b/backend/tests/Feature/Auth/RegistrationTest.php @@ -9,19 +9,19 @@ class RegistrationTest extends TestCase { use RefreshDatabase; - public function test_registration_screen_can_be_rendered(): void + public function testRegistrationScreenCanBeRendered(): void { $response = $this->get('/register'); $response->assertStatus(200); } - public function test_new_users_can_register(): void + public function testNewUsersCanRegister(): void { $response = $this->post('/register', [ - 'name' => 'Test User', - 'email' => 'test@example.com', - 'password' => 'password', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', 'password_confirmation' => 'password', ]); diff --git a/backend/tests/Feature/ExampleTest.php b/backend/tests/Feature/ExampleTest.php index 8364a84..2b02cd9 100644 --- a/backend/tests/Feature/ExampleTest.php +++ b/backend/tests/Feature/ExampleTest.php @@ -10,10 +10,11 @@ class ExampleTest extends TestCase /** * A basic test example. */ - public function test_the_application_returns_a_successful_response(): void + public function testTheApplicationReturnsASuccessfulResponse(): void { $response = $this->get('/'); - $response->assertStatus(200); + $response->assertStatus(302); + $response->assertRedirect('/login'); } } diff --git a/backend/tests/Feature/ProfileTest.php b/backend/tests/Feature/ProfileTest.php index 252fdcc..0aa655a 100644 --- a/backend/tests/Feature/ProfileTest.php +++ b/backend/tests/Feature/ProfileTest.php @@ -10,7 +10,7 @@ class ProfileTest extends TestCase { use RefreshDatabase; - public function test_profile_page_is_displayed(): void + public function testProfilePageIsDisplayed(): void { $user = User::factory()->create(); @@ -21,14 +21,14 @@ public function test_profile_page_is_displayed(): void $response->assertOk(); } - public function test_profile_information_can_be_updated(): void + public function testProfileInformationCanBeUpdated(): void { $user = User::factory()->create(); $response = $this ->actingAs($user) ->patch('/profile', [ - 'name' => 'Test User', + 'name' => 'Test User', 'email' => 'test@example.com', ]); @@ -43,14 +43,14 @@ public function test_profile_information_can_be_updated(): void $this->assertNull($user->email_verified_at); } - public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void + public function testEmailVerificationStatusIsUnchangedWhenTheEmailAddressIsUnchanged(): void { $user = User::factory()->create(); $response = $this ->actingAs($user) ->patch('/profile', [ - 'name' => 'Test User', + 'name' => 'Test User', 'email' => $user->email, ]); @@ -61,7 +61,7 @@ public function test_email_verification_status_is_unchanged_when_the_email_addre $this->assertNotNull($user->refresh()->email_verified_at); } - public function test_user_can_delete_their_account(): void + public function testUserCanDeleteTheirAccount(): void { $user = User::factory()->create(); @@ -79,7 +79,7 @@ public function test_user_can_delete_their_account(): void $this->assertNull($user->fresh()); } - public function test_correct_password_must_be_provided_to_delete_account(): void + public function testCorrectPasswordMustBeProvidedToDeleteAccount(): void { $user = User::factory()->create(); diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php index fe1ffc2..539c7dd 100644 --- a/backend/tests/TestCase.php +++ b/backend/tests/TestCase.php @@ -6,5 +6,4 @@ abstract class TestCase extends BaseTestCase { - // } diff --git a/backend/tests/Unit/ExampleTest.php b/backend/tests/Unit/ExampleTest.php index 5773b0c..b5da561 100644 --- a/backend/tests/Unit/ExampleTest.php +++ b/backend/tests/Unit/ExampleTest.php @@ -9,7 +9,7 @@ class ExampleTest extends TestCase /** * A basic test example. */ - public function test_that_true_is_true(): void + public function testThatTrueIsTrue(): void { $this->assertTrue(true); } diff --git a/docker-compose.yml b/docker-compose.yml index fd597d2..4f1c6a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,11 @@ +x-defaults: &defaults + environment: + - TZ=Europe/Warsaw + services: # MariaDB Database mariadb: + <<: *defaults image: mariadb:11.4 container_name: bank-app-mariadb environment: @@ -24,6 +29,7 @@ services: # Redis Cache redis: + <<: *defaults image: redis:7-alpine container_name: bank-app-redis ports: @@ -40,7 +46,7 @@ services: # Backend (Laravel with PHP-FPM + Nginx) backend: -# image: php:8.4-fpm-alpine + <<: *defaults build: context: ./backend dockerfile: Dockerfile @@ -65,6 +71,7 @@ services: # Frontend (React) frontend: + <<: *defaults build: context: ./frontend dockerfile: Dockerfile diff --git a/docker/scripts/mariadb/01-create-testing-db.sql b/docker/scripts/mariadb/01-create-testing-db.sql new file mode 100644 index 0000000..57c3c7c --- /dev/null +++ b/docker/scripts/mariadb/01-create-testing-db.sql @@ -0,0 +1,3 @@ +CREATE DATABASE IF NOT EXISTS backend_testing CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +GRANT ALL PRIVILEGES ON backend_testing.* TO 'laravel'@'%'; +FLUSH PRIVILEGES; \ No newline at end of file diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..678626b --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "ignore": ["node_modules", "dist", "build"] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useConst": "error", + "useTemplate": "error", + "noVar": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "es5", + "semicolons": "always" + } + } +} \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js deleted file mode 100644 index ea36dd3..0000000 --- a/frontend/eslint.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{js,jsx}'], - extends: [ - js.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - globals: globals.browser, - parserOptions: { ecmaFeatures: { jsx: true } }, - }, - }, -]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ed336d1..e05dc80 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,15 +27,11 @@ "zod": "^4.4.3" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@biomejs/biome": "^2.4.15", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.5.0", - "eslint": "^10.2.1", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", "postcss": "^8.5.14", "tailwindcss": "^3.4.19", "vite": "^8.0.10" @@ -66,43 +62,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, "node_modules/@babel/generator": { "version": "7.29.1", "license": "MIT", @@ -117,21 +76,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "license": "MIT", @@ -150,22 +94,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "license": "MIT", @@ -180,26 +108,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.29.3", "license": "MIT", @@ -261,6 +169,169 @@ "node": ">=6.9.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -447,116 +518,6 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, "node_modules/@fontsource/roboto": { "version": "5.2.10", "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz", @@ -576,62 +537,6 @@ "react-hook-form": "^7.55.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "license": "MIT", @@ -640,15 +545,6 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "license": "MIT", @@ -1308,16 +1204,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "dev": true, - "license": "MIT" - }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", @@ -1330,11 +1216,6 @@ "@types/react": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "dev": true, - "license": "MIT" - }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -1395,40 +1276,6 @@ } } }, - "node_modules/acorn": { - "version": "8.16.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1535,14 +1382,6 @@ "npm": ">=6" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.10.27", "dev": true, @@ -1567,17 +1406,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1748,11 +1576,6 @@ "node": ">= 6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "1.1.1", "license": "MIT", @@ -1789,19 +1612,6 @@ "node": ">= 6" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -1854,11 +1664,6 @@ } } }, - "node_modules/deep-is": { - "version": "0.1.4", - "dev": true, - "license": "MIT" - }, "node_modules/delayed-stream": { "version": "1.0.0", "license": "MIT", @@ -1976,175 +1781,8 @@ "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "dev": true, - "license": "MIT" + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/fast-glob": { "version": "3.3.3", @@ -2176,16 +1814,6 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "dev": true, - "license": "MIT" - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2212,17 +1840,6 @@ } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2242,38 +1859,6 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, - "node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "dev": true, - "license": "ISC" - }, "node_modules/follow-redirects": { "version": "1.16.0", "funding": [ @@ -2342,14 +1927,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -2394,17 +1971,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "17.6.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/goober": { "version": "2.1.18", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", @@ -2457,19 +2023,6 @@ "node": ">= 0.4" } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -2485,14 +2038,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2509,14 +2054,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2580,11 +2117,6 @@ "node": ">=0.12.0" } }, - "node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2609,58 +2141,12 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "dev": true, @@ -2937,20 +2423,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2963,14 +2435,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -3032,20 +2496,6 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -3079,11 +2529,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/node-releases": { "version": "2.0.38", "dev": true, @@ -3149,50 +2594,6 @@ "node": ">= 6" } }, - "node_modules/optionator": { - "version": "0.9.4", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3223,22 +2624,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3451,14 +2836,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3483,14 +2860,6 @@ "node": ">=10" } }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3743,37 +3112,10 @@ "version": "0.27.0", "license": "MIT" }, - "node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/set-cookie-parser": { "version": "2.7.2", "license": "MIT" }, - "node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -3978,17 +3320,6 @@ "license": "0BSD", "optional": true }, - "node_modules/type-check": { - "version": "0.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "dev": true, @@ -4018,14 +3349,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4109,61 +3432,12 @@ } } }, - "node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "4.4.3", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } } } } diff --git a/frontend/package.json b/frontend/package.json index b0a241a..848013c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,8 +6,11 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "lint": "biome check src/", + "lint:fix": "biome check --fix src/", + "format": "biome format --write src/", + "check": "biome ci src/" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -29,15 +32,11 @@ "zod": "^4.4.3" }, "devDependencies": { - "@eslint/js": "^10.0.1", + "@biomejs/biome": "^2.4.15", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.5.0", - "eslint": "^10.2.1", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", "postcss": "^8.5.14", "tailwindcss": "^3.4.19", "vite": "^8.0.10" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b6de51..7ffdd44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,71 +1,282 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; +import {ThemeProvider, createTheme, Theme} from '@mui/material/styles'; import { CssBaseline, Box } from '@mui/material'; import { AuthProvider } from './context/AuthContext'; import { ToastProvider } from './context/ToastContext'; import { LoadingProvider } from './context/LoadingContext'; -import { Header } from './components/Layout/Header'; import { ProtectedRoute } from './components/Layout/ProtectedRoute'; -import { LoadingOverlay } from './components/UI/LoadingOverlay'; import { LoginPage } from './pages/Forms/LoginPage'; import { RegisterPage } from './pages/Forms/RegisterPage'; import { BalancePage } from './pages/User/BalancePage'; -import {JSX} from "react"; -import {ProfilePage} from "./pages/User/ProfilePage"; -import {DashboardPage} from "./pages/Dashboard/DashboardPage"; +import { JSX } from "react"; +import { ProfilePage } from "./pages/User/ProfilePage"; +import { DashboardPage } from "./pages/Dashboard/DashboardPage"; +import { GuestOnlyRoute } from "./components/Layout/GuestOnlyRoute"; +import { DashboardLayout } from "./components/Layout/DashboardLayout"; +import { AccountPage } from "./pages/Account/AccountPage"; +import { AccountViewPage } from "./pages/Account/AccoutViewPage"; +import { GuardianApprovalPage } from "./pages/Guardian/GuardianApprovalPage"; +import { NotificationBell } from "./components/Notification/NotificationBell"; +import { NotificationsPage } from "./pages/Notification/NotificationsPage"; const theme = createTheme({ - palette: { - primary: { - main: '#1976d2', - }, - secondary: { - main: '#dc004e', - }, - background: { - default: '#f5f5f5', - paper: '#ffffff', - }, - }, - typography: { - fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', - h4: { - fontWeight: 600, - }, - h5: { - fontWeight: 600, - }, - h6: { - fontWeight: 600, - }, - }, - components: { - MuiButton: { - styleOverrides: { - root: { - textTransform: 'none', - borderRadius: 4, - fontWeight: 600, - }, - }, + palette: { + mode: 'light', + primary: { + main: '#1976d2', + light: '#42a5f5', + dark: '#1565c0', + contrastText: '#ffffff', + }, + secondary: { + main: '#dc004e', + light: '#ff5983', + dark: '#9a0036', + contrastText: '#ffffff', + }, + background: { + default: '#f5f5f5', + paper: '#ffffff', + }, + text: { + primary: 'rgba(0, 0, 0, 0.87)', + secondary: 'rgba(0, 0, 0, 0.6)', + disabled: 'rgba(0, 0, 0, 0.38)', + }, + divider: 'rgba(0, 0, 0, 0.12)', + error: { + main: '#d32f2f', + light: '#ef5350', + dark: '#c62828', + contrastText: '#ffffff', + }, + warning: { + main: '#ed6c02', + light: '#ff9800', + dark: '#e65100', + contrastText: '#ffffff', + }, + info: { + main: '#0288d1', + light: '#03a9f4', + dark: '#01579b', + contrastText: '#ffffff', + }, + success: { + main: '#2e7d32', + light: '#4caf50', + dark: '#1b5e20', + contrastText: '#ffffff', + }, + grey: { + 50: '#fafafa', + 100: '#f5f5f5', + 200: '#eeeeee', + 300: '#e0e0e0', + 400: '#bdbdbd', + 500: '#9e9e9e', + 600: '#757575', + 700: '#616161', + 800: '#424242', + 900: '#212121', + }, }, - MuiPaper: { - styleOverrides: { - root: { - borderRadius: 8, + typography: { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + h4: { + fontWeight: 600, + }, + h5: { + fontWeight: 600, + }, + h6: { + fontWeight: 600, }, - }, }, - MuiTextField: { - styleOverrides: { - root: { - '& .MuiOutlinedInput-root': { - borderRadius: 8, - }, - }, - }, + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + backgroundColor: '#f5f5f5', + color: 'rgba(0, 0, 0, 0.87)', + }, + }, + }, + MuiSvgIcon: { + defaultProps: { + fontSize: 'small', + }, + }, + MuiButton: { + styleOverrides: { + root: ({ theme }) => ({ + textTransform: 'none', + borderRadius: 4, + fontWeight: 600, + color: theme.palette.text.primary, + }), + contained: ({ theme }) => ({ + color: theme.palette.primary.contrastText, + }), + containedSecondary: ({ theme }: any) => ({ + color: theme.palette.secondary.contrastText, + }), + outlined: ({ theme }) => ({ + color: theme.palette.primary.main, + borderColor: theme.palette.primary.main, + }), + }, + }, + MuiPaper: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: 8, + backgroundColor: theme.palette.background.paper, + color: theme.palette.getContrastText(theme.palette.background.paper), + }), + }, + }, + MuiCard: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + color: theme.palette.getContrastText(theme.palette.background.paper), + }), + }, + }, + MuiCardContent: { + styleOverrides: { + root: { + color: 'inherit', + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: ({ theme }) => ({ + '& .MuiOutlinedInput-root': { + borderRadius: 8, + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + }, + '& .MuiInputLabel-root': { + color: theme.palette.text.secondary, + }, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.divider, + }, + }), + }, + }, + MuiTableCell: { + styleOverrides: { + root: ({ theme }) => ({ + color: theme.palette.text.primary, + borderBottom: `1px solid ${theme.palette.divider}`, + }), + head: ({ theme }) => ({ + color: theme.palette.text.primary, + fontWeight: 600, + backgroundColor: theme.palette.grey[50], + }), + }, + }, + MuiTableRow: { + styleOverrides: { + root: { + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + }, + }, + }, + }, + MuiAlert: { + styleOverrides: { + root: ({ theme }) => ({ + color: theme.palette.text.primary, + '& .MuiAlert-message': { + color: theme.palette.text.primary, + }, + }), + }, + }, + MuiChip: { + styleOverrides: { + root: { + fontWeight: 600, + }, + filled: ({ theme }) => ({ + color: theme.palette.primary.contrastText, + }), + outlined: ({ theme }) => ({ + color: theme.palette.text.primary, + borderColor: theme.palette.grey[400], + }), + }, + }, + MuiTypography: { + styleOverrides: { + root: { + color: 'inherit', + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: ({ theme }) => ({ + borderColor: theme.palette.divider, + }), + }, + }, + MuiIconButton: { + styleOverrides: { + root: ({ theme }) => ({ + color: theme.palette.text.primary, + }), + }, + }, + MuiListItemText: { + styleOverrides: { + primary: ({ theme }) => ({ + color: theme.palette.text.primary, + }), + secondary: ({ theme }) => ({ + color: theme.palette.text.secondary, + }), + }, + }, + MuiMenuItem: { + styleOverrides: { + root: ({ theme }) => ({ + color: theme.palette.text.primary, + }), + }, + }, + MuiAppBar: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }), + }, + }, + MuiDrawer: { + styleOverrides: { + paper: ({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + }), + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: ({ theme }) => ({ + backgroundColor: theme.palette.grey[800], + color: theme.palette.getContrastText(theme.palette.grey[800]), + }), + }, + }, }, - }, }); function App(): JSX.Element { @@ -76,41 +287,28 @@ function App(): JSX.Element { - -
- - - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - - - - {/**/} + + {/* Public - bez headera i menu */} + }> + } /> + } /> + + + {/* Protected z menu bocznym i headerem */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + diff --git a/frontend/src/components/Auth/LoginForm.tsx b/frontend/src/components/Auth/LoginForm.tsx index d9d9ddb..168c136 100644 --- a/frontend/src/components/Auth/LoginForm.tsx +++ b/frontend/src/components/Auth/LoginForm.tsx @@ -43,12 +43,18 @@ export const LoginForm = ({ onLogin, onSwitchToRegister }: LoginFormProps) => { } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Błąd logowania'; setError(errorMessage); - showError(errorMessage); } finally { stopLoading(); } }; + const canLogin = () => { + return ( + email.length > 0 && + password.length > 0 + ); + }; + return ( { fullWidth variant="contained" size="large" - disabled={isLoading} + disabled={isLoading || !canLogin()} startIcon={isLoading ? : } sx={{ mt: 2, py: 1.5 }} > diff --git a/frontend/src/components/Auth/RegisterForm.tsx b/frontend/src/components/Auth/RegisterForm.tsx index fb9a4e7..8c23f13 100644 --- a/frontend/src/components/Auth/RegisterForm.tsx +++ b/frontend/src/components/Auth/RegisterForm.tsx @@ -31,14 +31,6 @@ export const RegisterForm = ({ onRegister, onSwitchToLogin }: RegisterFormProps) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); - - if (password !== passwordConfirmation) { - const errorMessage = 'Hasła nie są takie same'; - setError(errorMessage); - showError(errorMessage); - return; - } - startLoading(); try { @@ -54,6 +46,14 @@ export const RegisterForm = ({ onRegister, onSwitchToLogin }: RegisterFormProps) } }; + const canRegister = () => { + return ( + name.length > 0 && + email.length > 0 && + password.length > 0 + ); + }; + return ( setPasswordConfirmation(e.target.value)} required disabled={isLoading} + error={passwordConfirmation.length > 0 && password !== passwordConfirmation} + helperText={passwordConfirmation.length > 0 && password !== passwordConfirmation ? 'Hasła nie są zgodne' : ''} variant="outlined" /> @@ -139,7 +141,7 @@ export const RegisterForm = ({ onRegister, onSwitchToLogin }: RegisterFormProps) fullWidth variant="contained" size="large" - disabled={isLoading} + disabled={isLoading || !canRegister()} startIcon={isLoading ? : } sx={{ mt: 2, py: 1.5 }} > diff --git a/frontend/src/components/Field/DateField.tsx b/frontend/src/components/Field/DateField.tsx new file mode 100644 index 0000000..4eca0ac --- /dev/null +++ b/frontend/src/components/Field/DateField.tsx @@ -0,0 +1,106 @@ +import React, { useMemo } from 'react'; +import { FormControl, FormHelperText, InputLabel, OutlinedInput } from '@mui/material'; + +interface DateFieldProps { + value: string | Date | null | undefined; + onChange: (value: string) => void; + formatDate: (date: string | Date | null | undefined) => string; + label?: string; + helperText?: string; + error?: boolean; + errorText?: string; + required?: boolean; + fullWidth?: boolean; + disabled?: boolean; + size?: 'small' | 'medium'; + maxDate?: string | Date | (() => string | Date); + minDate?: string | Date | (() => string | Date); + id?: string; + sx?: React.CSSProperties; +} + +export const DateField: React.FC = ({ + value, + onChange, + formatDate, + label = 'Data', + helperText, + error = false, + errorText, + required = false, + fullWidth = true, + disabled = false, + size = 'medium', + maxDate, + minDate, + id, + sx, +}) => { + const inputId = id || `date-field-${Math.random().toString(36).slice(2, 11)}`; + const formattedValue = formatDate(value) || ''; + const hasValue = Boolean(value && value !== ''); + + const maxDateStr = useMemo(() => { + if (!maxDate) return undefined; + if (typeof maxDate === 'function') { + const result = maxDate(); + return result instanceof Date ? result.toISOString().split('T')[0] : result; + } + return maxDate instanceof Date ? maxDate.toISOString().split('T')[0] : maxDate; + }, [maxDate]); + + const minDateStr = useMemo(() => { + if (!minDate) return undefined; + if (typeof minDate === 'function') { + const result = minDate(); + return result instanceof Date ? result.toISOString().split('T')[0] : result; + } + return minDate instanceof Date ? minDate.toISOString().split('T')[0] : minDate; + }, [minDate]); + + const displayHelperText = error && errorText ? errorText : helperText; + + return ( + + + {label} + + onChange(e.target.value)} + label={label} + notched + disabled={disabled} + inputProps={{ + max: maxDateStr, + min: minDateStr, + }} + sx={{ + ...sx, + ...(!hasValue && !disabled && { + '& input[type="date"]': { + color: 'transparent', + '&::-webkit-datetime-edit': { color: 'transparent' }, + '&::-webkit-datetime-edit-fields-wrapper': { color: 'transparent' }, + '&::-webkit-calendar-picker-indicator': { opacity: 0.6 }, + }, + }), + }} + /> + {displayHelperText && ( + + {displayHelperText} + + )} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/Layout/DashboardLayout.tsx b/frontend/src/components/Layout/DashboardLayout.tsx new file mode 100644 index 0000000..6675a67 --- /dev/null +++ b/frontend/src/components/Layout/DashboardLayout.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom'; +import { + Box, + Drawer, + AppBar, + Toolbar, + List, + Typography, + Divider, + IconButton, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Avatar, + Menu, + MenuItem, +} from '@mui/material'; +import { + Menu as MenuIcon, + Dashboard as DashboardIcon, + AccountBalance as AccountIcon, + CreditCard as CardIcon, + Payment as PaymentIcon, + History as HistoryIcon, + Person as PersonIcon, + Logout as LogoutIcon, + ChevronLeft as ChevronLeftIcon, + AccountBalanceWallet as BalanceIcon, + Warning as WarningIcon, + NotificationImportantRounded as NotificationIcon +} from '@mui/icons-material'; +import { useAuth } from '../../context/AuthContext'; +import { Badge } from '@mui/material'; +import {NotificationBell} from "../Notification/NotificationBell"; + +const DRAWER_WIDTH = 240; + +const menuItems = [ + { text: 'Dashboard', icon: , path: '/dashboard' }, + { text: 'Saldo', icon: , path: '/balance' }, + { text: 'Konta', icon: , path: '/accounts' }, + { text: 'Karty', icon: , path: '/cards' }, + { text: 'Przelewy', icon: , path: '/transfer' }, + { text: 'FLIK', icon: , path: '/flik' }, + { text: 'Historia', icon: , path: '/transactions' }, + { text: 'Powiadomienia', icon: , path: '/notifications' }, +]; + +const getNameApp: string = import.meta.env.VITE_APP_NAME + +export const DashboardLayout = () => { + const [open, setOpen] = useState(true); + const [anchorEl, setAnchorEl] = useState(null); + const navigate = useNavigate(); + const location = useLocation(); + const { user, handleLogout } = useAuth(); + + const toggleDrawer = () => setOpen(!open); + + const handleMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleProfile = () => { + navigate('/profile'); + handleClose(); + }; + + const handleLogoutClick = async () => { + await handleLogout(); + navigate('/login'); + handleClose(); + }; + + return ( + + theme.zIndex.drawer + 1, borderRadius: 0 }}> + + + {open ? : } + + + 🏦 {getNameApp} + + + + + + + {user?.name || user?.email} + + + + + {(user?.name || user?.email || '?').charAt(0).toUpperCase()} + + + + + + + + + Profil + + + + + Wyloguj + + + + + + + + + + {menuItems.map((item) => ( + + navigate(item.path)} + > + {item.icon} + + + + ))} + + + + + navigate('/profile')} + > + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/Layout/GuestOnlyRoute.tsx b/frontend/src/components/Layout/GuestOnlyRoute.tsx index c4d6cb7..6dba6d7 100644 --- a/frontend/src/components/Layout/GuestOnlyRoute.tsx +++ b/frontend/src/components/Layout/GuestOnlyRoute.tsx @@ -1,4 +1,4 @@ -import { Navigate } from 'react-router-dom'; +import {Navigate, Outlet} from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; export const GuestOnlyRoute = ({ children }: any) => { @@ -16,5 +16,5 @@ export const GuestOnlyRoute = ({ children }: any) => { return ; } - return children; + return ; }; \ No newline at end of file diff --git a/frontend/src/components/Layout/ProtectedRoute.tsx b/frontend/src/components/Layout/ProtectedRoute.tsx index 4a0c923..8fc89b3 100644 --- a/frontend/src/components/Layout/ProtectedRoute.tsx +++ b/frontend/src/components/Layout/ProtectedRoute.tsx @@ -1,21 +1,16 @@ -import { Navigate } from 'react-router-dom'; +import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; -import { Box, CircularProgress, Typography, Container } from '@mui/material'; -import React from "react"; +import { Box, CircularProgress, Typography } from '@mui/material'; -export const ProtectedRoute = ({ children }: any) => { +export const ProtectedRoute = () => { const { user, loading } = useAuth(); if (loading) { return ( - - - - - Ładowanie... - - - + + + Ładowanie... + ); } @@ -23,5 +18,5 @@ export const ProtectedRoute = ({ children }: any) => { return ; } - return <>{children}; + return ; }; \ No newline at end of file diff --git a/frontend/src/components/Notification/NotificationBell.tsx b/frontend/src/components/Notification/NotificationBell.tsx new file mode 100644 index 0000000..959031c --- /dev/null +++ b/frontend/src/components/Notification/NotificationBell.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Badge, + IconButton, + Menu, + MenuItem, + Typography, + Box, + Divider, + Button, + ListItemText, + ListItemIcon, +} from '@mui/material'; +import { + Notifications as NotificationsIcon, + Circle as CircleIcon, + ArrowForward as ViewAllIcon, +} from '@mui/icons-material'; +import { useAuth } from '../../context/AuthContext'; +import { getNotifications, markAsRead, getUnreadCount } from '../../services/notificationService'; +import { Notification } from '../../types/types'; + +export const NotificationBell = () => { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [anchorEl, setAnchorEl] = useState(null); + const navigate = useNavigate(); + const { user } = useAuth(); + + const fetchData = useCallback(async () => { + if (!user) return; + try { + const [notifs, count] = await Promise.all([ + getNotifications(1, 5), + getUnreadCount(), + ]); + setNotifications(notifs.data); + setUnreadCount(count); + } catch (err) { + console.error('Failed to fetch notifications:', err); + } + }, [user]); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 30000); + return () => clearInterval(interval); + }, [fetchData]); + + const handleRead = async (id: string) => { + try { + await markAsRead(id); + fetchData(); + } catch (err) { + console.error('Failed to mark as read:', err); + } + }; + + const handleAction = (notification: Notification) => { + handleRead(notification.id); + + if (notification.data?.action === 'approve_guardian' && notification.data?.ward_id) { + navigate(`/guardian/approve/${notification.data.ward_id}`, { + state: { + ward: { + id: notification.data.ward_id, + name: notification.data.ward_name, + email: notification.data.ward_email, + date_of_birth: notification.data.ward_date_of_birth, + age: notification.data.ward_age, + }, + }, + }); + } + }; + + const getIconColor = (type: string): string => { + switch (type) { + case 'guardian_request': return 'warning.main'; + case 'guardian_approved': return 'success.main'; + case 'guardian_rejected': return 'error.main'; + default: return 'info.main'; + } + }; + + const open = Boolean(anchorEl); + + return ( + <> + setAnchorEl(e.currentTarget)} + color="inherit" + aria-label="notifications" + > + + + + + + setAnchorEl(null)} + slotProps={{ + paper: { + sx: { width: 380, maxHeight: 480 }, + }, + }} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + > + {notifications.length === 0 && ( + + + + )} + + {notifications.map((n) => ( + + handleAction(n)} + sx={{ + bgcolor: n.read_at ? 'transparent' : 'action.hover', + borderLeft: 3, + borderColor: getIconColor(n.type), + py: 1.5, + }} + > + + + + + {n.title} + + } + secondary={ + + + {n.body} + + + {new Date(n.created_at).toLocaleString('pl-PL')} + + + } + /> + + + {n.data?.action === 'approve_guardian' && !n.read_at && ( + + + + )} + + + + ))} + + {/* Link to full notifications page */} + { + setAnchorEl(null); + navigate('/notifications'); + }} + sx={{ justifyContent: 'center' }} + > + + + + + Zobacz wszystkie powiadomienia + + } + /> + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index aaba218..39345b9 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -58,6 +58,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const value: AuthContextType = { loading, user, + setUser, + loadUser, isAuthenticated, login: handleLogin, register: handleRegister, diff --git a/frontend/src/hooks/formatedDate.tsx b/frontend/src/hooks/formatedDate.tsx deleted file mode 100644 index 7b5f602..0000000 --- a/frontend/src/hooks/formatedDate.tsx +++ /dev/null @@ -1,27 +0,0 @@ -export const formatDate = (date: Date | string | undefined) => { - if (!date) return 'Brak danych'; - const d = new Date(date); - - return d.toLocaleString('pl-PL', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); -} - -export const formatDateShort = (date: Date | string | undefined) => { - if (!date) return 'Brak danych'; - const d = new Date(date); - - return d.toLocaleString('pl-PL', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); -} \ No newline at end of file diff --git a/frontend/src/pages/Account/AccountPage.tsx b/frontend/src/pages/Account/AccountPage.tsx new file mode 100644 index 0000000..7e28f6d --- /dev/null +++ b/frontend/src/pages/Account/AccountPage.tsx @@ -0,0 +1,303 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Typography, + Card, + CardContent, + CardActionArea, + Grid, + Chip, + Skeleton, + Alert, + Pagination, + Stack, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + MenuItem, + FormControlLabel, + Switch, + CircularProgress, +} from '@mui/material'; +import { + AccountBalance as AccountIcon, + Add as AddIcon, +} from '@mui/icons-material'; +import { getAccounts, createAccount } from '../../services/accountService'; +import type { Account, PaginationMeta } from '../../types/types'; +import {formatAccountNumber} from "../../utils/formatAccount"; + +export const AccountPage = () => { + const [accounts, setAccounts] = useState([]); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const navigate = useNavigate(); + + // Dialog state + const [openDialog, setOpenDialog] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + const [formData, setFormData] = useState({ + name: 'Konto bieżące', + currency: 'PLN', + type: 'current', + with_card: true, + }); + + const loadAccounts = async (pageNumber: number = 1) => { + setLoading(true); + setError(null); + try { + const response = await getAccounts(pageNumber); + setAccounts(response.data); + setMeta(response.meta); + } catch (err: any) { + setError(err.message || 'Błąd pobierania kont'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAccounts(page); + }, [page]); + + const handlePageChange = (_: React.ChangeEvent, value: number) => { + setPage(value); + }; + + const handleAccountClick = (id: string) => { + navigate(`/accounts/${id}`); + }; + + const handleOpenDialog = () => { + setFormData({ name: '', currency: 'PLN', type: 'current', with_card: true }); + setFormError(null); + setOpenDialog(true); + }; + + const handleCloseDialog = () => { + setOpenDialog(false); + }; + + const handleFormChange = (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + const handleSubmit = async () => { + setFormError(null); + setSubmitting(true); + + try { + await createAccount(formData); + handleCloseDialog(); + await loadAccounts(); + } catch (err: any) { + setFormError(err.message || 'Błąd tworzenia konta'); + } finally { + setSubmitting(false); + } + }; + + const getAccountTypeLabel = (type: string): string => { + return type === 'savings' ? 'Oszczędnościowe' : 'Bieżące'; + }; + + const getAccountTypeColor = (type: string): 'success' | 'primary' => { + return type === 'savings' ? 'success' : 'primary'; + }; + + return ( + + + Konta bankowe + + + + {error && ( + + {error} + + )} + + {loading ? ( + + {[1, 2].map((i) => ( + + + + ))} + + ) : accounts.length === 0 ? ( + + + + + Brak kont bankowych + + + Utwórz swoje pierwsze konto bankowe + + + + + ) : ( + <> + + {accounts.map((account) => ( + + + handleAccountClick(account.id)}> + + + + + {account.name} + + + {formatAccountNumber(account.account_number)} + + + + + + + {account.balance} {account.currency} + + + + {account.cards?.slice(0, 3).map((card) => ( + + ))} + {account.cards && account.cards.length > 3 && ( + + )} + {(!account.cards || account.cards.length === 0) && ( + + Brak kart + + )} + + + + + + ))} + + + {meta && meta.last_page > 1 && ( + + + + Wyświetlono {meta.from ?? 0}-{meta.to ?? 0} z {meta.total} kont + + + )} + + )} + + {/* Create Account Dialog */} + + Nowe konto bankowe + + {formError && ( + + {formError} + + )} + + + + PLN - Polski złoty + + + Bieżące + Oszczędnościowe + + + } + label="Utwórz kartę debetową do konta" + /> + + + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Account/AccoutViewPage.tsx b/frontend/src/pages/Account/AccoutViewPage.tsx new file mode 100644 index 0000000..95a331e --- /dev/null +++ b/frontend/src/pages/Account/AccoutViewPage.tsx @@ -0,0 +1,420 @@ +// src/pages/Accounts/AccountViewPage.tsx + +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Box, + Typography, + Card as CardPage, + CardContent, + Grid, + Chip, + Skeleton, + Alert, + Button, + Stack, + Divider, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + MenuItem, + CircularProgress, +} from '@mui/material'; +import { + ArrowBack as BackIcon, + CreditCard as CardIcon, + Add as AddIcon, + Block as BlockIcon, + LockOpen as UnblockIcon, + Delete as DeleteIcon, +} from '@mui/icons-material'; +import { + getAccount, + getCards, + createCard, + blockCard, + unblockCard, + deleteCard, getDeleteAccount, +} from '../../services/accountService'; +import type { Account, Card } from '../../types/types'; +import {formatDate, formatDateShort} from "../../utils/formatDate"; +import {formatAccountNumber} from "../../utils/formatAccount"; +import {useToast} from "../../context/ToastContext"; + +export const AccountViewPage = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { showError, showSuccess } = useToast(); + + const [account, setAccount] = useState(null); + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Add card dialog + const [openAddDialog, setOpenAddDialog] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [network, setNetwork] = useState('visa'); + const [cardType, setCardType] = useState('debit'); + + // Delete confirmation + const [deleteCardId, setDeleteCardId] = useState(null); + + // Delete account configmation + const [deleteAccountId, setDeleteAccountId] = useState(null); + + const loadData = async () => { + if (!id) return; + setLoading(true); + setError(null); + try { + const [accountData, cardsData] = await Promise.all([ + getAccount(id), + getCards(id), + ]); + setAccount(accountData); + setCards(cardsData.data || []); + } catch (err: any) { + setError(err.message || 'Błąd pobierania danych'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, [id]); + + const handleBlockCard = async (cardId: string) => { + try { + await blockCard(cardId); + await loadData(); + } catch (err: any) { + setError(err.message); + } + }; + + const handleUnblockCard = async (cardId: string) => { + try { + await unblockCard(cardId); + await loadData(); + } catch (err: any) { + setError(err.message); + } + }; + + const handleDeleteCard = async () => { + if (!deleteCardId) return; + try { + await deleteCard(deleteCardId); + setDeleteCardId(null); + await loadData(); + } catch (err: any) { + setError(err.message); + } + }; + + const handleDeleteAccount = async () => { + if (!deleteAccountId) return; + try { + await getDeleteAccount(deleteAccountId); + setDeleteAccountId(null); + navigate('/accounts'); + showSuccess('Konto bankowe zostało usunięte.'); + } catch (err: any) { + setError(err.message); + } + }; + + const handleAddCard = async () => { + if (!id) return; + setSubmitting(true); + try { + await createCard(id, network, cardType); + setOpenAddDialog(false); + setNetwork('visa'); + setCardType('debit'); + await loadData(); + } catch (err: any) { + setError(err.message); + } finally { + setSubmitting(false); + } + }; + + const getStatusColor = (status: string): 'success' | 'error' | 'warning' | 'default' => { + switch (status) { + case 'active': return 'success'; + case 'blocked': return 'error'; + case 'inactive': return 'warning'; + case 'expired': return 'default'; + default: return 'default'; + } + }; + + const getStatusLabel = (status: string): string => { + switch (status) { + case 'active': return 'Aktywna'; + case 'blocked': return 'Zablokowana'; + case 'inactive': return 'Nieaktywna'; + case 'expired': return 'Wygasła'; + default: return status; + } + }; + + const getAccountTypeLabel = (type: string): string => { + return type === 'savings' ? 'Oszczędnościowe' : 'Bieżące'; + }; + + if (loading) { + return ( + + + + + ); + } + + if (error || !account) { + return ( + + + {error || 'Nie znaleziono konta'} + + ); + } + + return ( + + + + {error && setError(null)}>{error}} + + {/* Account Details Card */} + + + + + + {account.name} + + + {formatAccountNumber(account.account_number)} + + + + + + + + Saldo + + {account.balance} {account.currency} + + + + Waluta + {account.currency} + + + Utworzono + + {formatDate(account.created_at)} + + + + setDeleteAccountId(account.id)} title="Usuń"> + + + + + + + {/* Cards Section */} + + + + Karty ({cards.length}) + + + + + + + {cards.length > 0 ? ( + + + + + Karta + Network + Ważność + Status + Akcje + + + + {cards.map((card) => ( + + + + + + + •••• {card.card_last_four} + + + {card.type === 'debit' ? 'Debetowa' : card.type} + + + + + + + + + {String(card.exp_month).padStart(2, '0')}/{card.exp_year} + + + + + + + {card.status === 'active' && ( + handleBlockCard(card.id)} title="Zablokuj"> + + + )} + {card.status === 'blocked' && ( + handleUnblockCard(card.id)} title="Odblokuj"> + + + )} + setDeleteCardId(card.id)} title="Usuń"> + + + + + + ))} + +
+
+ ) : ( + + + + + Brak kart + + + To konto nie ma przypisanych kart płatniczych + + + + + )} + + {/* Add Card Dialog */} + setOpenAddDialog(false)} maxWidth="xs" fullWidth> + Dodaj nową kartę + + + setNetwork(e.target.value)} + select + fullWidth + disabled + > + Visa + Mastercard + + setCardType(e.target.value)} + select + fullWidth + disabled + > + Debetowa + Kredytowa + Wirtualna + + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteCardId(null)}> + Usunąć kartę? + + + Ta operacja jest nieodwracalna. Karta zostanie trwale usunięta. + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteAccountId(null)}> + Usunąć konto bankowe? + + + Ta operacja jest nieodwracalna. Konto zostanie trwale usunięta. + Jeśli jest więcej od 0 na koncie, to konto nie może zostać usunięte. + + + + + + + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Account/Card/CardPage.tsx b/frontend/src/pages/Account/Card/CardPage.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/Guardian/GuardianApprovalPage.tsx b/frontend/src/pages/Guardian/GuardianApprovalPage.tsx new file mode 100644 index 0000000..12e5f5f --- /dev/null +++ b/frontend/src/pages/Guardian/GuardianApprovalPage.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import { + Container, + Typography, + Box, + Card, + CardContent, + Button, + Alert, +} from '@mui/material'; +import { + CheckCircle as ApproveIcon, + Cancel as RejectIcon, +} from '@mui/icons-material'; +import { useToast } from '../../context/ToastContext'; +import { fetchWithAuth } from '../../services/authService'; +import {ApiError} from "../../types/types"; + +interface WardData { + id: string; + name: string; + email: string; + date_of_birth?: string; + age?: number; +} + +export const GuardianApprovalPage = () => { + const { wardId } = useParams<{ wardId: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const { showSuccess, showError } = useToast(); + + const ward = location.state?.ward as WardData | undefined; + + const [processing, setProcessing] = useState(false); + + if (!ward) { + return ( + + + Brak danych użytkownika. Otwórz tę stronę z powiadomienia. + + + + ); + } + + const handleApprove = async () => { + setProcessing(true); + try { + const response = await fetchWithAuth(`/guardian/approve/${wardId}`, { + method: 'POST', + }); + + showSuccess('Opieka została potwierdzona!'); + navigate('/dashboard'); + } catch (err: any) { + showError(err?.errors?.guardian[0] || err?.message || 'Błąd zatwierdzania opieki'); + } finally { + setProcessing(false); + } + }; + + const handleReject = async () => { + setProcessing(true); + try { + const response = await fetchWithAuth(`/guardian/reject/${wardId}`, { + method: 'POST', + }); + + showSuccess('Prośba o opiekę została odrzucona.'); + navigate('/dashboard'); + } catch (err: any) { + showError(err.message || 'Błąd odrzucania opieki'); + } finally { + setProcessing(false); + } + }; + + return ( + + + + + Prośba o opiekę + + + + {ward.name} ({ward.email}) prosi Cię o potwierdzenie opieki prawnej nad swoim kontem bankowym. + + + {ward.date_of_birth && ( + + + Data urodzenia: {ward.date_of_birth} + + {ward.age !== undefined && ( + + Wiek: {ward.age} lat + + )} + + )} + + + ⚠️ Potwierdzając, bierzesz odpowiedzialność za operacje tego użytkownika. + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Notification/NotificationsPage.tsx b/frontend/src/pages/Notification/NotificationsPage.tsx new file mode 100644 index 0000000..9a85565 --- /dev/null +++ b/frontend/src/pages/Notification/NotificationsPage.tsx @@ -0,0 +1,275 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Container, + Typography, + Box, + Card, + CardContent, + Button, + IconButton, + Chip, + Divider, + List, + ListItem, + ListItemButton, + ListItemText, + Badge, + Pagination, + Skeleton, + Alert, + Tooltip, +} from '@mui/material'; +import { + NotificationsActive as UnreadIcon, + DoneAll as MarkAllReadIcon, + ArrowBack as BackIcon, + Circle as CircleIcon, + DeleteOutlineRounded as DeleteIcon, +} from '@mui/icons-material'; +import { useAuth } from '../../context/AuthContext'; +import { useToast } from '../../context/ToastContext'; +import { getNotifications, markAsRead, markAllAsRead } from '../../services/notificationService'; +import { Notification } from '../../types/types'; +import { formatRelative } from '../../utils/formatDate'; + +export const NotificationsPage = () => { + const navigate = useNavigate(); + const { showSuccess, showError } = useToast(); + + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const perPage = 50; + + const fetchNotifications = useCallback(async () => { + setLoading(true); + try { + const response = await getNotifications(page, perPage); + setNotifications(response.data); + setTotalPages(response.meta.last_page || 1); + } catch (err: any) { + showError(err.message || 'Błąd pobierania powiadomień'); + } finally { + setLoading(false); + } + }, [page, perPage]); + + useEffect(() => { + fetchNotifications(); + }, [fetchNotifications]); + + const handleMarkAsRead = async (id: string, e?: React.MouseEvent) => { + e?.stopPropagation(); + try { + await markAsRead(id); + setNotifications(prev => prev.map(n => + n.id === id ? { ...n, read_at: new Date().toISOString() } : n + )); + showSuccess('Oznaczono jako przeczytane'); + } catch (err: any) { + showError(err.message); + } + }; + + const handleMarkAllAsRead = async () => { + try { + await markAllAsRead(); + setNotifications(prev => prev.map(n => ({ ...n, read_at: new Date().toISOString() }))); + showSuccess('Wszystkie oznaczone jako przeczytane'); + } catch (err: any) { + showError(err.message); + } + }; + + const handleNotificationClick = (notification: Notification) => { + if (!notification.read_at) { + handleMarkAsRead(notification.id); + } + + // Nawigacja w zależności od typu + switch (notification.data?.action) { + case 'approve_guardian': + if (notification.data.ward_id) { + navigate(`/guardian/approve/${notification.data.ward_id}`, { + state: { + ward: { + id: notification.data.ward_id, + name: notification.data.ward_name, + email: notification.data.ward_email, + date_of_birth: notification.data.ward_date_of_birth, + age: notification.data.ward_age, + }, + }, + }); + } + break; + case 'view_account': + navigate('/accounts'); + break; + default: + // Nic nie rób — tylko oznacz jako przeczytane + break; + } + }; + + const getTypeColor = (type: string) => { + switch (type) { + case 'guardian_request': return 'warning'; + case 'guardian_approved': return 'success'; + case 'guardian_rejected': return 'error'; + case 'guardian_request_sent': return 'info'; + default: return 'default'; + } + }; + + const getTypeLabel = (type: string) => { + switch (type) { + case 'guardian_request': return 'Prośba o opiekę'; + case 'guardian_approved': return 'Opiekun zatwierdzony'; + case 'guardian_rejected': return 'Opiekun odrzucony'; + case 'guardian_request_sent': return 'Prośba wysłana'; + default: return 'Powiadomienie'; + } + }; + + const unreadCount = notifications.filter(n => !n.read_at).length; + + return ( + + {/* Header */} + + navigate(-1)}> + + + + Powiadomienia + + {unreadCount > 0 && ( + + + + )} + + + {/* Unread badge */} + {unreadCount > 0 && ( + } + sx={{ mb: 2 }} + > + Masz {unreadCount} nieprzeczytanych powiadomień + + )} + + {/* Lista powiadomień */} + + {loading ? ( + + {[...Array(5)].map((_, i) => ( + + ))} + + ) : notifications.length === 0 ? ( + + + Brak powiadomień + + + ) : ( + + {notifications.map((notification, index) => ( + + + handleMarkAsRead(notification.id, e)} + size="small" + > + + + + ) + } + > + handleNotificationClick(notification)} + sx={{ + bgcolor: notification.read_at ? 'transparent' : 'action.hover', + borderLeft: 4, + borderColor: `${getTypeColor(notification.type)}.main`, + py: 2, + pr: 6, // miejsce na secondaryAction + }} + > + + + {notification.title} + + + {!notification.read_at && ( + + )} + + } + secondary={ + + + {notification.body} + + + {formatRelative(notification.created_at)} + + + } + /> + + + {index < notifications.length - 1 && } +
+ ))} + + )} + + + {/* Pagination */} + {totalPages > 1 && ( + + setPage(value)} + color="primary" + /> + + )} + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/User/BalancePage.tsx b/frontend/src/pages/User/BalancePage.tsx index 1116f93..44f3dd7 100644 --- a/frontend/src/pages/User/BalancePage.tsx +++ b/frontend/src/pages/User/BalancePage.tsx @@ -3,7 +3,6 @@ import { useAuth } from '../../context/AuthContext'; import { fetchWithAuth } from '../../services/authService'; import { Container, - Paper, Typography, Box, Grid, @@ -11,15 +10,60 @@ import { CardContent, Alert, CircularProgress, - Divider + Divider, + Chip, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Tooltip } from '@mui/material'; -import { AccountBalance, AccountBalanceWallet, Receipt, Settings } from '@mui/icons-material'; +import { + AccountBalanceWallet, + AccountBalance, + Receipt, + InfoOutlined, + Euro, + AttachMoney, + CurrencyExchange +} from '@mui/icons-material'; + +interface CurrencyBalance { + currency: string; + balance: string; +} + +interface BalanceResponse { + balances: CurrencyBalance[]; + total: string; +} + +const getCurrencyIcon = (currency: string) => { + switch (currency) { + case 'EURO': return ; + case 'USD': return ; + case 'PLN': return ; + default: return ; + } +}; + +const getCurrencyColor = (currency: string) => { + switch (currency) { + case 'PLN': return 'success'; + case 'EURO': return 'info'; + case 'USD': return 'warning'; + default: return 'default'; + } +}; export const BalancePage = () => { const { user } = useAuth(); - const [balance, setBalance] = useState(null); + const [balance, setBalance] = useState(); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); useEffect(() => { fetchBalance(); @@ -27,14 +71,14 @@ export const BalancePage = () => { const fetchBalance = async () => { try { - const response = await fetchWithAuth('/user/balance'); + const response = await fetchWithAuth('/accounts/balance'); const data = await response.json(); if (!response.ok) { throw new Error(data.message || 'Błąd pobierania salda'); } - setBalance(data.balance); + setBalance(data); } catch (err: any) { setError(err.message || 'Nie udało się pobrać salda'); console.error(err); @@ -58,6 +102,7 @@ export const BalancePage = () => { return ( + {/* Nagłówek */} Witaj, {user?.name || user?.email}! @@ -68,9 +113,10 @@ export const BalancePage = () => { - {/* Saldo */} - - + {/* Lewa kolumna - Saldo i lista walut */} + + {/* Główne saldo PLN */} + @@ -97,20 +143,129 @@ export const BalancePage = () => { Twoje saldo wynosi: - {balance !== null ? `${balance} PLN` : '---'} + {balance?.total || '0.00'} PLN + + + + + Tylko waluta PLN + + + )} + + {/* Lista walut */} + + + + + + Salda na kontach + + + + + + {!balance?.balances || balance.balances.length === 0 ? ( + Brak dostępnych kont + ) : ( + + + + + Waluta + Saldo + Status + + + + {balance.balances.map((item, index) => ( + + + + + {getCurrencyIcon(item.currency)} + + + {item.currency} + + + + + + {item.balance} + + + + : undefined} + label={item.currency === 'PLN' ? 'Podsumowanie' : 'Wymaga przeliczenia'} + size="small" + color={getCurrencyColor(item.currency)} + variant={item.currency === 'PLN' ? 'filled' : 'outlined'} + sx={{ fontWeight: 'bold' }} + /> + + + ))} + +
+
+ )} +
+
- {/* Menu boczne */} - + {/* Prawa kolumna - Menu */} + - + - + Moje konto @@ -120,9 +275,16 @@ export const BalancePage = () => { - + - + Moje transakcje @@ -131,18 +293,6 @@ export const BalancePage = () => { - - - - - - Ustawienia - - Konfiguracja konta - - - - diff --git a/frontend/src/pages/User/ProfilePage.tsx b/frontend/src/pages/User/ProfilePage.tsx index 91542c5..10b6e6c 100644 --- a/frontend/src/pages/User/ProfilePage.tsx +++ b/frontend/src/pages/User/ProfilePage.tsx @@ -1,27 +1,35 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { useAuth } from '../../context/AuthContext'; import { Container, Typography, Box, - Grid, Alert, - Divider, TextField, Button + Divider, + TextField, + Button, + Card, + InputLabel, + Stack, + Paper, } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; -import {LoadingSpinner} from "../../components/Assets/Svg/LoadingSpinner"; -import {useToast} from "../../context/ToastContext"; -import {useLoading} from "../../context/LoadingContext"; -import {formatDate, formatDateShort} from "../../hooks/formatedDate"; -import {updateUserProfile} from "../../services/authService"; -import {ProfileUpdateData} from "../../types/types"; +import { LoadingSpinner } from "../../components/Assets/Svg/LoadingSpinner"; +import { useToast } from "../../context/ToastContext"; +import { useLoading } from "../../context/LoadingContext"; +import { calculateAge, formatDateInput, formatDateTime, isAdult, today } from "../../utils/formatDate"; +import { updateUserProfile } from "../../services/authService"; +import { ProfileUpdateData } from "../../types/types"; +import { DateField } from "../../components/Field/DateField"; export const ProfilePage = () => { - const { user } = useAuth(); + const { user, loadUser } = useAuth(); const [error, setError] = useState(''); const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [password, setPassword] = useState(''); + const [dateOfBirth, setDateOfBirth] = useState(''); + const [guardianEmail, setGuardianEmail] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [oldPassword, setOldPassword] = useState(''); const { showError, showSuccess } = useToast(); @@ -31,11 +39,65 @@ export const ProfilePage = () => { if (user) { setName(user.name || ''); setEmail(user.email || ''); + setDateOfBirth(user.date_of_birth || ''); } }, [user]); + const handleRemoveGuardian = async () => { + setError(''); + startLoading(); + + const payload: ProfileUpdateData = { + name: user?.name || '', + email: user?.email || '', + guardian_delete: true, + }; + + try { + await updateUserProfile(payload); + showSuccess('Opiekun został odłączony.'); + loadUser(); + } catch (err: any) { + const errorMessage = err.message || 'Błąd odłączania opiekuna'; + setError(errorMessage); + showError(errorMessage); + } finally { + stopLoading(); + } + }; + + const handleSetGuardian = async () => { + setError(''); + startLoading(); + + if (user?.guardian?.id) { + showError('Twoje konto zostało przypisane do opiekuna, nie możesz jeszcze raz wysyłać informacji do opiekuna.'); + return; + } + + const payload: ProfileUpdateData = { + guardian_email: guardianEmail || undefined, + }; + + try { + await updateUserProfile(payload); + + setGuardianEmail(''); + + showSuccess('Powiadomienie zostało wysłane do opiekuna w celu potwierdzenia.'); + loadUser(); + } catch (err: any) { + const errorMessage = err.errors.guardian_email[0] || err.message || 'Błąd odłączania opiekuna'; + setError(errorMessage); + showError(errorMessage); + } finally { + stopLoading(); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setError(''); startLoading(); @@ -45,6 +107,7 @@ export const ProfilePage = () => { password: password || undefined, password_confirmation: confirmPassword || undefined, old_password: oldPassword || undefined, + date_of_birth: !user?.date_of_birth && dateOfBirth ? dateOfBirth : undefined, }; try { @@ -55,6 +118,7 @@ export const ProfilePage = () => { setOldPassword(''); showSuccess('Profil zaktualizowany pomyślnie!'); + loadUser(); } catch (err: any) { const errorMessage = err.message || 'Błąd aktualizacji profilu'; setError(errorMessage); @@ -68,112 +132,271 @@ export const ProfilePage = () => { return ( (name !== user?.name) || (email !== user?.email) || - password.length > 0 + (dateOfBirth !== user?.date_of_birth) || + (guardianEmail !== user?.guardian?.email) || + password.length > 0 || + confirmPassword.length > 0 || + oldPassword.length > 0 ); }; + const age = calculateAge(dateOfBirth); + const adult = isAdult(dateOfBirth); + const hasGuardian = Boolean(user?.guardian?.id); + return ( - - - + + + + Profil użytkownika + + Zmiana danych osobowych i ustawień konta. - - {error && ( - - {error} - - )} - - - setName(e.target.value)} - required - disabled={isLoading} - variant="outlined" - /> - - setEmail(e.target.value)} - required - disabled={isLoading} - variant="outlined" - /> - - - - - - Zmiana hasła (wypełnij tylko jeśli chcesz zmienić) - - - setOldPassword(e.target.value)} - disabled={isLoading} - variant="outlined" - /> - - setPassword(e.target.value)} - disabled={isLoading} - variant="outlined" - /> - - setConfirmPassword(e.target.value)} - disabled={isLoading || !password} - variant="outlined" - error={confirmPassword.length > 0 && password !== confirmPassword} - helperText={confirmPassword.length > 0 && password !== confirmPassword ? 'Hasła nie są zgodne' : ''} - /> - - - + + + {!user?.email_verified_at && ( + + Twój adres email nie został zweryfikowany. Sprawdź skrzynkę odbiorczą. + + )} + + {!user?.date_of_birth && ( + + Nie wpisano daty urodzenia. Bez tego nie można kontynuować działania w systemie. + Jeżeli nie masz 18 lat, poproś opiekuna o założenie konta i podpięcie Cię do swojego konta. + + )} + + {error && ( + + {error} + + )} + + + + Dane osobowe + + + setName(e.target.value)} + required + disabled={isLoading} + variant="outlined" + /> + + setEmail(e.target.value)} + required + disabled={isLoading} + variant="outlined" + /> + + setDateOfBirth(value)} + formatDate={formatDateInput} + label="Data urodzenia" + maxDate={today()} + required + disabled={!!user?.date_of_birth} + /> + + {age !== null && !adult && ( + + {adult + ? '' + : `Wiek: ${age} lat (niepełnoletni)` + } + + )} + + {dateOfBirth && ( + + {adult && hasGuardian && ( + <> + + Opiekun prawny + - + + + + {user?.guardian?.name} + + + {user?.guardian?.email} + + + Zatwierdzono: {formatDateTime(user?.guardian?.guardian_approved_at)} + + + + + + )} + + {!adult && ( + <> + + Opiekun prawny + + + {!hasGuardian && ( + + Masz mniej niż 18 lat. Aby korzystać z konta, musisz mieć przypisanego opiekuna prawnego. + + )} + + {hasGuardian ? ( + + + + {user?.guardian?.name} + + + {user?.guardian?.email} + + + Zatwierdzono: {formatDateTime(user?.guardian?.guardian_approved_at)} + + + {!user?.guardian?.guardian_approved_at && ( + + Oczekuje na zatwierdzenie przez opiekuna. + + )} + + ) : ( + <> + + Wpisz email opiekuna, który otrzyma zaproszenie: + + setGuardianEmail(e.target.value)} + helperText="Opiekun musi mieć ukończone 18 lat i posiadać konto w banku." + /> + + + + )} + + )} + + )} + + + + + Informacje o koncie + + + + + Data ostatniej zmiany + + + {user?.updated_at ? formatDateTime(user.updated_at) : 'Brak danych'} + + + + + + Data potwierdzenia e-mail + + + {user?.email_verified_at ? formatDateTime(user.email_verified_at) : 'Brak danych'} + + + + + + + Zmiana hasła + + + setOldPassword(e.target.value)} + disabled={isLoading} + variant="outlined" + /> + + setPassword(e.target.value)} + disabled={isLoading} + variant="outlined" + /> + + setConfirmPassword(e.target.value)} + disabled={isLoading || !password} + variant="outlined" + error={confirmPassword.length > 0 && password !== confirmPassword} + helperText={confirmPassword.length > 0 && password !== confirmPassword ? 'Hasła nie są zgodne' : ''} + /> + + + +
+ ); }; \ No newline at end of file diff --git a/frontend/src/services/accountService.ts b/frontend/src/services/accountService.ts new file mode 100644 index 0000000..8b4ed3a --- /dev/null +++ b/frontend/src/services/accountService.ts @@ -0,0 +1,118 @@ +import {fetchWithAuth} from "./authService"; +import {Account, ApiError, Card, PaginationResponse} from "../types/types"; + +export const getAccounts = async (page: number = 1, perPage: number = 20): Promise> => { + const response = await fetchWithAuth(`/accounts?page=${page}&per_page=${perPage}`, { + method: 'GET', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd pobierania danych o kontach bankowych.'); + } + + return await response.json(); +}; + +export const createAccount = async (data: { + name: string; + currency?: string; + type?: string; + with_card?: boolean; +}): Promise => { + const response = await fetchWithAuth('/accounts', { + method: 'POST', + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd tworzenia konta'); + } + + return await response.json(); +}; + +export const getAccount = async (id: string): Promise => { + const response = await fetchWithAuth(`/accounts/${id}`, { + method: 'GET', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd pobierania konta'); + } + + return (await response.json()).data; +}; + +export const getDeleteAccount = async (id: string) => { + const response = await fetchWithAuth(`/accounts/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd usuwania konta'); + } + + return await response.json(); +}; + +// Card +export const getCards = async (accountId: string): Promise> => { + const response = await fetchWithAuth(`/accounts/${accountId}/cards`, { + method: 'GET', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd pobierania kart'); + } + + return await response.json(); +}; + +export const createCard = async (accountId: string, network: string, type: string): Promise => { + const response = await fetchWithAuth(`/accounts/${accountId}/cards`, { + method: 'POST', + body: JSON.stringify({ network, type }), + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd tworzenia karty'); + } + + return (await response.json()).data; +}; + +export const blockCard = async (cardId: string): Promise => { + const response = await fetchWithAuth(`/cards/${cardId}/block`, { + method: 'PATCH', + }); + + if (!response.ok) { + throw new Error('Nie udało się zablokować karty'); + } +}; + +export const unblockCard = async (cardId: string): Promise => { + const response = await fetchWithAuth(`/cards/${cardId}/unblock`, { + method: 'PATCH', + }); + + if (!response.ok) { + throw new Error('Nie udało się odblokować karty'); + } +}; + +export const deleteCard = async (cardId: string): Promise => { + const response = await fetchWithAuth(`/cards/${cardId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Nie udało się usunąć karty'); + } +}; \ No newline at end of file diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index 37a43f9..3084ed7 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -34,6 +34,7 @@ export const loginWithPassword = async (email: string, password: string): Promis if (!response.ok) { const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd logowania'); } @@ -85,7 +86,9 @@ export const getUserInfo = async (): Promise => { }); if (!response.ok) { - throw new Error('Nie udało się pobrać danych użytkownika'); + const error: ApiError = await response.json(); + + throw new Error(error.message || 'Nie udało się pobrać danych użytkownika'); } return await response.json(); @@ -189,6 +192,11 @@ export const fetchWithAuth = async (url: string, options: FetchOptions = {}): Pr } } + if (!response.ok) { + const error: ApiError = await response.json(); + throw error; + } + return response; }; diff --git a/frontend/src/services/guardianService.ts b/frontend/src/services/guardianService.ts new file mode 100644 index 0000000..20fca05 --- /dev/null +++ b/frontend/src/services/guardianService.ts @@ -0,0 +1,24 @@ +import { fetchWithAuth } from './authService'; +import { ApiError } from '../types/types'; + +export const approveGuardian = async (wardId: string): Promise => { + const response = await fetchWithAuth(`/guardian/approve/${wardId}`, { + method: 'POST', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd zatwierdzania opieki.'); + } +}; + +export const rejectGuardian = async (wardId: string): Promise => { + const response = await fetchWithAuth(`/guardian/reject/${wardId}`, { + method: 'POST', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd odrzucania opieki.'); + } +}; \ No newline at end of file diff --git a/frontend/src/services/notificationService.tsx b/frontend/src/services/notificationService.tsx new file mode 100644 index 0000000..66e6a11 --- /dev/null +++ b/frontend/src/services/notificationService.tsx @@ -0,0 +1,54 @@ +import { fetchWithAuth } from "./authService"; +import {ApiError, Notification, NotificationCount, PaginationResponse} from "../types/types"; + +export const getNotifications = async ( + page: number = 1, + perPage: number = 10 +): Promise> => { + const response = await fetchWithAuth(`/notifications?page=${page}&per_page=${perPage}`, { + method: 'GET', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd pobierania powiadomień.'); + } + + return await response.json(); +}; + +export const getUnreadCount = async (): Promise => { + const response = await fetchWithAuth('/notifications/unread-count', { + method: 'GET', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd pobierania liczby nieprzeczytanych powiadomień.'); + } + + const data: NotificationCount = await response.json(); + return data.count; +}; + +export const markAsRead = async (id: string): Promise => { + const response = await fetchWithAuth(`/notifications/${id}/read`, { + method: 'PATCH', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd oznaczania powiadomienia jako przeczytane.'); + } +}; + +export const markAllAsRead = async (): Promise => { + const response = await fetchWithAuth('/notifications/read-all', { + method: 'PATCH', + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || 'Błąd oznaczania wszystkich powiadomień jako przeczytane.'); + } +}; \ No newline at end of file diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 44235a4..0b9a2a7 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -2,10 +2,51 @@ export interface User { id?: number; name?: string; - email: string; + email?: string; email_verified_at?: string; + date_of_birth?: string; created_at?: string; updated_at?: string; + guardian?: Guardian; +} + +export interface Guardian { + id?: string; + name?: string; + email?: string; + guardian_approved_at?: string; +} + +// Pagination +export interface PaginationLink { + url: string | null; + label: string; + page: number | null; + active: boolean; +} + +export interface PaginationMeta { + current_page: number; + from: number | null; + last_page: number; + links: PaginationLink[]; + path: string; + per_page: number; + to: number | null; + total: number; +} + +export interface PaginationLinks { + first: string; + last: string; + prev: string | null; + next: string | null; +} + +export interface PaginationResponse { + data: T[]; + links: PaginationLinks; + meta: PaginationMeta; } // Authentication types @@ -41,7 +82,9 @@ export interface ApiResponse { } export interface ApiError { - message: string; + status?: string, + message?: string; + code?: number, errors?: Record; } @@ -52,6 +95,9 @@ export interface ProfileUpdateData { password?: string; password_confirmation?: string; old_password?: string; + date_of_birth?: string; + guardian_email?: string; + guardian_delete?: boolean; } export interface ProfileUpdateResponse { @@ -107,6 +153,8 @@ export interface ToastData { export interface AuthContextType { loading?: boolean; user: User | null; + setUser: (user: User | null) => void; + loadUser: () => void; login: (email: string, password: string) => Promise; register: (name: string, email: string, password: string) => Promise; logout: () => void; @@ -131,3 +179,64 @@ export interface LoadingContextType { startLoading: () => void; stopLoading: () => void; } + +// Accounts +export interface Account { + id: string; + name: string; + account_number: string; + balance: number; + currency: string; + type: string; + cards: Card[]; + created_at: string; + updated_at: string; +} + +export interface Card { + id: string; + card_number: string; + card_last_four: string; + exp_month: number; + exp_year: number; + network: string; + type: string; + status: string; + created_at: string; + updated_at: string; +} + +export interface Transaction { + id: string; + amount: number; + description: string; + type: string; + status: string; + created_at: string; +} + +// Notification +export interface Notification { + id: string; + type: string; + title: string; + body: string; + data: { + ward_id?: string; + ward_name?: string; + guardian_id?: string; + guardian_name?: string; + action?: string; + action_url?: string; + status?: string; + ward_date_of_birth?: string; + ward_age?: number; + ward_email?: string; + }; + read_at: string | null; + created_at: string; +} + +export interface NotificationCount { + count: number; +} \ No newline at end of file diff --git a/frontend/src/utils/formatAccount.ts b/frontend/src/utils/formatAccount.ts new file mode 100644 index 0000000..7a3228f --- /dev/null +++ b/frontend/src/utils/formatAccount.ts @@ -0,0 +1,43 @@ +/** + * Format IBAN/NRB account number with spaces every 4 digits. + * "98123456780000000012345678" → "98 1234 5678 0000 0000 1234 5678" + */ +export const formatAccountNumber = (number: string): string => { + const cleaned = number.replace(/\s/g, ''); + return cleaned.replace(/(.{4})/g, '$1 ').trim(); +}; + +/** + * Mask account number - show only first 2 and last 4 digits. + * "98123456780000000012345678" → "98 **** **** **** **** 5678" + */ +export const maskAccountNumber = (number: string): string => { + const cleaned = number.replace(/\s/g, ''); + const first = cleaned.slice(0, 2); + const last = cleaned.slice(-4); + return `${first} **** **** **** **** ${last}`; +}; + +/** + * Get last 4 digits of account number. + * "98123456780000000012345678" → "5678" + */ +export const lastFourAccount = (number: string): string => { + return number.replace(/\s/g, '').slice(-4); +}; + +/** + * Convert 26-digit NRB to IBAN (add "PL" prefix). + * "98123456780000000012345678" → "PL98123456780000000012345678" + */ +export const toIBAN = (number: string): string => { + return 'PL' + number.replace(/\s/g, ''); +}; + +/** + * Check if account number has valid length (26 digits). + */ +export const isValidAccountNumber = (number: string): boolean => { + const cleaned = number.replace(/\s/g, ''); + return /^\d{26}$/.test(cleaned); +}; \ No newline at end of file diff --git a/frontend/src/utils/formatCard.ts b/frontend/src/utils/formatCard.ts new file mode 100644 index 0000000..53f7e10 --- /dev/null +++ b/frontend/src/utils/formatCard.ts @@ -0,0 +1,68 @@ +/** + * Format card number with spaces every 4 digits. + * "4532123456789012" → "4532 1234 5678 9012" + */ +export const formatCardNumber = (number: string): string => { + const cleaned = number.replace(/\s/g, ''); + return cleaned.replace(/(.{4})/g, '$1 ').trim(); +}; + +/** + * Mask card number - show first 4 and last 4 digits. + * "4532123456789012" → "4532 **** **** 9012" + */ +export const maskCardNumber = (number: string): string => { + const cleaned = number.replace(/\s/g, ''); + const first = cleaned.slice(0, 4); + const last = cleaned.slice(-4); + return `${first} **** **** ${last}`; +}; + +/** + * Get last 4 digits of card number. + * "4532123456789012" → "9012" + */ +export const lastFour = (number: string): string => { + return number.replace(/\s/g, '').slice(-4); +}; + +/** + * Format expiry date. + * { exp_month: 5, exp_year: 2030 } → "05/2030" + * { exp_month: 12, exp_year: 2024 } → "12/2024" + */ +export const formatExpiry = (month: number, year: number): string => { + return `${String(month).padStart(2, '0')}/${year}`; +}; + +/** + * Check if card is expired. + */ +export const isCardExpired = (month: number, year: number): boolean => { + const now = new Date(); + const expiry = new Date(year, month, 0); // last day of the month + return now > expiry; +}; + +/** + * Detect card network from number prefix. + * "4532..." → "Visa" + * "5214..." → "Mastercard" + */ +export const detectNetwork = (number: string): string => { + const cleaned = number.replace(/\s/g, ''); + if (cleaned.startsWith('4')) return 'Visa'; + if (/^5[1-5]/.test(cleaned)) return 'Mastercard'; + return 'Unknown'; +}; + +/** + * Get network color for UI. + */ +export const getNetworkColor = (network: string): string => { + switch (network.toLowerCase()) { + case 'visa': return '#1a1f71'; + case 'mastercard': return '#eb001b'; + default: return '#666'; + } +}; \ No newline at end of file diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts new file mode 100644 index 0000000..5baccc3 --- /dev/null +++ b/frontend/src/utils/formatDate.ts @@ -0,0 +1,93 @@ +export const formatDate = (date: Date | string | undefined): string => { + if (!date) return 'Brak danych'; + const d = new Date(date); + return d.toLocaleDateString('pl-PL', { year: 'numeric', month: 'long', day: 'numeric' }); +}; + +export const formatDateShort = (date: Date | string | undefined): string => { + if (!date) return 'Brak danych'; + const d = new Date(date); + return d.toLocaleDateString('pl-PL', { year: 'numeric', month: '2-digit', day: '2-digit' }); +}; + +export const formatDateTime = (date: Date | string | undefined): string => { + if (!date) return 'Brak danych'; + const d = new Date(date); + return d.toLocaleString('pl-PL', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +}; + +export const formatDateTimeShort = (date: Date | string | undefined): string => { + if (!date) return 'Brak danych'; + const d = new Date(date); + return d.toLocaleString('pl-PL', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); +}; + +export const formatDateTimeWithSeconds = (date: Date | string | undefined): string => { + if (!date) return 'Brak danych'; + const d = new Date(date); + return d.toLocaleString('pl-PL', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }); +}; + +export const formatTime = (date: Date | string | undefined): string => { + if (!date) return '--:--'; + const d = new Date(date); + return d.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' }); +}; + +export const formatRelative = (date: Date | string | undefined): string => { + if (!date) return 'Brak danych'; + const d = new Date(date); + const now = new Date(); + const diff = now.getTime() - d.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return 'Przed chwilą'; + if (minutes < 60) return `${minutes} min. temu`; + if (hours < 24) return `${hours} godz. temu`; + if (days < 7) return `${days} dni temu`; + return formatDate(d); +}; + +export const formatDateInput = (date: Date | string | null | undefined): string => { + if (!date) return ''; + const d = typeof date === 'string' ? new Date(date) : date; + if (isNaN(d.getTime())) return ''; + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +export const today = (): string => new Date().toISOString().split('T')[0]; + +export const yearsAgo = (years: number): string => { + const d = new Date(); + d.setFullYear(d.getFullYear() - years); + return d.toISOString().split('T')[0]; +}; + +export const yearsFromNow = (years: number): string => { + const d = new Date(); + d.setFullYear(d.getFullYear() + years); + return d.toISOString().split('T')[0]; +}; + +export const calculateAge = (dateOfBirth: Date | string | undefined): number | null => { + if (!dateOfBirth) return null; + const birth = new Date(dateOfBirth); + const now = new Date(); + let age = now.getFullYear() - birth.getFullYear(); + const monthDiff = now.getMonth() - birth.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) { + age--; + } + return age; +}; + +export const isAdult = (dateOfBirth: Date | string | undefined): boolean => { + const age = calculateAge(dateOfBirth); + return age !== null && age >= 18; +}; \ No newline at end of file diff --git a/frontend/src/utils/formatMoney.ts b/frontend/src/utils/formatMoney.ts new file mode 100644 index 0000000..d4bd55b --- /dev/null +++ b/frontend/src/utils/formatMoney.ts @@ -0,0 +1,106 @@ +/** + * Convert cents to zloty string. + * 499 → "4.99" + * 0 → "0.00" + */ +export const centsToZloty = (cents: number): string => { + return (cents / 100).toFixed(2); +}; + +/** + * Convert zloty to cents. + * 4.99 → 499 + * 0.01 → 1 + */ +export const zlotyToCents = (zloty: number): number => { + return Math.round(zloty * 100); +}; + +/** + * Format amount in PLN with currency symbol. + * 499 → "4,99 PLN" + * 250000 → "2 500,00 PLN" + */ +export const formatMoney = (cents: number, currency: string = 'PLN'): string => { + return new Intl.NumberFormat('pl-PL', { + style: 'currency', + currency: currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(cents / 100); +}; + +/** + * Format amount without currency symbol. + * 499 → "4,99" + * 250000 → "2 500,00" + */ +export const formatAmount = (cents: number): string => { + return new Intl.NumberFormat('pl-PL', { + style: 'decimal', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(cents / 100); +}; + +/** + * Format amount with space separator, no currency. + * 499 → "4.99" (API format, kropka zamiast przecinka) + */ +export const formatAmountForApi = (cents: number): string => { + return (cents / 100).toFixed(2); +}; + +/** + * Parse formatted money string to cents. + * "4,99 PLN" → 499 + * "2 500,00" → 250000 + */ +export const parseMoneyToCents = (formatted: string): number => { + const cleaned = formatted + .replace(/[^\d,.-]/g, '') + .replace(',', '.'); + return Math.round(parseFloat(cleaned) * 100); +}; + +/** + * Format balance with color indicator. + * +499 → "+4,99" (green) + * -499 → "-4,99" (red) + * 0 → "0,00" + */ +export const formatBalance = (cents: number): { text: string; isPositive: boolean; isZero: boolean } => { + const amount = cents / 100; + return { + text: new Intl.NumberFormat('pl-PL', { + style: 'decimal', + signDisplay: 'always', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount), + isPositive: cents > 0, + isZero: cents === 0, + }; +}; + +/** + * Short format for large amounts. + * 100000 → "1 000,00" + * 1000000 → "10 000,00" + * For bigger: use formatMoney + */ +export const formatMoneyShort = (cents: number, currency: string = 'PLN'): string => { + const amount = cents / 100; + + if (Math.abs(amount) >= 1_000_000) { + return new Intl.NumberFormat('pl-PL', { + style: 'currency', + currency: currency, + notation: 'compact', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } + + return formatMoney(cents, currency); +}; \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 2512c6c..9a6850d 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -7,7 +7,8 @@ "skipLibCheck": true, "noEmit": true, "strict": true, - "isolatedModules": true + "isolatedModules": true, + "composite": true }, "include": ["vite.config.ts"] } \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c9c895f..5a8dbb1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,7 +13,12 @@ export default defineConfig({ }, '/oauth': { target: 'http://backend:80', - changeOrigin: true, + changeOrigin: false, + secure: false, + }, + '/login': { + target: 'http://backend:80', + changeOrigin: false, secure: false, } }