From e30e3d799d4d0c330a7b7fb2c904c11d5636583e Mon Sep 17 00:00:00 2001 From: Om Lanke Date: Mon, 20 Apr 2026 22:14:57 +0530 Subject: [PATCH 1/6] refactor ployglot --- AGENT.md | 2 +- ticketflow/.env.example | 104 +- ticketflow/.gitignore | 126 +- ticketflow/Makefile | 267 + ticketflow/README.md | 568 +- ticketflow/docker-compose.dev.yml | 387 +- ticketflow/docker-compose.yml | 276 +- .../docs/adr/001-polyglot-architecture.md | 87 + .../docs/adr/002-kafka-over-rabbitmq.md | 95 + ticketflow/docs/adr/003-async-saga-pattern.md | 117 + .../docs/adr/004-database-per-service.md | 85 + ticketflow/docs/api-reference.md | 717 ++ ticketflow/docs/architecture.md | 358 + ticketflow/docs/deployment.md | 441 + ticketflow/docs/development.md | 461 ++ ticketflow/docs/event-catalog.md | 579 ++ ticketflow/docs/observability.md | 439 + ticketflow/docs/services/booking-service.md | 179 + ticketflow/docs/services/event-service.md | 172 + ticketflow/docs/services/gateway.md | 162 + ticketflow/docs/services/inventory-service.md | 190 + .../docs/services/notification-service.md | 215 + ticketflow/docs/services/payment-service.md | 190 + ticketflow/docs/services/user-service.md | 163 + ticketflow/frontend/Dockerfile | 26 + ticketflow/frontend/nginx.conf | 27 + ticketflow/frontend/src/lib/api.ts | 42 +- ticketflow/frontend/src/pages/EventPage.tsx | 54 +- ticketflow/gateway/Dockerfile | 15 +- ticketflow/gateway/jest.config.js | 8 - ticketflow/gateway/package.json | 28 +- ticketflow/gateway/src/app.ts | 158 +- ticketflow/gateway/src/config.ts | 16 + ticketflow/gateway/src/index.ts | 11 +- ticketflow/gateway/src/middleware/auth.ts | 37 +- .../gateway/src/middleware/rateLimiter.ts | 38 +- ticketflow/gateway/src/proxy.ts | 47 + ticketflow/gateway/src/routes.ts | 31 - ticketflow/gateway/tsconfig.json | 16 +- ticketflow/infra/kafka/create-topics.sh | 77 + ticketflow/infra/mongo/init.js | 29 + ticketflow/infra/postgres/init.sql | 23 +- .../k8s/apps/booking-service/deployment.yaml | 82 + ticketflow/k8s/apps/booking-service/hpa.yaml | 27 + .../k8s/apps/event-service/deployment.yaml | 82 + ticketflow/k8s/apps/event-service/hpa.yaml | 27 + ticketflow/k8s/apps/frontend/deployment.yaml | 68 + ticketflow/k8s/apps/gateway/deployment.yaml | 107 + ticketflow/k8s/apps/gateway/hpa.yaml | 27 + .../apps/inventory-service/deployment.yaml | 83 + .../k8s/apps/inventory-service/hpa.yaml | 27 + .../apps/notification-service/deployment.yaml | 107 + .../k8s/apps/notification-service/hpa.yaml | 28 + .../k8s/apps/payment-service/deployment.yaml | 81 + ticketflow/k8s/apps/payment-service/hpa.yaml | 28 + .../k8s/apps/user-service/deployment.yaml | 82 + ticketflow/k8s/apps/user-service/hpa.yaml | 27 + ticketflow/k8s/configmaps/app-config.yaml | 22 + ticketflow/k8s/infra/kafka.yaml | 180 + ticketflow/k8s/infra/mongodb.yaml | 91 + ticketflow/k8s/infra/postgres.yaml | 122 + ticketflow/k8s/infra/redis.yaml | 90 + ticketflow/k8s/ingress/nginx-ingress.yaml | 43 + ticketflow/k8s/keda/keda-install.yaml | 25 + ticketflow/k8s/keda/scaled-objects.yaml | 45 + .../k8s/monitoring/grafana/grafana.yaml | 157 + ticketflow/k8s/monitoring/jaeger/jaeger.yaml | 109 + ticketflow/k8s/monitoring/loki/loki.yaml | 177 + .../k8s/monitoring/prometheus/prometheus.yaml | 223 + .../k8s/monitoring/promtail/promtail.yaml | 209 + ticketflow/k8s/namespace.yaml | 7 + ticketflow/package.json | 15 - ticketflow/pnpm-lock.yaml | 7271 ----------------- ticketflow/pnpm-workspace.yaml | 5 - ticketflow/scripts/demo.sh | 165 + .../services/booking-service/Dockerfile | 19 +- .../services/booking-service/jest.config.js | 8 - .../services/booking-service/package.json | 36 - ticketflow/services/booking-service/pom.xml | 87 + .../booking-service/scripts/migrate.cjs | 48 - .../services/booking-service/src/app.ts | 9 - .../src/controllers/booking.controller.ts | 51 - .../services/booking-service/src/db/client.ts | 10 - .../services/booking-service/src/db/schema.ts | 19 - .../services/booking-service/src/index.ts | 8 - .../booking/BookingServiceApplication.java | 11 + .../booking/config/KafkaConfig.java | 92 + .../booking/config/SecurityConfig.java | 21 + .../booking/controller/BookingController.java | 57 + .../controller/GlobalExceptionHandler.java | 46 + .../booking/dto/BookingResponse.java | 18 + .../booking/dto/CreateBookingRequest.java | 15 + .../ticketflow/booking/entity/Booking.java | 54 + .../booking/entity/BookingItem.java | 24 + .../booking/entity/BookingStatus.java | 8 + .../kafka/consumer/SagaReplyConsumer.java | 85 + .../kafka/events/BookingCancelledEvent.java | 20 + .../kafka/events/BookingConfirmedEvent.java | 25 + .../kafka/events/BookingFailedEvent.java | 20 + .../kafka/events/BookingInitiatedEvent.java | 25 + .../kafka/events/PaymentProcessedEvent.java | 18 + .../kafka/events/PaymentRequestedEvent.java | 22 + .../kafka/events/SeatsConfirmEvent.java | 19 + .../kafka/events/SeatsLockFailedEvent.java | 19 + .../kafka/events/SeatsLockedEvent.java | 18 + .../kafka/events/SeatsReleaseEvent.java | 19 + .../kafka/producer/BookingEventProducer.java | 101 + .../booking/repository/BookingRepository.java | 19 + .../booking/service/BookingService.java | 114 + .../src/main/resources/application.yml | 39 + .../src/messaging/publisher.ts | 55 - .../src/routes/booking.routes.ts | 18 - .../src/schemas/booking.schema.ts | 8 - .../src/services/booking.service.test.ts | 5 - .../src/services/booking.service.ts | 249 - .../services/booking-service/tsconfig.json | 15 - ticketflow/services/event-service/Dockerfile | 10 +- .../services/event-service/app/__init__.py | 0 .../services/event-service/app/config.py | 15 + .../services/event-service/app/database.py | 21 + .../event-service/app/kafka/__init__.py | 0 .../event-service/app/kafka/producer.py | 41 + ticketflow/services/event-service/app/main.py | 49 + .../event-service/app/models/__init__.py | 0 .../event-service/app/models/event.py | 38 + .../event-service/app/routes/__init__.py | 0 .../event-service/app/routes/events.py | 101 + .../event-service/app/schemas/__init__.py | 0 .../event-service/app/schemas/event.py | 65 + ticketflow/services/event-service/app/seed.py | 123 + .../event-service/app/services/__init__.py | 0 .../app/services/event_service.py | 150 + .../services/event-service/jest.config.js | 8 - .../services/event-service/package.json | 36 - .../services/event-service/requirements.txt | 10 + .../event-service/scripts/migrate.cjs | 47 - ticketflow/services/event-service/src/app.ts | 9 - .../src/controllers/event.controller.ts | 39 - .../services/event-service/src/db/client.ts | 10 - .../services/event-service/src/db/schema.ts | 24 - .../services/event-service/src/index.ts | 8 - .../event-service/src/routes/event.routes.ts | 34 - .../event-service/src/schemas/event.schema.ts | 19 - ticketflow/services/event-service/src/seed.ts | 112 - .../src/services/event.service.test.ts | 5 - .../src/services/event.service.ts | 124 - .../services/event-service/tsconfig.json | 15 - .../services/inventory-service/Dockerfile | 15 +- .../services/inventory-service/package.json | 38 +- .../inventory-service/scripts/migrate.cjs | 41 - .../inventory-service/scripts/seed.cjs | 49 - .../services/inventory-service/src/app.ts | 9 - .../services/inventory-service/src/config.ts | 16 + .../src/controllers/inventory.controller.ts | 52 - .../inventory-service/src/db/client.ts | 39 +- .../inventory-service/src/db/schema.ts | 29 +- .../services/inventory-service/src/index.ts | 47 +- .../inventory-service/src/kafka/consumer.ts | 135 + .../inventory-service/src/kafka/producer.ts | 73 + .../inventory-service/src/redis/client.ts | 46 +- .../src/routes/inventory.routes.ts | 39 +- .../src/schemas/inventory.schema.ts | 19 - .../src/services/inventory.service.test.ts | 5 - .../src/services/inventory.service.ts | 221 +- .../services/inventory-service/tsconfig.json | 16 +- .../services/notification-service/Dockerfile | 10 +- .../notification-service/app/__init__.py | 0 .../notification-service/app/config.py | 20 + .../notification-service/app/database.py | 21 + .../app/handlers/__init__.py | 0 .../app/handlers/booking_confirmed.py | 54 + .../app/handlers/booking_failed.py | 51 + .../app/handlers/welcome.py | 49 + .../app/kafka/__init__.py | 0 .../app/kafka/consumer.py | 92 + .../app/mailer/__init__.py | 0 .../notification-service/app/mailer/client.py | 29 + .../mailer/templates/booking_confirmed.html | 93 + .../app/mailer/templates/booking_failed.html | 77 + .../app/mailer/templates/welcome.html | 91 + .../services/notification-service/app/main.py | 84 + .../app/models/__init__.py | 0 .../app/models/notification.py | 20 + .../notification-service/jest.config.js | 8 - .../notification-service/package.json | 26 - .../notification-service/requirements.txt | 9 + .../notification-service/src/consumer.ts | 78 - .../src/handlers/booking.handler.test.ts | 53 - .../src/handlers/booking.handler.ts | 59 - .../notification-service/src/index.ts | 8 - .../notification-service/src/mailer/index.ts | 40 - .../notification-service/tsconfig.json | 15 - .../services/payment-service/Dockerfile | 10 +- .../services/payment-service/app/__init__.py | 0 .../services/payment-service/app/config.py | 16 + .../services/payment-service/app/database.py | 21 + .../payment-service/app/kafka/__init__.py | 0 .../payment-service/app/kafka/consumer.py | 90 + .../payment-service/app/kafka/producer.py | 49 + .../services/payment-service/app/main.py | 61 + .../payment-service/app/models/__init__.py | 0 .../payment-service/app/models/payment.py | 22 + .../payment-service/app/routes/__init__.py | 0 .../payment-service/app/routes/payments.py | 45 + .../payment-service/app/services/__init__.py | 0 .../app/services/payment_service.py | 78 + .../services/payment-service/jest.config.js | 8 - .../services/payment-service/package.json | 33 - .../services/payment-service/requirements.txt | 8 + .../payment-service/scripts/migrate.cjs | 40 - .../services/payment-service/src/app.ts | 9 - .../src/controllers/payment.controller.ts | 23 - .../services/payment-service/src/db/client.ts | 10 - .../services/payment-service/src/db/schema.ts | 13 - .../services/payment-service/src/index.ts | 8 - .../src/routes/payment.routes.ts | 9 - .../src/schemas/payment.schema.ts | 9 - .../src/services/payment.service.test.ts | 5 - .../src/services/payment.service.ts | 62 - .../services/payment-service/tsconfig.json | 15 - ticketflow/services/user-service/Dockerfile | 19 +- .../services/user-service/jest.config.js | 8 - ticketflow/services/user-service/package.json | 39 - ticketflow/services/user-service/pom.xml | 105 + .../services/user-service/scripts/migrate.cjs | 40 - ticketflow/services/user-service/src/app.ts | 9 - .../src/controllers/auth.controller.ts | 32 - .../services/user-service/src/db/client.ts | 10 - .../services/user-service/src/db/schema.ts | 13 - ticketflow/services/user-service/src/index.ts | 8 - .../user/UserServiceApplication.java | 11 + .../ticketflow/user/config/KafkaConfig.java | 29 + .../user/config/SecurityConfig.java | 41 + .../user/controller/AuthController.java | 47 + .../controller/GlobalExceptionHandler.java | 53 + .../com/ticketflow/user/dto/AuthResponse.java | 6 + .../com/ticketflow/user/dto/LoginRequest.java | 9 + .../ticketflow/user/dto/RegisterRequest.java | 11 + .../java/com/ticketflow/user/dto/UserDTO.java | 8 + .../java/com/ticketflow/user/entity/User.java | 82 + .../user/event/UserRegisteredEvent.java | 19 + .../user/repository/UserRepository.java | 13 + .../com/ticketflow/user/security/JwtUtil.java | 53 + .../ticketflow/user/service/AuthService.java | 73 + .../src/main/resources/application.yml | 35 + .../user-service/src/routes/auth.routes.ts | 28 - .../user-service/src/schemas/auth.schema.ts | 15 - .../src/services/auth.service.test.ts | 5 - .../user-service/src/services/auth.service.ts | 85 - .../services/user-service/tsconfig.json | 15 - ticketflow/shared/.gitignore | 2 - ticketflow/shared/package-lock.json | 1118 --- ticketflow/shared/package.json | 20 - ticketflow/shared/src/events/index.ts | 38 - ticketflow/shared/src/index.ts | 5 - ticketflow/shared/src/middleware/auth.ts | 52 - .../shared/src/middleware/errorHandler.ts | 50 - ticketflow/shared/src/middleware/validate.ts | 26 - ticketflow/shared/src/types/index.ts | 85 - ticketflow/shared/tsconfig.json | 18 - 260 files changed, 13157 insertions(+), 11267 deletions(-) create mode 100644 ticketflow/Makefile create mode 100644 ticketflow/docs/adr/001-polyglot-architecture.md create mode 100644 ticketflow/docs/adr/002-kafka-over-rabbitmq.md create mode 100644 ticketflow/docs/adr/003-async-saga-pattern.md create mode 100644 ticketflow/docs/adr/004-database-per-service.md create mode 100644 ticketflow/docs/api-reference.md create mode 100644 ticketflow/docs/architecture.md create mode 100644 ticketflow/docs/deployment.md create mode 100644 ticketflow/docs/development.md create mode 100644 ticketflow/docs/event-catalog.md create mode 100644 ticketflow/docs/observability.md create mode 100644 ticketflow/docs/services/booking-service.md create mode 100644 ticketflow/docs/services/event-service.md create mode 100644 ticketflow/docs/services/gateway.md create mode 100644 ticketflow/docs/services/inventory-service.md create mode 100644 ticketflow/docs/services/notification-service.md create mode 100644 ticketflow/docs/services/payment-service.md create mode 100644 ticketflow/docs/services/user-service.md create mode 100644 ticketflow/frontend/Dockerfile create mode 100644 ticketflow/frontend/nginx.conf delete mode 100644 ticketflow/gateway/jest.config.js create mode 100644 ticketflow/gateway/src/config.ts create mode 100644 ticketflow/gateway/src/proxy.ts delete mode 100644 ticketflow/gateway/src/routes.ts create mode 100644 ticketflow/infra/kafka/create-topics.sh create mode 100644 ticketflow/infra/mongo/init.js create mode 100644 ticketflow/k8s/apps/booking-service/deployment.yaml create mode 100644 ticketflow/k8s/apps/booking-service/hpa.yaml create mode 100644 ticketflow/k8s/apps/event-service/deployment.yaml create mode 100644 ticketflow/k8s/apps/event-service/hpa.yaml create mode 100644 ticketflow/k8s/apps/frontend/deployment.yaml create mode 100644 ticketflow/k8s/apps/gateway/deployment.yaml create mode 100644 ticketflow/k8s/apps/gateway/hpa.yaml create mode 100644 ticketflow/k8s/apps/inventory-service/deployment.yaml create mode 100644 ticketflow/k8s/apps/inventory-service/hpa.yaml create mode 100644 ticketflow/k8s/apps/notification-service/deployment.yaml create mode 100644 ticketflow/k8s/apps/notification-service/hpa.yaml create mode 100644 ticketflow/k8s/apps/payment-service/deployment.yaml create mode 100644 ticketflow/k8s/apps/payment-service/hpa.yaml create mode 100644 ticketflow/k8s/apps/user-service/deployment.yaml create mode 100644 ticketflow/k8s/apps/user-service/hpa.yaml create mode 100644 ticketflow/k8s/configmaps/app-config.yaml create mode 100644 ticketflow/k8s/infra/kafka.yaml create mode 100644 ticketflow/k8s/infra/mongodb.yaml create mode 100644 ticketflow/k8s/infra/postgres.yaml create mode 100644 ticketflow/k8s/infra/redis.yaml create mode 100644 ticketflow/k8s/ingress/nginx-ingress.yaml create mode 100644 ticketflow/k8s/keda/keda-install.yaml create mode 100644 ticketflow/k8s/keda/scaled-objects.yaml create mode 100644 ticketflow/k8s/monitoring/grafana/grafana.yaml create mode 100644 ticketflow/k8s/monitoring/jaeger/jaeger.yaml create mode 100644 ticketflow/k8s/monitoring/loki/loki.yaml create mode 100644 ticketflow/k8s/monitoring/prometheus/prometheus.yaml create mode 100644 ticketflow/k8s/monitoring/promtail/promtail.yaml create mode 100644 ticketflow/k8s/namespace.yaml delete mode 100644 ticketflow/package.json delete mode 100644 ticketflow/pnpm-lock.yaml delete mode 100644 ticketflow/pnpm-workspace.yaml create mode 100755 ticketflow/scripts/demo.sh delete mode 100644 ticketflow/services/booking-service/jest.config.js delete mode 100644 ticketflow/services/booking-service/package.json create mode 100644 ticketflow/services/booking-service/pom.xml delete mode 100644 ticketflow/services/booking-service/scripts/migrate.cjs delete mode 100644 ticketflow/services/booking-service/src/app.ts delete mode 100644 ticketflow/services/booking-service/src/controllers/booking.controller.ts delete mode 100644 ticketflow/services/booking-service/src/db/client.ts delete mode 100644 ticketflow/services/booking-service/src/db/schema.ts delete mode 100644 ticketflow/services/booking-service/src/index.ts create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/BookingServiceApplication.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/SecurityConfig.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/BookingController.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/GlobalExceptionHandler.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/BookingResponse.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/CreateBookingRequest.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/Booking.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingItem.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingStatus.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/consumer/SagaReplyConsumer.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingCancelledEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingConfirmedEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingFailedEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingInitiatedEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentProcessedEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentRequestedEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsConfirmEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockFailedEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockedEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsReleaseEvent.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/producer/BookingEventProducer.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/repository/BookingRepository.java create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/service/BookingService.java create mode 100644 ticketflow/services/booking-service/src/main/resources/application.yml delete mode 100644 ticketflow/services/booking-service/src/messaging/publisher.ts delete mode 100644 ticketflow/services/booking-service/src/routes/booking.routes.ts delete mode 100644 ticketflow/services/booking-service/src/schemas/booking.schema.ts delete mode 100644 ticketflow/services/booking-service/src/services/booking.service.test.ts delete mode 100644 ticketflow/services/booking-service/src/services/booking.service.ts delete mode 100644 ticketflow/services/booking-service/tsconfig.json create mode 100644 ticketflow/services/event-service/app/__init__.py create mode 100644 ticketflow/services/event-service/app/config.py create mode 100644 ticketflow/services/event-service/app/database.py create mode 100644 ticketflow/services/event-service/app/kafka/__init__.py create mode 100644 ticketflow/services/event-service/app/kafka/producer.py create mode 100644 ticketflow/services/event-service/app/main.py create mode 100644 ticketflow/services/event-service/app/models/__init__.py create mode 100644 ticketflow/services/event-service/app/models/event.py create mode 100644 ticketflow/services/event-service/app/routes/__init__.py create mode 100644 ticketflow/services/event-service/app/routes/events.py create mode 100644 ticketflow/services/event-service/app/schemas/__init__.py create mode 100644 ticketflow/services/event-service/app/schemas/event.py create mode 100644 ticketflow/services/event-service/app/seed.py create mode 100644 ticketflow/services/event-service/app/services/__init__.py create mode 100644 ticketflow/services/event-service/app/services/event_service.py delete mode 100644 ticketflow/services/event-service/jest.config.js delete mode 100644 ticketflow/services/event-service/package.json create mode 100644 ticketflow/services/event-service/requirements.txt delete mode 100644 ticketflow/services/event-service/scripts/migrate.cjs delete mode 100644 ticketflow/services/event-service/src/app.ts delete mode 100644 ticketflow/services/event-service/src/controllers/event.controller.ts delete mode 100644 ticketflow/services/event-service/src/db/client.ts delete mode 100644 ticketflow/services/event-service/src/db/schema.ts delete mode 100644 ticketflow/services/event-service/src/index.ts delete mode 100644 ticketflow/services/event-service/src/routes/event.routes.ts delete mode 100644 ticketflow/services/event-service/src/schemas/event.schema.ts delete mode 100644 ticketflow/services/event-service/src/seed.ts delete mode 100644 ticketflow/services/event-service/src/services/event.service.test.ts delete mode 100644 ticketflow/services/event-service/src/services/event.service.ts delete mode 100644 ticketflow/services/event-service/tsconfig.json delete mode 100644 ticketflow/services/inventory-service/scripts/migrate.cjs delete mode 100644 ticketflow/services/inventory-service/scripts/seed.cjs delete mode 100644 ticketflow/services/inventory-service/src/app.ts create mode 100644 ticketflow/services/inventory-service/src/config.ts delete mode 100644 ticketflow/services/inventory-service/src/controllers/inventory.controller.ts create mode 100644 ticketflow/services/inventory-service/src/kafka/consumer.ts create mode 100644 ticketflow/services/inventory-service/src/kafka/producer.ts delete mode 100644 ticketflow/services/inventory-service/src/schemas/inventory.schema.ts delete mode 100644 ticketflow/services/inventory-service/src/services/inventory.service.test.ts create mode 100644 ticketflow/services/notification-service/app/__init__.py create mode 100644 ticketflow/services/notification-service/app/config.py create mode 100644 ticketflow/services/notification-service/app/database.py create mode 100644 ticketflow/services/notification-service/app/handlers/__init__.py create mode 100644 ticketflow/services/notification-service/app/handlers/booking_confirmed.py create mode 100644 ticketflow/services/notification-service/app/handlers/booking_failed.py create mode 100644 ticketflow/services/notification-service/app/handlers/welcome.py create mode 100644 ticketflow/services/notification-service/app/kafka/__init__.py create mode 100644 ticketflow/services/notification-service/app/kafka/consumer.py create mode 100644 ticketflow/services/notification-service/app/mailer/__init__.py create mode 100644 ticketflow/services/notification-service/app/mailer/client.py create mode 100644 ticketflow/services/notification-service/app/mailer/templates/booking_confirmed.html create mode 100644 ticketflow/services/notification-service/app/mailer/templates/booking_failed.html create mode 100644 ticketflow/services/notification-service/app/mailer/templates/welcome.html create mode 100644 ticketflow/services/notification-service/app/main.py create mode 100644 ticketflow/services/notification-service/app/models/__init__.py create mode 100644 ticketflow/services/notification-service/app/models/notification.py delete mode 100644 ticketflow/services/notification-service/jest.config.js delete mode 100644 ticketflow/services/notification-service/package.json create mode 100644 ticketflow/services/notification-service/requirements.txt delete mode 100644 ticketflow/services/notification-service/src/consumer.ts delete mode 100644 ticketflow/services/notification-service/src/handlers/booking.handler.test.ts delete mode 100644 ticketflow/services/notification-service/src/handlers/booking.handler.ts delete mode 100644 ticketflow/services/notification-service/src/index.ts delete mode 100644 ticketflow/services/notification-service/src/mailer/index.ts delete mode 100644 ticketflow/services/notification-service/tsconfig.json create mode 100644 ticketflow/services/payment-service/app/__init__.py create mode 100644 ticketflow/services/payment-service/app/config.py create mode 100644 ticketflow/services/payment-service/app/database.py create mode 100644 ticketflow/services/payment-service/app/kafka/__init__.py create mode 100644 ticketflow/services/payment-service/app/kafka/consumer.py create mode 100644 ticketflow/services/payment-service/app/kafka/producer.py create mode 100644 ticketflow/services/payment-service/app/main.py create mode 100644 ticketflow/services/payment-service/app/models/__init__.py create mode 100644 ticketflow/services/payment-service/app/models/payment.py create mode 100644 ticketflow/services/payment-service/app/routes/__init__.py create mode 100644 ticketflow/services/payment-service/app/routes/payments.py create mode 100644 ticketflow/services/payment-service/app/services/__init__.py create mode 100644 ticketflow/services/payment-service/app/services/payment_service.py delete mode 100644 ticketflow/services/payment-service/jest.config.js delete mode 100644 ticketflow/services/payment-service/package.json create mode 100644 ticketflow/services/payment-service/requirements.txt delete mode 100644 ticketflow/services/payment-service/scripts/migrate.cjs delete mode 100644 ticketflow/services/payment-service/src/app.ts delete mode 100644 ticketflow/services/payment-service/src/controllers/payment.controller.ts delete mode 100644 ticketflow/services/payment-service/src/db/client.ts delete mode 100644 ticketflow/services/payment-service/src/db/schema.ts delete mode 100644 ticketflow/services/payment-service/src/index.ts delete mode 100644 ticketflow/services/payment-service/src/routes/payment.routes.ts delete mode 100644 ticketflow/services/payment-service/src/schemas/payment.schema.ts delete mode 100644 ticketflow/services/payment-service/src/services/payment.service.test.ts delete mode 100644 ticketflow/services/payment-service/src/services/payment.service.ts delete mode 100644 ticketflow/services/payment-service/tsconfig.json delete mode 100644 ticketflow/services/user-service/jest.config.js delete mode 100644 ticketflow/services/user-service/package.json create mode 100644 ticketflow/services/user-service/pom.xml delete mode 100644 ticketflow/services/user-service/scripts/migrate.cjs delete mode 100644 ticketflow/services/user-service/src/app.ts delete mode 100644 ticketflow/services/user-service/src/controllers/auth.controller.ts delete mode 100644 ticketflow/services/user-service/src/db/client.ts delete mode 100644 ticketflow/services/user-service/src/db/schema.ts delete mode 100644 ticketflow/services/user-service/src/index.ts create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/UserServiceApplication.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/KafkaConfig.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/GlobalExceptionHandler.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/AuthResponse.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/LoginRequest.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/RegisterRequest.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/UserDTO.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/entity/User.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/event/UserRegisteredEvent.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/repository/UserRepository.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/security/JwtUtil.java create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/service/AuthService.java create mode 100644 ticketflow/services/user-service/src/main/resources/application.yml delete mode 100644 ticketflow/services/user-service/src/routes/auth.routes.ts delete mode 100644 ticketflow/services/user-service/src/schemas/auth.schema.ts delete mode 100644 ticketflow/services/user-service/src/services/auth.service.test.ts delete mode 100644 ticketflow/services/user-service/src/services/auth.service.ts delete mode 100644 ticketflow/services/user-service/tsconfig.json delete mode 100644 ticketflow/shared/.gitignore delete mode 100644 ticketflow/shared/package-lock.json delete mode 100644 ticketflow/shared/package.json delete mode 100644 ticketflow/shared/src/events/index.ts delete mode 100644 ticketflow/shared/src/index.ts delete mode 100644 ticketflow/shared/src/middleware/auth.ts delete mode 100644 ticketflow/shared/src/middleware/errorHandler.ts delete mode 100644 ticketflow/shared/src/middleware/validate.ts delete mode 100644 ticketflow/shared/src/types/index.ts delete mode 100644 ticketflow/shared/tsconfig.json diff --git a/AGENT.md b/AGENT.md index be66372..85a006f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -344,7 +344,7 @@ model BookingItem { - postgres:15 # single instance, multiple databases via init scripts - redis:7-alpine - rabbitmq:3-management # exposes :5672 and :15672 (management UI) -- mailhog/mailhog # SMTP mock, UI at :8025 +- axllent/mailpit # SMTP mock, UI at :8025 ``` --- diff --git a/ticketflow/.env.example b/ticketflow/.env.example index 4841f6e..b30aeaf 100644 --- a/ticketflow/.env.example +++ b/ticketflow/.env.example @@ -1,31 +1,97 @@ -# Shared -JWT_SECRET=changeme +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ TicketFlow Environment Variables ║ +# ║ Copy this file to .env and fill in your values ║ +# ╚══════════════════════════════════════════════════════════════════╝ -# Databases (one per service) -USER_DB_URL=postgresql://postgres:postgres@localhost:5432/users -EVENT_DB_URL=postgresql://postgres:postgres@localhost:5432/events -BOOKING_DB_URL=postgresql://postgres:postgres@localhost:5432/bookings -INVENTORY_DB_URL=postgresql://postgres:postgres@localhost:5432/inventory -PAYMENT_DB_URL=postgresql://postgres:postgres@localhost:5432/payments +# ───────────────────────────────────────────── +# SECURITY +# ───────────────────────────────────────────── +# JWT secret: minimum 32 characters, use a random string in production +# Generate with: openssl rand -base64 64 +JWT_SECRET=dev-secret-change-in-production-must-be-at-least-32-chars-long -# Redis (Inventory service — seat locking) -REDIS_URL=redis://localhost:6379 +# ───────────────────────────────────────────── +# DATABASES +# ───────────────────────────────────────────── -# RabbitMQ -RABBITMQ_URL=amqp://guest:guest@localhost:5672 +# PostgreSQL (used by: User Service, Booking Service, Inventory Service) +DB_USERNAME=postgres +DB_PASSWORD=postgres -# Payment mock -STRIPE_SECRET_KEY=sk_test_mock_key -PAYMENT_SUCCESS_RATE=0.95 +# MongoDB (used by: Event Service, Payment Service, Notification Service) +MONGO_USERNAME=mongo +MONGO_PASSWORD=mongo -# Notification (SMTP mock via MailHog) -SMTP_HOST=localhost -SMTP_PORT=1025 +# Redis (used by: Inventory Service for distributed seat locking) +REDIS_PASSWORD=redis_dev_password +REDIS_URL=redis://localhost:6379 -# Service URLs (for inter-service communication) +# ───────────────────────────────────────────── +# SERVICE URLS (for local development without Docker) +# ───────────────────────────────────────────── USER_SERVICE_URL=http://localhost:3001 EVENT_SERVICE_URL=http://localhost:3002 BOOKING_SERVICE_URL=http://localhost:3003 INVENTORY_SERVICE_URL=http://localhost:3004 PAYMENT_SERVICE_URL=http://localhost:3005 NOTIFICATION_SERVICE_URL=http://localhost:3006 + +# ───────────────────────────────────────────── +# INDIVIDUAL SERVICE DATABASE URLS +# ───────────────────────────────────────────── + +# PostgreSQL connection strings for each service +USER_DB_URL=jdbc:postgresql://localhost:5432/ticketflow_users +BOOKING_DB_URL=jdbc:postgresql://localhost:5432/ticketflow_bookings +INVENTORY_DB_URL=postgresql://postgres:postgres@localhost:5432/ticketflow_inventory + +# MongoDB connection strings for each service +EVENT_MONGODB_URL=mongodb://mongo:mongo@localhost:27017 +PAYMENT_MONGODB_URL=mongodb://mongo:mongo@localhost:27017 +NOTIFICATION_MONGODB_URL=mongodb://mongo:mongo@localhost:27017 + +# ───────────────────────────────────────────── +# MESSAGING - APACHE KAFKA +# ───────────────────────────────────────────── +# Use 9093 from localhost (9092 is internal docker-to-docker) +KAFKA_BOOTSTRAP_SERVERS=localhost:9093 + +# ───────────────────────────────────────────── +# EMAIL (SMTP) +# ───────────────────────────────────────────── +# For development: use Mailpit (http://localhost:8025) +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_FROM=noreply@ticketflow.com +SMTP_USERNAME= +SMTP_PASSWORD= + +# For production: use a real SMTP provider (e.g., SendGrid, AWS SES) +# SMTP_HOST=smtp.sendgrid.net +# SMTP_PORT=587 +# SMTP_USERNAME=apikey +# SMTP_PASSWORD=your-sendgrid-api-key + +# ───────────────────────────────────────────── +# PAYMENT +# ───────────────────────────────────────────── +# Success rate for mock payment (0.0 to 1.0, default 0.95 = 95% success) +# Set to 1.0 to always succeed, 0.0 to always fail (for testing) +PAYMENT_SUCCESS_RATE=0.95 + +# ───────────────────────────────────────────── +# FRONTEND +# ───────────────────────────────────────────── +VITE_API_BASE_URL=http://localhost:3000 + +# For production +FRONTEND_API_URL=https://api.yourdomain.com + +# ───────────────────────────────────────────── +# KUBERNETES (production overrides) +# ───────────────────────────────────────────── +# These are used when deploying to Kubernetes +# Replace with actual values from your K8s secrets +K8S_NAMESPACE=ticketflow +DOCKER_REGISTRY=your-registry.io/ticketflow +IMAGE_TAG=latest diff --git a/ticketflow/.gitignore b/ticketflow/.gitignore index d03b3dd..f64f9a7 100644 --- a/ticketflow/.gitignore +++ b/ticketflow/.gitignore @@ -1,4 +1,128 @@ +# ============================================================================= +# TicketFlow — Polyglot Monorepo .gitignore +# Covers: Node/Bun, Java/Maven, Python, Docker, IDE, OS +# ============================================================================= + +# --------------------------------------------------------------------------- +# Node / Bun (gateway, inventory-service, frontend) +# --------------------------------------------------------------------------- node_modules/ dist/ -*.env +.next/ +.nuxt/ +build/ +*.tsbuildinfo +bun.lockb +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.pnpm-store/ +.yarn/ +.cache/ + +# --------------------------------------------------------------------------- +# Java / Maven (user-service, booking-service) +# --------------------------------------------------------------------------- +target/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +# --------------------------------------------------------------------------- +# Python (event-service, payment-service, notification-service) +# --------------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*$py.class +*.pyo +*.pyd +.venv/ +venv/ +env/ +ENV/ +*.egg +*.egg-info/ +eggs/ +.eggs/ +pip-log.txt +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +coverage.xml +*.cover +.pytest_cache/ + +# --------------------------------------------------------------------------- +# Environment / Secrets +# --------------------------------------------------------------------------- .env +.env.* +!.env.example +*.pem +*.key +*.cert +*.p12 +secrets/ + +# --------------------------------------------------------------------------- +# Docker +# --------------------------------------------------------------------------- +docker-compose.override.yml + +# --------------------------------------------------------------------------- +# Kubernetes / Helm +# --------------------------------------------------------------------------- +*.kubeconfig +kubeconfig +charts/*.tgz + +# --------------------------------------------------------------------------- +# IDE / Editor +# --------------------------------------------------------------------------- +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.project +.classpath +.settings/ +*.swp +*.swo +*~ + +# --------------------------------------------------------------------------- +# OS +# --------------------------------------------------------------------------- +.DS_Store +Thumbs.db +.AppleDouble +.LSOverride +Icon + +# --------------------------------------------------------------------------- +# Logs & temp files +# --------------------------------------------------------------------------- +logs/ +*.log +*.log.* +tmp/ +temp/ +.tmp/ + +# --------------------------------------------------------------------------- +# Test / Coverage output +# --------------------------------------------------------------------------- +coverage/ +.nyc_output/ +test-results/ +surefire-reports/ diff --git a/ticketflow/Makefile b/ticketflow/Makefile new file mode 100644 index 0000000..63b529b --- /dev/null +++ b/ticketflow/Makefile @@ -0,0 +1,267 @@ +# ══════════════════════════════════════════════════════════════ +# TicketFlow Makefile +# Usage: make +# ══════════════════════════════════════════════════════════════ + +.PHONY: help dev dev-infra dev-stop build push deploy k8s-apply k8s-delete \ + logs health seed clean lint test + +# Load .env if it exists +-include .env +export + +COMPOSE_DEV := docker compose -f docker-compose.dev.yml +COMPOSE_PROD := docker compose -f docker-compose.yml +REGISTRY ?= $(DOCKER_REGISTRY) +IMAGE_TAG ?= latest +K8S_NAMESPACE ?= ticketflow + +# ───────────────────────────────────────────── +# HELP +# ───────────────────────────────────────────── +help: ## Show this help message + @echo "" + @echo " ████████╗██╗ ██████╗██╗ ██╗███████╗████████╗███████╗██╗ ██████╗ ██╗ ██╗" + @echo " ╚══██╔══╝██║██╔════╝██║ ██╔╝██╔════╝╚══██╔══╝██╔════╝██║ ██╔═══██╗██║ ██║" + @echo " ██║ ██║██║ █████╔╝ █████╗ ██║ █████╗ ██║ ██║ ██║██║ █╗ ██║" + @echo " ██║ ██║██║ ██╔═██╗ ██╔══╝ ██║ ██╔══╝ ██║ ██║ ██║██║███╗██║" + @echo " ██║ ██║╚██████╗██║ ██╗███████╗ ██║ ██║ ███████╗╚██████╔╝╚███╔███╔╝" + @echo " ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚══╝╚══╝" + @echo "" + @echo " Polyglot Microservice Architecture" + @echo " Java SpringBoot | Bun Elysia | Python FastAPI | PostgreSQL | MongoDB | Kafka" + @echo "" + @awk 'BEGIN {FS = ":.*##"; printf " \033[36m%-20s\033[0m %s\n", "Target", "Description"} \ + /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + @echo "" + +# ───────────────────────────────────────────── +# DEVELOPMENT +# ───────────────────────────────────────────── +setup: ## Copy .env.example to .env (first-time setup) + @if [ ! -f .env ]; then \ + cp .env.example .env; \ + echo " .env created from .env.example — please review and update values"; \ + else \ + echo " .env already exists, skipping"; \ + fi + +dev: ## Start full dev environment (infra + all services) + $(COMPOSE_DEV) up --build -d + @echo "" + @echo " Services starting..." + @echo " Gateway: http://localhost:3000" + @echo " Frontend: http://localhost:5173" + @echo " Kafka UI: http://localhost:8080" + @echo " Mongo Express: http://localhost:8081" + @echo " Mailpit: http://localhost:8025" + @echo "" + @echo " Run 'make logs' to follow logs, 'make health' to check service status" + +dev-infra: ## Start only infrastructure (Postgres, MongoDB, Redis, Kafka) + $(COMPOSE_DEV) up -d postgres mongodb redis zookeeper kafka kafka-init kafka-ui mongo-express mailpit + @echo "Infrastructure started. Run 'make dev' to also start application services." + +dev-stop: ## Stop dev environment + $(COMPOSE_DEV) down + +dev-clean: ## Stop dev environment and remove all volumes (DATA LOSS!) + @echo "WARNING: This will delete all local data!" + @read -p "Continue? [y/N] " ans; [ "$$ans" = "y" ] || exit 1 + $(COMPOSE_DEV) down -v + +restart: ## Restart a specific service: make restart s=user-service + $(COMPOSE_DEV) restart $(s) + +rebuild: ## Rebuild and restart a specific service: make rebuild s=booking-service + $(COMPOSE_DEV) up -d --build $(s) + +# ───────────────────────────────────────────── +# LOGS +# ───────────────────────────────────────────── +logs: ## Follow logs from all services + $(COMPOSE_DEV) logs -f --tail=100 + +logs-gateway: ## Follow gateway logs + $(COMPOSE_DEV) logs -f --tail=100 gateway + +logs-user: ## Follow user-service logs + $(COMPOSE_DEV) logs -f --tail=100 user-service + +logs-event: ## Follow event-service logs + $(COMPOSE_DEV) logs -f --tail=100 event-service + +logs-booking: ## Follow booking-service logs + $(COMPOSE_DEV) logs -f --tail=100 booking-service + +logs-inventory: ## Follow inventory-service logs + $(COMPOSE_DEV) logs -f --tail=100 inventory-service + +logs-payment: ## Follow payment-service logs + $(COMPOSE_DEV) logs -f --tail=100 payment-service + +logs-notification: ## Follow notification-service logs + $(COMPOSE_DEV) logs -f --tail=100 notification-service + +# ───────────────────────────────────────────── +# HEALTH CHECKS +# ───────────────────────────────────────────── +health: ## Check health of all services via gateway + @echo "Checking service health..." + @curl -s http://localhost:3000/health/all | python3 -m json.tool 2>/dev/null || \ + curl -s http://localhost:3000/health/all + +health-individual: ## Check each service health individually + @echo "Gateway: " && curl -sf http://localhost:3000/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "DOWN" + @echo "User Service: " && curl -sf http://localhost:3001/api/users/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "DOWN" + @echo "Event Service: " && curl -sf http://localhost:3002/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "DOWN" + @echo "Booking Service: " && curl -sf http://localhost:3003/api/bookings/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "DOWN" + @echo "Inventory Service:" && curl -sf http://localhost:3004/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "DOWN" + @echo "Payment Service: " && curl -sf http://localhost:3005/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "DOWN" + @echo "Notification: " && curl -sf http://localhost:3006/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "DOWN" + +# ───────────────────────────────────────────── +# DATABASE & SEED +# ───────────────────────────────────────────── +seed: ## Seed event-service with sample data (venues + events) + $(COMPOSE_DEV) exec event-service python -m app.seed + +psql-users: ## Open psql shell for users DB + $(COMPOSE_DEV) exec postgres psql -U postgres ticketflow_users + +psql-bookings: ## Open psql shell for bookings DB + $(COMPOSE_DEV) exec postgres psql -U postgres ticketflow_bookings + +psql-inventory: ## Open psql shell for inventory DB + $(COMPOSE_DEV) exec postgres psql -U postgres ticketflow_inventory + +mongo-shell: ## Open MongoDB shell + $(COMPOSE_DEV) exec mongodb mongosh -u $(MONGO_USERNAME) -p $(MONGO_PASSWORD) + +redis-cli: ## Open Redis CLI + $(COMPOSE_DEV) exec redis redis-cli + +# ───────────────────────────────────────────── +# KAFKA +# ───────────────────────────────────────────── +kafka-topics: ## List all Kafka topics + $(COMPOSE_DEV) exec kafka kafka-topics.sh --bootstrap-server localhost:9092 --list + +kafka-topic-describe: ## Describe a topic: make kafka-topic-describe t=ticketflow.booking.initiated + $(COMPOSE_DEV) exec kafka kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic $(t) + +kafka-consume: ## Consume messages from a topic: make kafka-consume t=ticketflow.booking.confirmed + $(COMPOSE_DEV) exec kafka kafka-console-consumer.sh \ + --bootstrap-server localhost:9092 \ + --topic $(t) \ + --from-beginning \ + --property print.key=true \ + --property print.timestamp=true + +kafka-groups: ## List Kafka consumer groups + $(COMPOSE_DEV) exec kafka kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list + +kafka-lag: ## Check consumer group lag: make kafka-lag g=booking-service + $(COMPOSE_DEV) exec kafka kafka-consumer-groups.sh \ + --bootstrap-server localhost:9092 \ + --group $(g) \ + --describe + +# ───────────────────────────────────────────── +# PRODUCTION BUILD +# ───────────────────────────────────────────── +build: ## Build all Docker images + $(COMPOSE_PROD) build + +build-push: ## Build and push all images to registry + $(COMPOSE_PROD) build + $(COMPOSE_PROD) push + +prod-up: ## Start production environment + $(COMPOSE_PROD) up -d + +prod-down: ## Stop production environment + $(COMPOSE_PROD) down + +# ───────────────────────────────────────────── +# KUBERNETES +# ───────────────────────────────────────────── +k8s-namespace: ## Create Kubernetes namespace + kubectl apply -f k8s/namespace.yaml + +k8s-infra: ## Deploy infrastructure to Kubernetes + kubectl apply -f k8s/infra/ -n $(K8S_NAMESPACE) + +k8s-monitoring: ## Deploy monitoring stack to Kubernetes + kubectl apply -f k8s/monitoring/ -n $(K8S_NAMESPACE) + +k8s-services: ## Deploy all application services to Kubernetes + kubectl apply -f k8s/configmaps/ -n $(K8S_NAMESPACE) + kubectl apply -f k8s/secrets/ -n $(K8S_NAMESPACE) + kubectl apply -f k8s/apps/ -n $(K8S_NAMESPACE) + +k8s-keda: ## Deploy KEDA ScaledObjects + kubectl apply -f k8s/keda/ -n $(K8S_NAMESPACE) + +k8s-ingress: ## Deploy ingress + kubectl apply -f k8s/ingress/ -n $(K8S_NAMESPACE) + +k8s-apply: k8s-namespace k8s-infra k8s-monitoring k8s-services k8s-keda k8s-ingress ## Deploy everything to Kubernetes + +k8s-delete: ## Delete all Kubernetes resources + kubectl delete namespace $(K8S_NAMESPACE) --ignore-not-found + +k8s-pods: ## List all pods in namespace + kubectl get pods -n $(K8S_NAMESPACE) + +k8s-logs: ## Follow pod logs: make k8s-logs pod=booking-service-xxx + kubectl logs -f $(pod) -n $(K8S_NAMESPACE) + +k8s-scale: ## Scale a deployment: make k8s-scale deploy=payment-service replicas=3 + kubectl scale deployment $(deploy) --replicas=$(replicas) -n $(K8S_NAMESPACE) + +k8s-status: ## Show full cluster status for ticketflow namespace + @echo "=== Deployments ===" + kubectl get deployments -n $(K8S_NAMESPACE) + @echo "" + @echo "=== Pods ===" + kubectl get pods -n $(K8S_NAMESPACE) + @echo "" + @echo "=== Services ===" + kubectl get services -n $(K8S_NAMESPACE) + @echo "" + @echo "=== HPA ===" + kubectl get hpa -n $(K8S_NAMESPACE) + @echo "" + @echo "=== ScaledObjects (KEDA) ===" + kubectl get scaledobjects -n $(K8S_NAMESPACE) 2>/dev/null || echo "KEDA not installed" + +# ───────────────────────────────────────────── +# UTILITIES +# ───────────────────────────────────────────── +clean: ## Remove all build artifacts and node_modules + @find . -name "node_modules" -type d -prune -exec rm -rf {} + 2>/dev/null; echo "Removed node_modules" + @find . -name "dist" -type d -prune -exec rm -rf {} + 2>/dev/null; echo "Removed dist" + @find . -name "__pycache__" -type d -prune -exec rm -rf {} + 2>/dev/null; echo "Removed __pycache__" + @find . -name "*.pyc" -delete 2>/dev/null; echo "Removed .pyc files" + @find . -name "target" -type d -prune -exec rm -rf {} + 2>/dev/null; echo "Removed Maven target" + +lint: ## Lint all services + @echo "Linting Bun/TypeScript services..." + @cd gateway && bun x tsc --noEmit 2>/dev/null && echo " gateway: OK" || echo " gateway: ERRORS" + @cd services/inventory-service && bun x tsc --noEmit 2>/dev/null && echo " inventory-service: OK" || echo " inventory-service: ERRORS" + @echo "Linting Python services..." + @cd services/event-service && python -m py_compile app/main.py app/config.py 2>/dev/null && echo " event-service: OK" || echo " event-service: ERRORS" + @cd services/payment-service && python -m py_compile app/main.py app/config.py 2>/dev/null && echo " payment-service: OK" || echo " payment-service: ERRORS" + @cd services/notification-service && python -m py_compile app/main.py app/config.py 2>/dev/null && echo " notification-service: OK" || echo " notification-service: ERRORS" + +test: ## Run all tests + @echo "Running Java service tests..." + @cd services/user-service && mvn test -q 2>/dev/null && echo " user-service: OK" || echo " user-service: FAILED" + @cd services/booking-service && mvn test -q 2>/dev/null && echo " booking-service: OK" || echo " booking-service: FAILED" + +# Quick demo: register user, create event, make booking +demo: ## Run a quick end-to-end demo via curl + @bash scripts/demo.sh + +.DEFAULT_GOAL := help diff --git a/ticketflow/README.md b/ticketflow/README.md index 6359df4..c669d2f 100644 --- a/ticketflow/README.md +++ b/ticketflow/README.md @@ -1,44 +1,554 @@ -# TicketFlow +# TicketFlow — Polyglot Microservice Architecture -TicketFlow is a distributed event ticket booking platform built as a microservices architecture. It allows users to browse events, lock seats atomically using Redis, process payments, and receive email confirmations — all handled by independent Node.js/Express services communicating via REST and RabbitMQ, with Drizzle ORM for service-local data access. +![Java](https://img.shields.io/badge/Java-21-ED8B00?style=flat-square&logo=openjdk&logoColor=white) +![Python](https://img.shields.io/badge/Python-3.12-3776AB?style=flat-square&logo=python&logoColor=white) +![Bun](https://img.shields.io/badge/Bun-1.1-000000?style=flat-square&logo=bun&logoColor=white) +![Apache Kafka](https://img.shields.io/badge/Apache_Kafka-2.x-231F20?style=flat-square&logo=apache-kafka&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-316192?style=flat-square&logo=postgresql&logoColor=white) +![MongoDB](https://img.shields.io/badge/MongoDB-7-47A248?style=flat-square&logo=mongodb&logoColor=white) +![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat-square&logo=redis&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=flat-square&logo=docker&logoColor=white) +![Kubernetes](https://img.shields.io/badge/Kubernetes-KEDA-326CE5?style=flat-square&logo=kubernetes&logoColor=white) +![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white) -For full setup and run instructions, see [INSTRUCTIONS.md](../INSTRUCTIONS.md). +> A distributed event ticket booking platform demonstrating polyglot microservice architecture with event-driven choreography saga. -## Services +--- -| Service | Port | Responsibility | -|---------|------|----------------| -| API Gateway | 3000 | Request routing, rate limiting | -| User Service | 3001 | Authentication, JWT, user profiles | -| Event Service | 3002 | Event listings, venues, schedules | -| Booking Service | 3003 | Booking orchestration | -| Inventory Service | 3004 | Seat locking (Redis SETNX) | -| Payment Service | 3005 | Payment simulation | -| Notification Service | 3006 | Email notifications (RabbitMQ consumer) | +## Table of Contents + +- [What is TicketFlow?](#what-is-ticketflow) +- [Architecture Overview](#architecture-overview) +- [Booking Saga Flow](#booking-saga-flow) +- [Tech Stack](#tech-stack) +- [Project Structure](#project-structure) +- [Quick Start](#quick-start) +- [API Reference Summary](#api-reference-summary) +- [Environment Variables](#environment-variables) +- [Development Tools](#development-tools) +- [Kafka Topics](#kafka-topics) +- [Why Polyglot?](#why-polyglot) +- [Why Kafka over RabbitMQ?](#why-kafka-over-rabbitmq) +- [Contributing](#contributing) +- [License](#license) + +--- + +## What is TicketFlow? + +TicketFlow is a production-grade reference implementation of a **polyglot microservice platform** for selling and managing event tickets. It is designed to demonstrate: + +- **Polyglot architecture** — each service uses the runtime and framework best suited to its workload (Java Spring Boot for transactional services, Python FastAPI for document-centric services, and Bun/Elysia for high-throughput edge logic). +- **Event-driven choreography saga** — the entire booking lifecycle (seat locking, payment processing, confirmation) is coordinated asynchronously through Apache Kafka with no central orchestrator. +- **Database-per-service** — every service owns and manages its own datastore; no shared databases, no cross-service SQL joins. +- **Production-ready observability** — Prometheus metrics, Grafana dashboards, Jaeger distributed tracing, and Loki log aggregation are included out of the box. +- **Cloud-native scalability** — Kubernetes manifests with KEDA-based autoscaling that reacts to Kafka consumer lag, allowing each service to scale independently under load. + +Whether you are exploring microservice design patterns, evaluating polyglot technology choices, or using TicketFlow as a scaffold for a real ticketing product, this repository provides a complete, runnable reference. + +--- + +## Architecture Overview + +```mermaid +graph TB + subgraph "Client Layer" + FE["Frontend\nReact + Vite\n:5173"] + end + + subgraph "API Gateway Layer" + GW["API Gateway\nBun + Elysia\n:3000"] + end + + subgraph "Java Services" + US["User Service\nSpring Boot 3\n:3001"] + BS["Booking Service\nSpring Boot 3\n:3003"] + end + + subgraph "Python Services" + ES["Event Service\nFastAPI\n:3002"] + PS["Payment Service\nFastAPI\n:3005"] + NS["Notification Service\nFastAPI\n:3006"] + end + + subgraph "Bun Services" + IS["Inventory Service\nElysia\n:3004"] + end + + subgraph "Message Bus" + KF["Apache Kafka"] + end + + subgraph "Databases" + PG[("PostgreSQL\nUsers / Bookings / Inventory")] + MG[("MongoDB\nEvents / Payments / Notifications")] + RD[("Redis\nSeat Locks")] + end + + FE --> GW + GW --> US + GW --> ES + GW --> BS + GW --> IS + GW --> PS + GW --> NS + + US --> PG + BS --> PG + IS --> PG + IS --> RD + ES --> MG + PS --> MG + NS --> MG + + BS --> KF + IS --> KF + PS --> KF + NS --> KF + US --> KF + ES --> KF + + KF --> IS + KF --> PS + KF --> NS + KF --> BS +``` + +All client requests enter through the **API Gateway** (Bun + Elysia), which handles JWT validation, route proxying, and request logging. For reads and simple commands the gateway forwards directly to the target service over HTTP. For the booking lifecycle the gateway accepts the initial request and returns `202 Accepted`; the client then polls for the result while the saga progresses asynchronously via Kafka. + +--- + +## Booking Saga Flow + +The booking lifecycle is the centrepiece of TicketFlow's event-driven design. No service calls another service directly; every step is triggered by a Kafka event. + +```mermaid +sequenceDiagram + participant C as Client + participant GW as Gateway + participant BS as Booking Service + participant KF as Kafka + participant IS as Inventory Service + participant PS as Payment Service + participant NS as Notification Service + + C->>GW: POST /api/bookings + GW->>BS: Forward request + BS->>BS: Save booking (PENDING) + BS->>KF: booking.initiated + BS-->>C: 202 Accepted {bookingId} + + KF->>IS: booking.initiated + IS->>IS: Lock seats (Redis SETNX) + IS->>IS: Update DB (LOCKED) + IS->>KF: seats.locked + + KF->>BS: seats.locked + BS->>KF: payment.requested + + KF->>PS: payment.requested + PS->>PS: Process payment + PS->>PS: Save to MongoDB + PS->>KF: payment.processed (SUCCESS) + + KF->>BS: payment.processed + BS->>BS: Update booking (CONFIRMED) + BS->>KF: booking.confirmed + BS->>KF: seats.confirm + + KF->>IS: seats.confirm + IS->>IS: Update DB (RESERVED) + IS->>IS: Clear Redis locks + + KF->>NS: booking.confirmed + NS->>NS: Send confirmation email + NS->>NS: Save notification log + + C->>GW: GET /api/bookings/{id} + GW->>BS: Forward request + BS-->>C: {status: "CONFIRMED"} +``` + +### Saga Compensation (Failure Path) + +If any step fails the saga runs compensating transactions in reverse: + +| Failure Point | Compensating Event | Compensating Action | +|---|---|---| +| Seat lock fails | `seats.lock-failed` | Booking Service marks booking FAILED | +| Payment fails | `payment.processed` (FAILED) | Inventory releases locks via `seats.release` | +| Booking Service crash | Kafka retry / DLT | Message replayed on restart | + +--- + +## Tech Stack + +| Service | Language | Runtime | Framework | Database | Kafka Role | Port | +|---|---|---|---|---|---|---| +| API Gateway | TypeScript | Bun 1.1 | Elysia | — | — | 3000 | +| User Service | Java | JDK 21 | Spring Boot 3 | PostgreSQL | Producer | 3001 | +| Event Service | Python | CPython 3.12 | FastAPI | MongoDB | Producer | 3002 | +| Booking Service | Java | JDK 21 | Spring Boot 3 | PostgreSQL | Producer + Consumer | 3003 | +| Inventory Service | TypeScript | Bun 1.1 | Elysia | PostgreSQL + Redis | Producer + Consumer | 3004 | +| Payment Service | Python | CPython 3.12 | FastAPI | MongoDB | Producer + Consumer | 3005 | +| Notification Service | Python | CPython 3.12 | FastAPI | MongoDB | Consumer | 3006 | +| Frontend | TypeScript | Node 20 | React 18 + Vite | — | — | 5173 | + +### Infrastructure + +| Component | Technology | Purpose | +|---|---|---| +| Message Broker | Apache Kafka | Async inter-service communication | +| Relational DB | PostgreSQL 16 | Users, bookings, seat inventory | +| Document DB | MongoDB 7 | Events, payments, notifications | +| Cache / Lock | Redis 7 | Distributed seat locking, TTL-based | +| Observability | Prometheus + Grafana | Metrics and dashboards | +| Tracing | Jaeger + OpenTelemetry | Distributed trace correlation | +| Log Aggregation | Loki + Promtail | Centralised log storage and search | +| Local Email | Mailpit | Captures outbound email in dev | +| Kafka UI | Redpanda Console | Browse topics and messages | +| Mongo UI | Mongo Express | Browse MongoDB collections | + +--- + +## Project Structure + +``` +ticketflow/ +├── README.md +├── Makefile # Developer shortcuts +├── docker-compose.yml # Full dev stack +├── docker-compose.prod.yml # Production overrides +├── .env.example # Template environment file +│ +├── gateway/ # API Gateway (Bun + Elysia) +│ ├── src/ +│ │ ├── index.ts +│ │ ├── routes/ +│ │ ├── middleware/ +│ │ └── config.ts +│ ├── package.json +│ ├── tsconfig.json +│ └── Dockerfile +│ +├── services/ +│ ├── user-service/ # Java + Spring Boot 3 +│ │ ├── src/main/java/ +│ │ ├── src/main/resources/ +│ │ ├── pom.xml +│ │ └── Dockerfile +│ │ +│ ├── event-service/ # Python + FastAPI +│ │ ├── app/ +│ │ │ ├── main.py +│ │ │ ├── routers/ +│ │ │ ├── models/ +│ │ │ └── kafka/ +│ │ ├── requirements.txt +│ │ └── Dockerfile +│ │ +│ ├── booking-service/ # Java + Spring Boot 3 +│ │ ├── src/main/java/ +│ │ ├── src/main/resources/ +│ │ ├── pom.xml +│ │ └── Dockerfile +│ │ +│ ├── inventory-service/ # Bun + Elysia +│ │ ├── src/ +│ │ ├── package.json +│ │ └── Dockerfile +│ │ +│ ├── payment-service/ # Python + FastAPI +│ │ ├── app/ +│ │ ├── requirements.txt +│ │ └── Dockerfile +│ │ +│ └── notification-service/ # Python + FastAPI +│ ├── app/ +│ ├── requirements.txt +│ └── Dockerfile +│ +├── frontend/ # React 18 + Vite +│ ├── src/ +│ ├── public/ +│ ├── package.json +│ ├── vite.config.ts +│ └── Dockerfile +│ +├── infra/ +│ ├── kafka/ +│ │ └── topics.sh # Topic creation script +│ ├── postgres/ +│ │ └── init.sql # Schema initialisation +│ ├── mongo/ +│ │ └── init.js # Collection + index setup +│ ├── prometheus/ +│ │ └── prometheus.yml +│ ├── grafana/ +│ │ └── dashboards/ +│ ├── jaeger/ +│ └── loki/ +│ +├── k8s/ +│ ├── namespace.yaml +│ ├── secrets/ +│ │ └── app-secrets.yaml.example +│ ├── gateway/ +│ ├── services/ +│ └── keda/ +│ └── scaledobjects.yaml +│ +├── scripts/ +│ ├── seed.sh +│ ├── health-check.sh +│ └── e2e-booking.sh +│ +└── docs/ + ├── architecture.md + ├── development.md + ├── deployment.md + ├── event-catalog.md + ├── api-reference.md + ├── observability.md + ├── adr/ + │ ├── 001-polyglot-architecture.md + │ ├── 002-kafka-over-rabbitmq.md + │ ├── 003-async-saga-pattern.md + │ └── 004-database-per-service.md + └── services/ + ├── gateway.md + ├── user-service.md + ├── event-service.md + ├── booking-service.md + ├── inventory-service.md + ├── payment-service.md + └── notification-service.md +``` + +--- ## Quick Start +### Prerequisites + +| Tool | Minimum Version | Install | +|---|---|---| +| Docker | 24.x | [docs.docker.com](https://docs.docker.com/get-docker/) | +| Docker Compose | 2.x | Bundled with Docker Desktop | +| Make | any | `brew install make` / `apt install make` | +| Bun | 1.1+ | `curl -fsSL https://bun.sh/install \| bash` | +| JDK | 21 | `sdk install java 21-graalce` | +| Python | 3.12 | `pyenv install 3.12` | + +> **Note:** For Docker Compose development you only strictly need Docker + Make. The language runtimes are required only if you want to run individual services outside of containers. + +### 1 — Clone and setup + ```bash -# 1. Install dependencies -pnpm install +git clone https://github.com/your-org/ticketflow.git +cd ticketflow -# 2. Start infrastructure -docker compose -f docker-compose.dev.yml up -d +# Copy the example environment file and review/edit as needed +make setup +``` -# 3. Run migrations -pnpm --filter user-service run migrate -pnpm --filter event-service run migrate -pnpm --filter booking-service run migrate -pnpm --filter inventory-service run migrate -pnpm --filter payment-service run migrate +### 2 — Start infrastructure and all services -# 4. Start full stack (frontend + backend) -pnpm run dev:all +```bash +# Starts Kafka, databases, all microservices, and the frontend +make dev +``` -# Or backend only -pnpm run dev:backend +This command: +1. Pulls or builds all Docker images. +2. Starts Zookeeper + Kafka and waits for the broker to be healthy. +3. Creates all Kafka topics via `infra/kafka/topics.sh`. +4. Starts PostgreSQL, MongoDB, and Redis; runs migrations and index creation. +5. Starts all eight application services. +6. Starts Prometheus, Grafana, Jaeger, Loki, Mailpit, and Redpanda Console. + +### 3 — Seed sample data + +```bash +# Creates sample venues, events, and a test user account +make seed ``` -## Architecture +Credentials for the seeded test user: +- **Email:** `test@ticketflow.dev` +- **Password:** `Test1234!` + +### 4 — Verify everything is running + +```bash +make health +``` + +Expected output: + +``` +gateway ✓ http://localhost:3000/health +user-service ✓ http://localhost:3001/actuator/health +event-service ✓ http://localhost:3002/health +booking-service ✓ http://localhost:3003/actuator/health +inventory-service ✓ http://localhost:3004/health +payment-service ✓ http://localhost:3005/health +notification-service ✓ http://localhost:3006/health +frontend ✓ http://localhost:5173 +``` + +### 5 — Run an end-to-end booking + +```bash +# Guided script that registers a user, creates a booking, and polls for confirmation +make e2e +``` + +--- + +## API Reference Summary + +All requests go through the gateway at `http://localhost:3000`. Protected routes require `Authorization: Bearer `. + +| Service | Method | Path | Auth | Description | +|---|---|---|---|---| +| Gateway | GET | `/health` | No | Gateway health check | +| User | POST | `/api/users/register` | No | Register a new user | +| User | POST | `/api/users/login` | No | Authenticate, receive JWT | +| User | GET | `/api/users/me` | Yes | Get current user profile | +| Event | GET | `/api/events` | No | List all upcoming events | +| Event | GET | `/api/events/:id` | No | Get event details | +| Event | POST | `/api/events` | Yes (admin) | Create a new event | +| Event | PUT | `/api/events/:id` | Yes (admin) | Update event | +| Event | POST | `/api/venues` | Yes (admin) | Create a venue | +| Event | GET | `/api/venues/:id` | No | Get venue details | +| Inventory | GET | `/api/inventory/events/:eventId/seats` | No | List seat availability | +| Booking | POST | `/api/bookings` | Yes | Initiate a booking (async) | +| Booking | GET | `/api/bookings/my` | Yes | List user's bookings | +| Booking | GET | `/api/bookings/:id` | Yes | Get booking status | +| Booking | POST | `/api/bookings/:id/cancel` | Yes | Cancel a booking | +| Payment | GET | `/api/payments/:id` | Yes | Get payment details | +| Payment | GET | `/api/payments/booking/:bookingId` | Yes | Get payment for booking | +| Notification | GET | `/api/notifications/recent` | Yes | Recent notifications | +| Notification | GET | `/api/notifications/health` | No | Notification service health | + +Full request/response schemas and error codes are documented in [docs/api-reference.md](docs/api-reference.md). + +--- + +## Environment Variables + +The `.env.example` file contains all variables. Run `make setup` to copy it to `.env`. + +| Variable | Description | Default | Required | +|---|---|---|---| +| `JWT_SECRET` | Secret key for JWT signing | — | Yes | +| `JWT_EXPIRY_HOURS` | Token lifetime in hours | `24` | No | +| `POSTGRES_USER` | PostgreSQL superuser | `ticketflow` | Yes | +| `POSTGRES_PASSWORD` | PostgreSQL password | — | Yes | +| `POSTGRES_DB` | Default database name | `ticketflow` | Yes | +| `MONGO_INITDB_ROOT_USERNAME` | MongoDB root user | `ticketflow` | Yes | +| `MONGO_INITDB_ROOT_PASSWORD` | MongoDB root password | — | Yes | +| `REDIS_PASSWORD` | Redis password | — | Yes | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker address | `kafka:9092` | Yes | +| `USER_SERVICE_URL` | Internal URL for user service | `http://user-service:3001` | Yes | +| `EVENT_SERVICE_URL` | Internal URL for event service | `http://event-service:3002` | Yes | +| `BOOKING_SERVICE_URL` | Internal URL for booking service | `http://booking-service:3003` | Yes | +| `INVENTORY_SERVICE_URL` | Internal URL for inventory service | `http://inventory-service:3004` | Yes | +| `PAYMENT_SERVICE_URL` | Internal URL for payment service | `http://payment-service:3005` | Yes | +| `NOTIFICATION_SERVICE_URL` | Internal URL for notification service | `http://notification-service:3006` | Yes | +| `SMTP_HOST` | SMTP server host | `mailpit` | Yes | +| `SMTP_PORT` | SMTP server port | `1025` | Yes | +| `EMAIL_FROM` | Sender address for notifications | `noreply@ticketflow.dev` | Yes | +| `STRIPE_SECRET_KEY` | Stripe API key (payment) | — | Yes | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | — | Yes | +| `PROMETHEUS_ENABLED` | Enable Prometheus metrics | `true` | No | +| `OTEL_EXPORTER_JAEGER_ENDPOINT` | Jaeger collector endpoint | `http://jaeger:14268/api/traces` | No | +| `LOG_LEVEL` | Application log level | `info` | No | + +--- + +## Development Tools + +| Tool | URL | Description | +|---|---|---| +| API Gateway | http://localhost:3000 | Entry point for all API requests | +| Frontend | http://localhost:5173 | React application (Vite dev server) | +| Redpanda Console (Kafka UI) | http://localhost:8080 | Browse topics, consumer groups, messages | +| Mongo Express | http://localhost:8081 | Browse MongoDB collections | +| Mailpit | http://localhost:8025 | Captures all outbound email (dev only) | +| Prometheus | http://localhost:9090 | Metrics query and alerting rules | +| Grafana | http://localhost:3010 | Pre-built dashboards (admin / admin) | +| Jaeger UI | http://localhost:16686 | Distributed trace explorer | +| Loki / Grafana Explore | http://localhost:3010/explore | Log aggregation query UI | + +--- + +## Kafka Topics + +| Topic | Producer | Consumers | Purpose | +|---|---|---|---| +| `ticketflow.user.registered` | User Service | Notification Service | Welcome email trigger | +| `ticketflow.event.created` | Event Service | — | Event lifecycle event | +| `ticketflow.booking.initiated` | Booking Service | Inventory Service | Start seat lock step | +| `ticketflow.seats.locked` | Inventory Service | Booking Service | Proceed to payment step | +| `ticketflow.seats.lock-failed` | Inventory Service | Booking Service | Abort saga | +| `ticketflow.seats.confirm` | Booking Service | Inventory Service | Finalise seat reservation | +| `ticketflow.seats.release` | Booking Service / Payment Service | Inventory Service | Release seats on failure | +| `ticketflow.payment.requested` | Booking Service | Payment Service | Trigger payment processing | +| `ticketflow.payment.processed` | Payment Service | Booking Service | Payment result | +| `ticketflow.booking.confirmed` | Booking Service | Notification Service | Send confirmation email | +| `ticketflow.booking.failed` | Booking Service | Notification Service | Send failure notification | +| `ticketflow.booking.cancelled` | Booking Service | Notification Service, Inventory Service | Cancel flow | + +Full payload schemas in [docs/event-catalog.md](docs/event-catalog.md). + +--- + +## Why Polyglot? + +Each technology was chosen to match the characteristics of its service. + +| Service | Runtime | Rationale | +|---|---|---| +| API Gateway | Bun + Elysia | Extremely low startup time, high throughput for proxying. Bun's native fetch is faster than Node's for proxy workloads. | +| User Service | Java + Spring Boot 3 | Strong type safety, mature Spring Security for JWT, Spring Data JPA for transactional user management. Virtual threads (JDK 21) handle high concurrency. | +| Event Service | Python + FastAPI | Event data is document-centric and schema-flexible. FastAPI's automatic OpenAPI generation suits the read-heavy event catalog. | +| Booking Service | Java + Spring Boot 3 | Booking records require ACID guarantees. Spring Kafka integration supports exactly-once semantics. | +| Inventory Service | Bun + Elysia | Seat availability queries are extremely hot. Bun's performance on tight loops and Redis commands is competitive with Go for this workload. | +| Payment Service | Python + FastAPI | Payment provider SDKs (Stripe) have first-class Python support. Async FastAPI handles webhooks and background tasks naturally. | +| Notification Service | Python + FastAPI | Email templating (Jinja2) and SMTP are idiomatic in Python. Low-throughput service where iteration speed matters more than raw performance. | + +--- + +## Why Kafka over RabbitMQ? + +TicketFlow originally used RabbitMQ. The migration to Kafka was driven by: + +1. **Event log replay** — Kafka retains messages for a configurable period. Services can replay missed events from their last committed offset on restart. +2. **Consumer groups** — Multiple instances of the same service share a consumer group and automatically partition work without additional coordination. +3. **KEDA integration** — KEDA's Kafka trigger scales pod replicas based on consumer lag. +4. **Partition-based ordering** — Booking events for the same `bookingId` are routed to the same partition, guaranteeing ordered processing per booking. +5. **Saga auditability** — The Kafka topic acts as an immutable audit log of every saga step. + +Full rationale in [docs/adr/002-kafka-over-rabbitmq.md](docs/adr/002-kafka-over-rabbitmq.md). + +--- + +## Contributing + +1. Fork the repository. +2. Create a feature branch: `git checkout -b feat/your-feature`. +3. Make changes, add tests, ensure `make test` passes. +4. Open a pull request against `main`. + +Code style per language: +- Java: Google Java Style Guide +- Python: Black formatter + isort (`make lint`) +- TypeScript: ESLint + Prettier (`bun run lint`) + +--- + +## License -Services communicate synchronously via HTTP (using axios) and asynchronously via RabbitMQ. The inventory service uses Redis `SETNX` for atomic seat locking to prevent double-booking under concurrent load. +MIT License. See [LICENSE](LICENSE) for details. diff --git a/ticketflow/docker-compose.dev.yml b/ticketflow/docker-compose.dev.yml index 68efc47..a75cd5f 100644 --- a/ticketflow/docker-compose.dev.yml +++ b/ticketflow/docker-compose.dev.yml @@ -1,50 +1,389 @@ -version: '3.9' +name: ticketflow + services: + # ───────────────────────────────────────────── + # INFRASTRUCTURE + # ───────────────────────────────────────────── + postgres: - image: postgres:15 + image: postgres:16-alpine + container_name: ticketflow-postgres environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_USER: ${DB_USERNAME:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: postgres ports: - - '5432:5432' + - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] - interval: 5s + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s timeout: 5s - retries: 10 + retries: 5 + networks: + - ticketflow-net + + mongodb: + image: mongo:7.0 + container_name: ticketflow-mongodb + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME:-mongo} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-mongo} + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + - ./infra/mongo/init.js:/docker-entrypoint-initdb.d/init.js + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - ticketflow-net redis: image: redis:7-alpine + container_name: ticketflow-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --save 60 1 --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - ticketflow-net + + zookeeper: + image: confluentinc/cp-zookeeper:7.7.0 + container_name: ticketflow-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 ports: - - '6379:6379' + - "2181:2181" + volumes: + - zookeeper_data:/var/lib/zookeeper/data + - zookeeper_logs:/var/lib/zookeeper/log healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 5s + test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep imok"] + interval: 10s timeout: 5s - retries: 10 + retries: 5 + networks: + - ticketflow-net - rabbitmq: - image: rabbitmq:3-management + kafka: + image: confluentinc/cp-kafka:7.7.0 + container_name: ticketflow-kafka + depends_on: + zookeeper: + condition: service_healthy ports: - - '5672:5672' - - '15672:15672' + - "9092:9092" + - "9093:9093" environment: - RABBITMQ_DEFAULT_USER: guest - RABBITMQ_DEFAULT_PASS: guest + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:9093 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_LOG_RETENTION_HOURS: 168 + volumes: + - kafka_data:/var/lib/kafka/data healthcheck: - test: ['CMD', 'rabbitmq-diagnostics', 'ping'] - interval: 10s + test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list"] + interval: 15s timeout: 10s retries: 10 + networks: + - ticketflow-net - mailhog: - image: mailhog/mailhog + # Kafka topic initializer (runs once then exits) + kafka-init: + image: confluentinc/cp-kafka:7.7.0 + container_name: ticketflow-kafka-init + depends_on: + kafka: + condition: service_healthy + volumes: + - ./infra/kafka/create-topics.sh:/create-topics.sh + command: ["bash", "/create-topics.sh"] + environment: + KAFKA_BROKER: kafka:9092 + networks: + - ticketflow-net + + # Kafka UI — browse topics, messages, consumer groups + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: ticketflow-kafka-ui + depends_on: + - kafka ports: - - '1025:1025' - - '8025:8025' + - "8080:8080" + environment: + KAFKA_CLUSTERS_0_NAME: ticketflow-local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 + KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181 + networks: + - ticketflow-net + + # Mongo Express — MongoDB GUI + mongo-express: + image: mongo-express:1.0.2 + container_name: ticketflow-mongo-express + depends_on: + mongodb: + condition: service_healthy + ports: + - "8081:8081" + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_USERNAME:-mongo} + ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASSWORD:-mongo} + ME_CONFIG_MONGODB_SERVER: mongodb + ME_CONFIG_BASICAUTH: "false" + networks: + - ticketflow-net + + # Mailpit — Email testing UI + mailpit: + image: axllent/mailpit:latest + container_name: ticketflow-mailpit + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + environment: + MP_MAX_MESSAGES: 5000 + MP_DATABASE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: "true" + MP_SMTP_AUTH_ALLOW_INSECURE: "true" + volumes: + - mailpit_data:/data + networks: + - ticketflow-net + + # ───────────────────────────────────────────── + # APPLICATION SERVICES + # ───────────────────────────────────────────── + + gateway: + build: + context: ./gateway + dockerfile: Dockerfile + container_name: ticketflow-gateway + ports: + - "3000:3000" + environment: + PORT: 3000 + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-in-prod-min-32-chars-long} + USER_SERVICE_URL: http://user-service:3001 + EVENT_SERVICE_URL: http://event-service:3002 + BOOKING_SERVICE_URL: http://booking-service:3003 + INVENTORY_SERVICE_URL: http://inventory-service:3004 + PAYMENT_SERVICE_URL: http://payment-service:3005 + NOTIFICATION_SERVICE_URL: http://notification-service:3006 + depends_on: + - user-service + - event-service + - booking-service + - inventory-service + - payment-service + - notification-service + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + + user-service: + build: + context: ./services/user-service + dockerfile: Dockerfile + container_name: ticketflow-user-service + environment: + PORT: 3001 + USER_DB_URL: jdbc:postgresql://postgres:5432/ticketflow_users + DB_USERNAME: ${DB_USERNAME:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-in-prod-min-32-chars-long} + JWT_EXPIRATION: 604800000 + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3001/api/users/health || exit 1"] + interval: 20s + timeout: 10s + retries: 5 + + event-service: + build: + context: ./services/event-service + dockerfile: Dockerfile + container_name: ticketflow-event-service + environment: + PORT: 3002 + MONGODB_URL: mongodb://${MONGO_USERNAME:-mongo}:${MONGO_PASSWORD:-mongo}@mongodb:27017 + MONGODB_DB: ticketflow_events + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + depends_on: + mongodb: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3002/health || exit 1"] + interval: 20s + timeout: 10s + retries: 5 + + booking-service: + build: + context: ./services/booking-service + dockerfile: Dockerfile + container_name: ticketflow-booking-service + environment: + PORT: 3003 + BOOKING_DB_URL: jdbc:postgresql://postgres:5432/ticketflow_bookings + DB_USERNAME: ${DB_USERNAME:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3003/api/bookings/health || exit 1"] + interval: 20s + timeout: 10s + retries: 5 + + inventory-service: + build: + context: ./services/inventory-service + dockerfile: Dockerfile + container_name: ticketflow-inventory-service + environment: + PORT: 3004 + INVENTORY_DB_URL: postgresql://${DB_USERNAME:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/ticketflow_inventory + REDIS_URL: redis://redis:6379 + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3004/health || exit 1"] + interval: 20s + timeout: 10s + retries: 5 + + payment-service: + build: + context: ./services/payment-service + dockerfile: Dockerfile + container_name: ticketflow-payment-service + environment: + PORT: 3005 + MONGODB_URL: mongodb://${MONGO_USERNAME:-mongo}:${MONGO_PASSWORD:-mongo}@mongodb:27017 + MONGODB_DB: ticketflow_payments + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + PAYMENT_SUCCESS_RATE: ${PAYMENT_SUCCESS_RATE:-0.95} + depends_on: + mongodb: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3005/health || exit 1"] + interval: 20s + timeout: 10s + retries: 5 + + notification-service: + build: + context: ./services/notification-service + dockerfile: Dockerfile + container_name: ticketflow-notification-service + environment: + PORT: 3006 + MONGODB_URL: mongodb://${MONGO_USERNAME:-mongo}:${MONGO_PASSWORD:-mongo}@mongodb:27017 + MONGODB_DB: ticketflow_notifications + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + SMTP_HOST: mailpit + SMTP_PORT: 1025 + SMTP_FROM: noreply@ticketflow.com + SMTP_USERNAME: "" + SMTP_PASSWORD: "" + depends_on: + mongodb: + condition: service_healthy + kafka: + condition: service_healthy + mailpit: + - service_started + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3006/health || exit 1"] + interval: 20s + timeout: 10s + retries: 5 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: ticketflow-frontend + ports: + - "5173:80" + environment: + VITE_API_BASE_URL: http://localhost:3000 + depends_on: + - gateway + networks: + - ticketflow-net volumes: postgres_data: + mongodb_data: + redis_data: + kafka_data: + zookeeper_data: + zookeeper_logs: + mailpit_data: + +networks: + ticketflow-net: + driver: bridge diff --git a/ticketflow/docker-compose.yml b/ticketflow/docker-compose.yml index 1764da6..d084a67 100644 --- a/ticketflow/docker-compose.yml +++ b/ticketflow/docker-compose.yml @@ -1,119 +1,289 @@ -version: '3.9' +name: ticketflow-prod + services: + # ───────────────────────────────────────────── + # INFRASTRUCTURE + # ───────────────────────────────────────────── + postgres: - image: postgres:15 + image: postgres:16-alpine + restart: unless-stopped environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: postgres volumes: - postgres_data:/var/lib/postgresql/data - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres'] - interval: 5s + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres}"] + interval: 10s timeout: 5s - retries: 10 + retries: 5 + networks: + - ticketflow-net + + mongodb: + image: mongo:7.0 + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} + volumes: + - mongodb_data:/data/db + - ./infra/mongo/init.js:/docker-entrypoint-initdb.d/init.js + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - ticketflow-net redis: image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + command: redis-server --save 60 1 --requirepass ${REDIS_PASSWORD} --loglevel warning healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 5s - timeout: 5s - retries: 10 + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - ticketflow-net + + zookeeper: + image: confluentinc/cp-zookeeper:7.7.0 + restart: unless-stopped + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + volumes: + - zookeeper_data:/var/lib/zookeeper/data + - zookeeper_logs:/var/lib/zookeeper/log + networks: + - ticketflow-net - rabbitmq: - image: rabbitmq:3-management + kafka: + image: confluentinc/cp-kafka:7.7.0 + restart: unless-stopped + depends_on: + zookeeper: + condition: service_healthy environment: - RABBITMQ_DEFAULT_USER: guest - RABBITMQ_DEFAULT_PASS: guest + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" + KAFKA_LOG_RETENTION_HOURS: 168 + volumes: + - kafka_data:/var/lib/kafka/data healthcheck: - test: ['CMD', 'rabbitmq-diagnostics', 'ping'] - interval: 10s + test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list"] + interval: 15s timeout: 10s retries: 10 + networks: + - ticketflow-net + + kafka-init: + image: confluentinc/cp-kafka:7.7.0 + depends_on: + kafka: + condition: service_healthy + volumes: + - ./infra/kafka/create-topics.sh:/create-topics.sh + command: ["bash", "/create-topics.sh"] + environment: + KAFKA_BROKER: kafka:9092 + REPLICATION_FACTOR: 1 + networks: + - ticketflow-net + + # ───────────────────────────────────────────── + # APPLICATION SERVICES + # ───────────────────────────────────────────── gateway: - build: ./gateway + build: + context: ./gateway + dockerfile: Dockerfile + restart: unless-stopped ports: - - '3000:3000' + - "3000:3000" environment: - - JWT_SECRET=${JWT_SECRET} - - USER_SERVICE_URL=http://user-service:3001 - - EVENT_SERVICE_URL=http://event-service:3002 - - BOOKING_SERVICE_URL=http://booking-service:3003 - - INVENTORY_SERVICE_URL=http://inventory-service:3004 - - PAYMENT_SERVICE_URL=http://payment-service:3005 + PORT: 3000 + JWT_SECRET: ${JWT_SECRET} + USER_SERVICE_URL: http://user-service:3001 + EVENT_SERVICE_URL: http://event-service:3002 + BOOKING_SERVICE_URL: http://booking-service:3003 + INVENTORY_SERVICE_URL: http://inventory-service:3004 + PAYMENT_SERVICE_URL: http://payment-service:3005 + NOTIFICATION_SERVICE_URL: http://notification-service:3006 depends_on: - user-service - event-service - booking-service - inventory-service - payment-service + - notification-service + networks: + - ticketflow-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 user-service: - build: ./services/user-service + build: + context: ./services/user-service + dockerfile: Dockerfile + restart: unless-stopped environment: - - DATABASE_URL=${USER_DB_URL} - - JWT_SECRET=${JWT_SECRET} + PORT: 3001 + USER_DB_URL: jdbc:postgresql://postgres:5432/ticketflow_users + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + JWT_SECRET: ${JWT_SECRET} depends_on: postgres: condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net event-service: - build: ./services/event-service + build: + context: ./services/event-service + dockerfile: Dockerfile + restart: unless-stopped environment: - - DATABASE_URL=${EVENT_DB_URL} - - JWT_SECRET=${JWT_SECRET} + PORT: 3002 + MONGODB_URL: mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongodb:27017 + MONGODB_DB: ticketflow_events + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 depends_on: - postgres: + mongodb: condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net booking-service: - build: ./services/booking-service + build: + context: ./services/booking-service + dockerfile: Dockerfile + restart: unless-stopped environment: - - DATABASE_URL=${BOOKING_DB_URL} - - JWT_SECRET=${JWT_SECRET} - - RABBITMQ_URL=${RABBITMQ_URL} - - INVENTORY_SERVICE_URL=http://inventory-service:3004 - - PAYMENT_SERVICE_URL=http://payment-service:3005 - - EVENT_SERVICE_URL=http://event-service:3002 - - USER_SERVICE_URL=http://user-service:3001 + PORT: 3003 + BOOKING_DB_URL: jdbc:postgresql://postgres:5432/ticketflow_bookings + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 depends_on: postgres: condition: service_healthy - rabbitmq: + kafka: condition: service_healthy + networks: + - ticketflow-net inventory-service: - build: ./services/inventory-service + build: + context: ./services/inventory-service + dockerfile: Dockerfile + restart: unless-stopped environment: - - DATABASE_URL=${INVENTORY_DB_URL} - - REDIS_URL=${REDIS_URL} + PORT: 3004 + INVENTORY_DB_URL: postgresql://${DB_USERNAME}:${DB_PASSWORD}@postgres:5432/ticketflow_inventory + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 depends_on: postgres: condition: service_healthy redis: condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net payment-service: - build: ./services/payment-service + build: + context: ./services/payment-service + dockerfile: Dockerfile + restart: unless-stopped environment: - - DATABASE_URL=${PAYMENT_DB_URL} - - PAYMENT_SUCCESS_RATE=${PAYMENT_SUCCESS_RATE:-0.95} + PORT: 3005 + MONGODB_URL: mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongodb:27017 + MONGODB_DB: ticketflow_payments + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + PAYMENT_SUCCESS_RATE: ${PAYMENT_SUCCESS_RATE:-0.95} depends_on: - postgres: + mongodb: condition: service_healthy + kafka: + condition: service_healthy + networks: + - ticketflow-net notification-service: - build: ./services/notification-service + build: + context: ./services/notification-service + dockerfile: Dockerfile + restart: unless-stopped environment: - - RABBITMQ_URL=${RABBITMQ_URL} - - SMTP_HOST=${SMTP_HOST} - - SMTP_PORT=${SMTP_PORT} + PORT: 3006 + MONGODB_URL: mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongodb:27017 + MONGODB_DB: ticketflow_notifications + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_FROM: ${SMTP_FROM:-noreply@ticketflow.com} + SMTP_USERNAME: ${SMTP_USERNAME} + SMTP_PASSWORD: ${SMTP_PASSWORD} depends_on: - rabbitmq: + mongodb: + condition: service_healthy + kafka: condition: service_healthy + networks: + - ticketflow-net + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "80:80" + environment: + VITE_API_BASE_URL: ${FRONTEND_API_URL:-https://api.yourdomain.com} + depends_on: + - gateway + networks: + - ticketflow-net volumes: postgres_data: + mongodb_data: + redis_data: + kafka_data: + zookeeper_data: + zookeeper_logs: + +networks: + ticketflow-net: + driver: bridge diff --git a/ticketflow/docs/adr/001-polyglot-architecture.md b/ticketflow/docs/adr/001-polyglot-architecture.md new file mode 100644 index 0000000..4108c12 --- /dev/null +++ b/ticketflow/docs/adr/001-polyglot-architecture.md @@ -0,0 +1,87 @@ +# ADR 001 — Polyglot Microservice Architecture + +**Status:** Accepted +**Date:** 2024-01-15 +**Deciders:** Platform Architecture Team + +--- + +## Context + +TicketFlow began as a monoglot Node.js/Express monolith. As the team grew and the domain expanded, we began decomposing the system into microservices. The initial decomposition kept all services on Node.js with the assumption that a single language lowers the operational burden. + +Over time this assumption was challenged by several workload-specific constraints: + +1. **Booking and User services** have strong transactional requirements. Node.js async I/O is well-suited to I/O-bound work, but the lack of true multithreading means CPU-bound saga coordination logic competes with the event loop. Spring Boot with JDK 21 virtual threads handles concurrent request processing and Kafka listener threads more naturally. + +2. **Event and Payment services** deal with schema-flexible, document-oriented data. Python's FastAPI provides automatic OpenAPI generation and Pydantic validation which significantly reduces boilerplate for document-centric APIs. The Stripe Python SDK is also the most mature. + +3. **Inventory and Gateway services** are the highest-throughput services. They proxy requests and perform Redis operations at high frequency. Bun's runtime throughput on these workloads benchmarks 30–40% higher than Node.js 20 with Fastify, at the cost of a smaller ecosystem. + +The question became: is the engineering cost of maintaining multiple runtimes justified by the workload-specific gains? + +--- + +## Decision + +We adopted a **polyglot microservice architecture** with three runtimes: + +| Runtime | Services | +|---|---| +| JDK 21 + Spring Boot 3 | User Service, Booking Service | +| Python 3.12 + FastAPI | Event Service, Payment Service, Notification Service | +| Bun 1.1 + Elysia | API Gateway, Inventory Service | + +--- + +## Rationale + +### Technology → Workload mapping + +| Service | Primary Characteristic | Technology Strength | +|---|---|---| +| API Gateway | High-throughput proxying, JWT verification | Bun: fast startup, native fetch, low overhead | +| User Service | ACID transactions, strong typing, concurrent auth | Spring Boot: mature security, JPA, virtual threads | +| Event Service | Flexible document schema, read-heavy, OpenAPI | FastAPI: Pydantic validation, automatic docs | +| Booking Service | Saga coordinator, transactional DB writes, Kafka | Spring Kafka: exactly-once, mature retry/DLT support | +| Inventory Service | Hot path (thousands of reads/sec), Redis-heavy | Bun: tight loop performance, competitive Redis client | +| Payment Service | Provider SDK integration, webhook callbacks | FastAPI: best-in-class Stripe SDK, async background tasks | +| Notification Service | Template rendering, low-throughput, iteration speed | FastAPI: Jinja2 email templates, simple SMTP integration | + +### Why not Go? + +Go was evaluated for the high-throughput services (Gateway, Inventory). Go's performance is excellent, but: + +- The team has no Go expertise; ramping up on Go + a gateway framework + Kafka client simultaneously carries high risk. +- Bun's TypeScript provides sufficient performance gains over Node.js for our traffic levels. +- TypeScript shared types can be used across Gateway, Inventory, and Frontend. + +### Why not a single language? + +- Forcing Java onto the Notification Service adds unnecessary startup time and operational weight for a low-throughput consumer. +- Forcing Python onto the Booking Service sacrifices Spring's mature transactional saga infrastructure. +- The team already has existing expertise in all three chosen runtimes. + +--- + +## Consequences + +### Positive + +- Each service uses the best tool for its job, reducing incidental complexity. +- Independent deployment pipelines per language (Maven for Java, pip/uv for Python, Bun for TypeScript). +- Failure in one runtime does not cascade across runtimes. +- Strong typing in Java and TypeScript catches schema contract violations at compile time. + +### Negative + +- **CI/CD complexity** — three separate build pipelines, three sets of linters, three testing frameworks. +- **Developer onboarding** — new engineers must be comfortable with at least two runtimes. +- **Shared code** — no shared libraries across runtimes; event payload schemas must be kept in sync manually (see `docs/event-catalog.md`). +- **Operational tooling** — JVM memory tuning, Python GIL considerations, and Bun runtime differences each require separate expertise. + +### Mitigations + +- Developer documentation (`docs/development.md`) provides per-service setup instructions. +- The event catalog is the single source of truth for Kafka payload contracts. +- `make lint` and `make test` run all language-specific checks from a single entry point. diff --git a/ticketflow/docs/adr/002-kafka-over-rabbitmq.md b/ticketflow/docs/adr/002-kafka-over-rabbitmq.md new file mode 100644 index 0000000..9cdc3ba --- /dev/null +++ b/ticketflow/docs/adr/002-kafka-over-rabbitmq.md @@ -0,0 +1,95 @@ +# ADR 002 — Apache Kafka over RabbitMQ + +**Status:** Accepted +**Date:** 2024-02-10 +**Deciders:** Platform Architecture Team + +--- + +## Context + +TicketFlow originally used **RabbitMQ** as its message broker. RabbitMQ was chosen for its simplicity: it is easy to set up, has a mature management UI, and works well for task queues. + +As the booking saga grew in complexity, several limitations surfaced: + +1. **No message replay** — Once a RabbitMQ message is acknowledged, it is gone. If a service is down when a message arrives and the message is not requeued, it is lost. In the booking saga, a lost `seats.locked` event means a payment is never requested and the booking silently stalls in PENDING. + +2. **Consumer group semantics** — RabbitMQ supports competing consumers on a queue, but there is no native concept of a named consumer group that tracks per-consumer offset. Scaling a service means carefully managing exclusive subscriptions or exchange bindings. + +3. **No native KEDA integration** — KEDA supports RabbitMQ via the queue depth metric, but the granularity is per-queue. Kafka's consumer group lag metric gives per-partition granularity, enabling more precise autoscaling. + +4. **Message ordering** — RabbitMQ cannot guarantee ordered delivery to a competing consumer pool. Booking saga steps for the same booking must be processed in order; RabbitMQ requires application-level sequencing logic to achieve this. + +5. **Audit trail** — The booking saga is a financial workflow. Regulators or support teams may need to reconstruct the exact sequence of events for a booking. RabbitMQ provides no durable log once messages are consumed. + +--- + +## Decision + +Replace RabbitMQ with **Apache Kafka** as the sole message broker for all asynchronous inter-service communication. + +--- + +## Rationale + +| Requirement | RabbitMQ | Kafka | +|---|---|---| +| Message replay after crash | No (unless manually requeued) | Yes (offset-based replay, configurable retention) | +| Consumer group with offset tracking | Limited | First-class: consumer group + committed offset | +| Per-partition ordering | No | Yes (partition key routes related events to same partition) | +| KEDA autoscaling precision | Queue depth (coarse) | Consumer group lag per partition (fine) | +| Durable audit log | No | Yes (configurable retention, default 7 days) | +| Exactly-once semantics | No | Yes (transactional producer + idempotent consumer) | +| Schema evolution support | Via plugins | Via header metadata + backward-compatible JSON | + +### Event log replay + +When the Inventory Service is redeployed it commits its current offset on startup. If it was offline during a burst of `booking.initiated` messages, it replays them from the last committed offset without requiring any special re-delivery mechanism. With RabbitMQ, this scenario required a separate retry queue and a dead-letter exchange with carefully tuned TTLs. + +### Partition-based ordering + +All saga events use `bookingId` as the Kafka partition key. Kafka guarantees that all messages with the same key go to the same partition, and all consumers process a partition sequentially. This means the Inventory Service always processes `booking.initiated` before `seats.confirm` for the same booking, without any application-level coordination. + +### KEDA integration + +KEDA's Kafka trigger scales service replicas based on consumer group lag: + +```yaml +triggers: + - type: kafka + metadata: + topic: ticketflow.booking.initiated + consumerGroup: inventory-service + lagThreshold: "50" +``` + +When 50+ unprocessed `booking.initiated` messages accumulate, KEDA adds Inventory Service pods. New pods join the same consumer group and Kafka automatically redistributes partitions. This is impossible to replicate with RabbitMQ without custom metrics. + +--- + +## Consequences + +### Positive + +- Services can recover from crashes by replaying missed messages. +- Booking saga steps are processed in order per booking. +- Kafka topic is an immutable audit log for financial compliance. +- KEDA autoscaling reacts directly to processing backlog. +- Consumer group lag is a meaningful SLO metric. + +### Negative + +- **Operational complexity** — Kafka requires Zookeeper (or KRaft) and is more complex to operate than RabbitMQ. The management UI (Redpanda Console) is provided to mitigate this. +- **Higher resource usage** — Kafka brokers require more memory than RabbitMQ (minimum 1 GB heap per broker). +- **Learning curve** — Kafka concepts (partitions, consumer groups, offsets, lag) are unfamiliar to engineers used to traditional message queues. +- **Not appropriate for every use case** — For simple task queues (e.g. image processing jobs) RabbitMQ or a Redis list would be simpler. Kafka is justified here because of the saga pattern's ordering, replay, and auditability requirements. + +### Migration notes + +The migration from RabbitMQ to Kafka involved: + +1. Replacing `amqplib` (Node.js) with `kafkajs` / Spring Kafka / `confluent-kafka-python`. +2. Redesigning the message schema to include `eventId`, `occurredAt`, and `eventType` fields (Kafka messages have no native routing envelope like AMQP). +3. Adding partition key logic (all saga events keyed by `bookingId`). +4. Updating Docker Compose and Kubernetes manifests. +5. Removing the RabbitMQ management plugin and related monitoring. diff --git a/ticketflow/docs/adr/003-async-saga-pattern.md b/ticketflow/docs/adr/003-async-saga-pattern.md new file mode 100644 index 0000000..6644af7 --- /dev/null +++ b/ticketflow/docs/adr/003-async-saga-pattern.md @@ -0,0 +1,117 @@ +# ADR 003 — Async Choreography Saga for Booking Flow + +**Status:** Accepted +**Date:** 2024-02-20 +**Deciders:** Platform Architecture Team + +--- + +## Context + +The original booking flow was **synchronous HTTP**: the gateway called booking-service, which called inventory-service, then payment-service, then notification-service in a chain. Each call was blocking; if any downstream service timed out, the entire booking failed with a 500 error to the client. + +Problems observed: + +1. **Cascading failures** — A spike in payment-service latency caused gateway timeouts, which caused retry storms, which caused further payment-service overload. +2. **Tight coupling** — Booking-service needed to know the URLs, request/response contracts, and error modes of inventory-service and payment-service. +3. **No partial recovery** — If payment-service succeeded but notification-service was down, the booking was marked as failed and seats were incorrectly released. +4. **Scaling constraint** — All services in the chain had to scale together; if payment-service was slow, it blocked all booking-service threads waiting for a response. +5. **No audit trail** — The sequence of events for a booking existed only in service logs, making post-incident analysis difficult. + +We evaluated two saga approaches: + +- **Orchestration** — A dedicated "booking orchestrator" service sends commands to each participant and tracks the saga state. +- **Choreography** — Each service reacts to events published by other services; there is no central coordinator. + +--- + +## Decision + +Implement the booking lifecycle as a **fully asynchronous choreography saga** via Apache Kafka. The gateway returns `202 Accepted` immediately; the client polls for the result. + +--- + +## Rationale + +### Choreography vs Orchestration + +| Dimension | Orchestration | Choreography | +|---|---|---| +| Central coordinator | Yes (orchestrator service) | No | +| Single point of failure | Yes (if orchestrator fails) | No | +| Service coupling | Services coupled to orchestrator | Services coupled only to Kafka schemas | +| Debugging | Easier (state in orchestrator) | Harder (state distributed across services) | +| Adding new participants | Orchestrator must be updated | New service subscribes to relevant topics | +| Scalability | Orchestrator may become bottleneck | Each participant scales independently | + +For TicketFlow's scale and the team's familiarity with event-driven patterns, choreography provides better resilience at the cost of slightly more complex debugging (mitigated by distributed tracing). + +### Why fully async (202 pattern)? + +The booking saga involves three external interactions: Redis lock, Stripe payment, SMTP send. End-to-end this can take 500ms–3s depending on Stripe latency. Holding an HTTP connection open for this duration: + +- Blocks gateway threads unnecessarily. +- Provides no resilience benefit (if the client disconnects, the saga still needs to complete). +- Requires synchronous propagation of every failure mode back to the client. + +With `202 Accepted` + polling: +- The gateway thread is free immediately after the Booking Service acknowledges. +- The saga runs to completion regardless of client connectivity. +- The client gets a clean polling interface: `GET /api/bookings/:id` returns the current status. + +### Compensating transactions + +Each saga step has a defined compensating action in the failure path. This is the key invariant of the saga pattern: if a step cannot complete, all prior steps must be undone. + +| Step | Compensation | +|---|---| +| Seats locked | Release seats (`seats.release`) | +| Payment processed | Issue refund (future work; currently payment attempted only after successful lock) | + +--- + +## Tradeoffs + +### Eventual consistency + +The booking record does not immediately reflect its final status. Between `202 Accepted` and `CONFIRMED` or `FAILED`, the booking is in `PENDING`. Clients must tolerate this window (typically < 5 seconds). + +This is acceptable for a ticketing platform: the user submits a booking and is told to wait for confirmation, which is standard UX for payment flows. + +### More complex client + +The frontend must implement a polling loop (or WebSocket subscription) rather than a simple request/response. The frontend polls `GET /api/bookings/:id` every 2 seconds with a 60-second timeout. + +### Harder to debug without tooling + +When a saga stalls, there is no single service with a complete state machine to inspect. Investigation requires: + +1. Checking the booking record in the Booking Service DB. +2. Checking consumer group lag in Redpanda Console. +3. Checking service logs in Loki. +4. Following the distributed trace in Jaeger. + +This is why the observability stack (Prometheus, Grafana, Jaeger, Loki) is non-optional infrastructure for TicketFlow. + +### No global transaction + +There is no way to atomically commit state across Booking Service (PostgreSQL) and Inventory Service (PostgreSQL + Redis). The saga pattern accepts this and provides compensating transactions as the recovery mechanism. + +--- + +## Consequences + +### Positive + +- No single point of failure in the booking flow. +- Each service (Inventory, Payment, Notification) scales independently based on its Kafka consumer lag. +- A service crash during a saga does not lose the booking; it resumes when the service restarts. +- The Kafka topic is an immutable audit log of every booking step. +- Adding a new participant (e.g. a loyalty points service) requires only subscribing to `booking.confirmed` — no changes to existing services. + +### Negative + +- Client must poll for final status. +- Eventual consistency window exists between saga start and completion. +- Debugging requires cross-service tooling (Jaeger + Loki + Kafka UI). +- Idempotency must be implemented in every consumer. diff --git a/ticketflow/docs/adr/004-database-per-service.md b/ticketflow/docs/adr/004-database-per-service.md new file mode 100644 index 0000000..f697407 --- /dev/null +++ b/ticketflow/docs/adr/004-database-per-service.md @@ -0,0 +1,85 @@ +# ADR 004 — Database Per Service + +**Status:** Accepted +**Date:** 2024-01-20 +**Deciders:** Platform Architecture Team + +--- + +## Context + +The original TicketFlow monolith used a **single PostgreSQL database** shared across all domain areas. When we decomposed the monolith into microservices, the initial approach kept the single database (shared database anti-pattern) for simplicity. + +This led to several problems: + +1. **Schema coupling** — A migration to the `bookings` table required coordinating deployments of three services simultaneously. +2. **Technology lock-in** — Event data (flexible, nested seat maps) was forced into PostgreSQL JSONB columns rather than a native document store. +3. **Blast radius** — A slow query in the notification service's SELECT statements degraded response times for the booking service. +4. **Independent scaling impossible** — PostgreSQL connection pools were shared; a traffic spike in the event service exhausted connections for the payment service. + +--- + +## Decision + +Each microservice owns exactly one database (or database cluster). No service may connect directly to another service's database. Cross-service data access is only permitted through the service's public API or via Kafka events. + +| Service | Database | Technology | +|---|---|---| +| User Service | `ticketflow_users` | PostgreSQL | +| Booking Service | `ticketflow_bookings` | PostgreSQL | +| Inventory Service | `ticketflow_inventory` | PostgreSQL + Redis | +| Event Service | `ticketflow_events` | MongoDB | +| Payment Service | `ticketflow_payments` | MongoDB | +| Notification Service | `ticketflow_notifications` | MongoDB | + +--- + +## Rationale + +### Technology fit + +| Service | Why PostgreSQL | Why MongoDB | +|---|---|---| +| User Service | User records are relational (user → roles → sessions). ACID required for credential updates. | — | +| Booking Service | Bookings are financial records. ACID non-negotiable. | — | +| Inventory Service | Seat records have a fixed schema and require transactional updates. | — | +| Event Service | — | Events have flexible, nested schemas (seat maps vary per venue, metadata fields per event type). | +| Payment Service | — | Payment records are append-only documents. Provider response shapes vary (Stripe vs PayPal). | +| Notification Service | — | Notification logs are simple append-only documents. No relational queries needed. | + +### Service isolation + +With database-per-service: +- A MongoDB upgrade affects only Python services; PostgreSQL services are unaffected. +- The Booking Service can add a column to `bookings` without any coordination. +- A MongoDB replica set failover does not affect PostgreSQL-backed services. + +### Independent scaling + +Each service manages its own connection pool. The Inventory Service can have 100 Redis connections without affecting the User Service's PostgreSQL pool. + +--- + +## Consequences + +### Positive + +- Services can be deployed independently without database migration coordination. +- Each service uses the database technology that best fits its access patterns. +- Failure of one datastore is isolated to the services that use it. +- Each service's schema can evolve without cross-team coordination. +- Performance isolation: heavy queries in one service do not affect others. + +### Negative + +- **No cross-service joins** — There is no SQL JOIN between `bookings` and `events`. Data needed for a combined view (e.g. booking confirmation email with event details) must be either fetched via API at event time or duplicated in the event payload. +- **Data duplication** — The `booking.confirmed` Kafka event carries a copy of `eventName`, `venueName`, and `eventDate` so the Notification Service does not need to query the Event Service. +- **Eventual consistency** — A user's booking list cannot be joined with real-time event data in a single query. The frontend fetches bookings, then fetches event details for each booking separately (or relies on cached data in the booking record). +- **Operational complexity** — Running PostgreSQL, MongoDB, and Redis in production requires three sets of backup procedures, monitoring dashboards, and operational runbooks. + +### Mitigations + +- Kafka events carry denormalised copies of the data consumers need (see `docs/event-catalog.md`). +- The `docs/architecture.md` section on database selection documents which data lives where. +- Backup and restore procedures are documented in `docs/deployment.md`. +- Grafana dashboards monitor all three datastores from a single pane. diff --git a/ticketflow/docs/api-reference.md b/ticketflow/docs/api-reference.md new file mode 100644 index 0000000..5009787 --- /dev/null +++ b/ticketflow/docs/api-reference.md @@ -0,0 +1,717 @@ +# API Reference + +All requests are routed through the API Gateway at `http://localhost:3000` (dev) or `https://tickets.yourdomain.com` (prod). + +## Table of Contents + +- [Authentication](#authentication) +- [Common Response Formats](#common-response-formats) +- [Gateway](#gateway) +- [User Service](#user-service) +- [Event Service](#event-service) +- [Booking Service](#booking-service) +- [Inventory Service](#inventory-service) +- [Payment Service](#payment-service) +- [Notification Service](#notification-service) + +--- + +## Authentication + +TicketFlow uses **JWT Bearer tokens** for authentication. + +### Obtaining a token + +```bash +curl -s -X POST http://localhost:3000/api/users/login \ + -H "Content-Type: application/json" \ + -d '{"email": "test@ticketflow.dev", "password": "Test1234!"}' +``` + +Response: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expiresAt": "2024-06-02T10:00:00Z", + "user": { + "id": "usr_01HX9ABC456", + "name": "Test User", + "email": "test@ticketflow.dev", + "role": "USER" + } +} +``` + +### Using the token + +Include the token in the `Authorization` header: + +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### JWT payload + +```json +{ + "sub": "usr_01HX9ABC456", + "name": "Test User", + "email": "test@ticketflow.dev", + "role": "USER", + "iat": 1717228800, + "exp": 1717315200 +} +``` + +### Roles + +| Role | Description | +|---|---| +| `USER` | Standard user — can browse events, make bookings, view own data | +| `ADMIN` | Can create/update events and venues | + +--- + +## Common Response Formats + +### Success + +```json +{ + "data": { ... }, + "meta": { + "requestId": "req_01HX...", + "timestamp": "2024-06-01T10:00:00Z" + } +} +``` + +For collections: + +```json +{ + "data": [ ... ], + "pagination": { + "page": 1, + "perPage": 20, + "total": 142, + "totalPages": 8 + } +} +``` + +### Error + +```json +{ + "error": { + "code": "BOOKING_NOT_FOUND", + "message": "Booking bkg_01HZ3ABC does not exist", + "requestId": "req_01HX..." + } +} +``` + +### HTTP Status Codes + +| Code | Meaning | +|---|---| +| `200` | OK | +| `201` | Created | +| `202` | Accepted (async operation started) | +| `400` | Bad Request — validation error | +| `401` | Unauthorized — missing or invalid token | +| `403` | Forbidden — valid token but insufficient role | +| `404` | Not Found | +| `409` | Conflict — e.g. email already registered | +| `422` | Unprocessable Entity — business rule violation | +| `500` | Internal Server Error | + +--- + +## Gateway + +### GET /health + +Health check for the gateway itself. + +**Auth:** None + +**Response `200`:** + +```json +{ + "status": "ok", + "version": "1.0.0", + "uptime": 3600 +} +``` + +--- + +## User Service + +Base path: `/api/users` + +--- + +### POST /api/users/register + +Register a new user account. + +**Auth:** None + +**Request Body:** + +```json +{ + "name": "string (required, 2-100 chars)", + "email": "string (required, valid email)", + "password": "string (required, min 8 chars, must contain uppercase, number)" +} +``` + +**Response `201`:** + +```json +{ + "id": "usr_01HX9ABC456", + "name": "Alice Example", + "email": "alice@example.com", + "role": "USER", + "createdAt": "2024-06-01T10:00:00Z" +} +``` + +**Error Codes:** + +| Code | HTTP | Description | +|---|---|---| +| `EMAIL_ALREADY_EXISTS` | 409 | Email is already registered | +| `VALIDATION_ERROR` | 400 | Invalid name, email, or weak password | + +--- + +### POST /api/users/login + +Authenticate and receive a JWT. + +**Auth:** None + +**Request Body:** + +```json +{ + "email": "string (required)", + "password": "string (required)" +} +``` + +**Response `200`:** + +```json +{ + "token": "eyJ...", + "expiresAt": "2024-06-02T10:00:00Z", + "user": { + "id": "usr_01HX9ABC456", + "name": "Alice Example", + "email": "alice@example.com", + "role": "USER" + } +} +``` + +**Error Codes:** + +| Code | HTTP | Description | +|---|---|---| +| `INVALID_CREDENTIALS` | 401 | Email/password combination incorrect | + +--- + +### GET /api/users/me + +Get the authenticated user's profile. + +**Auth:** Required + +**Response `200`:** + +```json +{ + "id": "usr_01HX9ABC456", + "name": "Alice Example", + "email": "alice@example.com", + "role": "USER", + "createdAt": "2024-06-01T10:00:00Z" +} +``` + +--- + +## Event Service + +Base path: `/api/events`, `/api/venues` + +--- + +### GET /api/events + +List all upcoming events, ordered by date ascending. + +**Auth:** None + +**Query Parameters:** + +| Parameter | Type | Description | +|---|---|---| +| `page` | integer | Page number (default: 1) | +| `perPage` | integer | Items per page (default: 20, max: 100) | +| `venueId` | string | Filter by venue | +| `from` | ISO-8601 date | Filter events starting on or after date | +| `to` | ISO-8601 date | Filter events starting before date | + +**Response `200`:** + +```json +{ + "data": [ + { + "id": "evnt_01HX1ABC", + "name": "Coldplay: Music of the Spheres", + "description": "World tour 2024", + "venue": { + "id": "ven_01HX2DEF", + "name": "O2 Arena", + "city": "London" + }, + "startsAt": "2024-09-15T19:30:00Z", + "endsAt": "2024-09-15T23:00:00Z", + "availableSeats": 14320, + "totalSeats": 20000, + "ticketTiers": [ + {"tier": "GENERAL", "price": 7500, "currency": "GBP", "available": 12000}, + {"tier": "VIP", "price": 25000, "currency": "GBP", "available": 2320} + ] + } + ], + "pagination": { "page": 1, "perPage": 20, "total": 4, "totalPages": 1 } +} +``` + +--- + +### GET /api/events/:id + +Get full details for a single event. + +**Auth:** None + +**Response `200`:** Same as a single item from the list above, plus full `seatMap` object. + +**Error Codes:** + +| Code | HTTP | Description | +|---|---|---| +| `EVENT_NOT_FOUND` | 404 | Event ID does not exist | + +--- + +### POST /api/events + +Create a new event. + +**Auth:** Required (ADMIN role) + +**Request Body:** + +```json +{ + "name": "string (required)", + "description": "string", + "venueId": "string (required)", + "startsAt": "ISO-8601 datetime (required)", + "endsAt": "ISO-8601 datetime (required)", + "ticketTiers": [ + { + "tier": "string (GENERAL | VIP | STANDING)", + "price": "integer (minor currency units)", + "currency": "string (ISO 4217)", + "capacity": "integer" + } + ] +} +``` + +**Response `201`:** + +```json +{ + "id": "evnt_02HY2DEF", + "name": "New Event", + "createdAt": "2024-06-01T12:00:00Z" +} +``` + +--- + +### PUT /api/events/:id + +Update an existing event. + +**Auth:** Required (ADMIN role) + +**Request Body:** Same fields as POST, all optional. + +**Response `200`:** Updated event object. + +--- + +### POST /api/venues + +Create a venue. + +**Auth:** Required (ADMIN role) + +**Request Body:** + +```json +{ + "name": "string (required)", + "address": "string (required)", + "city": "string (required)", + "country": "string (required, ISO 3166-1 alpha-2)", + "capacity": "integer (required)" +} +``` + +**Response `201`:** + +```json +{ + "id": "ven_02HY3GHI", + "name": "Madison Square Garden", + "city": "New York", + "country": "US", + "capacity": 20789 +} +``` + +--- + +### GET /api/venues/:id + +Get venue details. + +**Auth:** None + +**Response `200`:** Venue object including events scheduled at this venue. + +--- + +## Booking Service + +Base path: `/api/bookings` + +--- + +### POST /api/bookings + +Initiate a new booking. This is an **asynchronous** operation. The server returns `202 Accepted` immediately and the booking is processed via the Kafka saga. Poll `GET /api/bookings/:id` for the final status. + +**Auth:** Required + +**Request Body:** + +```json +{ + "eventId": "string (required)", + "seatIds": ["string (required, 1-10 seats)"], + "paymentMethod": { + "type": "card", + "token": "string (Stripe payment method token)" + } +} +``` + +**Response `202`:** + +```json +{ + "bookingId": "bkg_01HZ3ABC", + "status": "PENDING", + "message": "Booking is being processed. Poll GET /api/bookings/bkg_01HZ3ABC for status." +} +``` + +**Error Codes:** + +| Code | HTTP | Description | +|---|---|---| +| `EVENT_NOT_FOUND` | 404 | Event ID does not exist | +| `INVALID_SEAT_COUNT` | 400 | Must book between 1 and 10 seats | +| `VALIDATION_ERROR` | 400 | Missing required fields | + +--- + +### GET /api/bookings/my + +List all bookings for the authenticated user. + +**Auth:** Required + +**Query Parameters:** + +| Parameter | Type | Description | +|---|---|---| +| `status` | string | Filter: PENDING, CONFIRMED, FAILED, CANCELLED | +| `page` | integer | Page number | + +**Response `200`:** + +```json +{ + "data": [ + { + "id": "bkg_01HZ3ABC", + "eventId": "evnt_01HX1ABC", + "eventName": "Coldplay: Music of the Spheres", + "eventDate": "2024-09-15T19:30:00Z", + "seatIds": ["seat_A1", "seat_A2"], + "status": "CONFIRMED", + "totalAmount": 15000, + "currency": "GBP", + "createdAt": "2024-06-15T14:22:00Z", + "confirmedAt": "2024-06-15T14:22:05Z" + } + ] +} +``` + +--- + +### GET /api/bookings/:id + +Get the current status of a specific booking. + +**Auth:** Required (must be the booking owner or ADMIN) + +**Response `200`:** + +```json +{ + "id": "bkg_01HZ3ABC", + "userId": "usr_01HX9ABC456", + "eventId": "evnt_01HX1ABC", + "eventName": "Coldplay: Music of the Spheres", + "eventDate": "2024-09-15T19:30:00Z", + "venueName": "O2 Arena", + "seatIds": ["seat_A1", "seat_A2"], + "status": "CONFIRMED", + "totalAmount": 15000, + "currency": "GBP", + "paymentId": "pay_01HZ5DEF", + "createdAt": "2024-06-15T14:22:00Z", + "confirmedAt": "2024-06-15T14:22:05Z" +} +``` + +**Booking Status Values:** + +| Status | Description | +|---|---| +| `PENDING` | Saga is in progress | +| `CONFIRMED` | Payment successful, seats reserved | +| `FAILED` | Saga failed (seat unavailable or payment declined) | +| `CANCELLED` | User cancelled the booking | + +--- + +### POST /api/bookings/:id/cancel + +Cancel a confirmed booking. + +**Auth:** Required (must be booking owner) + +**Request Body:** Empty `{}` + +**Response `200`:** + +```json +{ + "bookingId": "bkg_01HZ3ABC", + "status": "CANCELLED", + "refundAmount": 15000, + "currency": "GBP", + "cancelledAt": "2024-06-16T09:00:00Z" +} +``` + +**Error Codes:** + +| Code | HTTP | Description | +|---|---|---| +| `BOOKING_NOT_FOUND` | 404 | Booking ID does not exist | +| `BOOKING_NOT_CANCELLABLE` | 422 | Booking is not in CONFIRMED status | +| `CANCELLATION_WINDOW_EXPIRED` | 422 | Event is less than 24 hours away | + +--- + +## Inventory Service + +Base path: `/api/inventory` + +--- + +### GET /api/inventory/events/:eventId/seats + +Get the current availability status of all seats for an event. + +**Auth:** None + +**Response `200`:** + +```json +{ + "eventId": "evnt_01HX1ABC", + "seats": [ + { + "id": "seat_A1", + "row": "A", + "number": 1, + "tier": "VIP", + "price": 25000, + "currency": "GBP", + "status": "AVAILABLE" + }, + { + "id": "seat_A2", + "row": "A", + "number": 2, + "tier": "VIP", + "price": 25000, + "currency": "GBP", + "status": "LOCKED" + }, + { + "id": "seat_B1", + "row": "B", + "number": 1, + "tier": "GENERAL", + "price": 7500, + "currency": "GBP", + "status": "RESERVED" + } + ], + "summary": { + "total": 20000, + "available": 14320, + "locked": 150, + "reserved": 5530 + } +} +``` + +**Seat Status Values:** + +| Status | Description | +|---|---| +| `AVAILABLE` | Can be booked | +| `LOCKED` | Temporarily held (Redis TTL, max 5 min) | +| `RESERVED` | Sold — booking confirmed | + +--- + +## Payment Service + +Base path: `/api/payments` + +--- + +### GET /api/payments/:id + +Get details of a specific payment. + +**Auth:** Required (must be payment owner or ADMIN) + +**Response `200`:** + +```json +{ + "id": "pay_01HZ5DEF", + "bookingId": "bkg_01HZ3ABC", + "userId": "usr_01HX9ABC456", + "amount": 15000, + "currency": "GBP", + "status": "SUCCESS", + "providerTransactionId": "ch_3PQR7sSt8uVwXyZ9", + "processedAt": "2024-06-15T14:22:03Z" +} +``` + +--- + +### GET /api/payments/booking/:bookingId + +Get the payment associated with a booking. + +**Auth:** Required (must be booking owner or ADMIN) + +**Response `200`:** Same as `GET /api/payments/:id`. + +**Error Codes:** + +| Code | HTTP | Description | +|---|---|---| +| `PAYMENT_NOT_FOUND` | 404 | No payment found for booking | + +--- + +## Notification Service + +Base path: `/api/notifications` + +--- + +### GET /api/notifications/recent + +Get the most recent 20 notifications for the authenticated user. + +**Auth:** Required + +**Response `200`:** + +```json +{ + "data": [ + { + "id": "notif_01HZ7GHI", + "type": "BOOKING_CONFIRMED", + "subject": "Your booking is confirmed!", + "sentAt": "2024-06-15T14:22:05Z", + "bookingId": "bkg_01HZ3ABC" + } + ] +} +``` + +--- + +### GET /api/notifications/health + +Health check for the Notification Service. + +**Auth:** None + +**Response `200`:** + +```json +{ + "status": "ok", + "kafka": "connected", + "mongodb": "connected", + "smtp": "reachable" +} +``` diff --git a/ticketflow/docs/architecture.md b/ticketflow/docs/architecture.md new file mode 100644 index 0000000..ef21fdd --- /dev/null +++ b/ticketflow/docs/architecture.md @@ -0,0 +1,358 @@ +# Architecture Deep-Dive + +## Table of Contents + +- [System Overview](#system-overview) +- [Design Principles](#design-principles) +- [Service Responsibilities](#service-responsibilities) +- [Communication Patterns](#communication-patterns) +- [Choreography Saga Pattern](#choreography-saga-pattern) +- [Database Selection Rationale](#database-selection-rationale) +- [Kafka Topic Design](#kafka-topic-design) +- [Seat Locking Algorithm](#seat-locking-algorithm) +- [JWT Authentication Flow](#jwt-authentication-flow) +- [Error Handling Strategy](#error-handling-strategy) +- [Resilience Patterns](#resilience-patterns) +- [Scalability Analysis](#scalability-analysis) +- [Security Considerations](#security-considerations) + +--- + +## System Overview + +TicketFlow is composed of eight independently deployable services that communicate via two channels: + +- **Synchronous HTTP** — used only from the API Gateway to individual services for direct client requests (reads, user management). The gateway is the single entry point; no service calls another service over HTTP. +- **Asynchronous Kafka** — used for all inter-service coordination, including the booking saga, notifications, and inventory management. + +This strict separation means each service is unaware of the existence of other services at the network level. Coupling is limited to shared Kafka topic schemas (defined in `docs/event-catalog.md`). + +--- + +## Design Principles + +### 1. Database Per Service + +Every service owns exactly one datastore. No two services share a database schema, a connection pool, or a set of tables. This enforces: + +- **Independent schema evolution** — a service can migrate its schema without coordination. +- **Technology fit** — relational data goes to PostgreSQL; document data goes to MongoDB. +- **Fault isolation** — a MongoDB outage affects only the services that use it. + +### 2. Single Responsibility + +Each service does one thing: + +| Service | Single Responsibility | +|---|---| +| API Gateway | Authenticate requests and proxy to downstream services | +| User Service | Manage user identities and credentials | +| Event Service | Manage event and venue data | +| Booking Service | Coordinate the booking lifecycle | +| Inventory Service | Manage seat state and locks | +| Payment Service | Process and record payments | +| Notification Service | Deliver email notifications | + +### 3. Event-Driven by Default + +Services publish events to Kafka when their state changes. Other services react to those events. No service invokes another service's internal logic; the only contract is the event payload schema. + +### 4. Idempotent Consumers + +Every Kafka consumer is designed to process the same message multiple times without side effects. This is achieved via: + +- Unique constraint checks before DB writes. +- Redis `SET NX` for distributed locks. +- Idempotency keys on payment records. + +--- + +## Service Responsibilities + +| Service | Runtime | Owns | Produces | Consumes | +|---|---|---|---|---| +| API Gateway | Bun + Elysia | — | — | — | +| User Service | Spring Boot 3 | `users` table | `user.registered` | — | +| Event Service | FastAPI | `events`, `venues` collections | `event.created` | — | +| Booking Service | Spring Boot 3 | `bookings` table | `booking.initiated`, `payment.requested`, `booking.confirmed`, `booking.failed`, `seats.confirm`, `seats.release` | `seats.locked`, `seats.lock-failed`, `payment.processed` | +| Inventory Service | Bun + Elysia | `seats` table, Redis keys | `seats.locked`, `seats.lock-failed` | `booking.initiated`, `seats.confirm`, `seats.release`, `booking.cancelled` | +| Payment Service | FastAPI | `payments` collection | `payment.processed` | `payment.requested` | +| Notification Service | FastAPI | `notifications` collection | — | `booking.confirmed`, `booking.failed`, `booking.cancelled`, `user.registered` | + +--- + +## Communication Patterns + +### Synchronous (HTTP) + +Used only for client-facing requests where immediate data is needed: + +``` +Client → Gateway → Service (HTTP/1.1 or HTTP/2) +``` + +All HTTP communication is internal to the Docker/Kubernetes network except the gateway's public port. Services do not expose ports directly. + +Gateway routing rules: + +| Path Prefix | Target Service | +|---|---| +| `/api/users/*` | `user-service:3001` | +| `/api/events/*` | `event-service:3002` | +| `/api/bookings/*` | `booking-service:3003` | +| `/api/inventory/*` | `inventory-service:3004` | +| `/api/payments/*` | `payment-service:3005` | +| `/api/notifications/*` | `notification-service:3006` | + +### Asynchronous (Kafka) + +Used for all inter-service state propagation. The pattern is: + +1. A service completes a local transaction and commits to its own database. +2. The service produces a Kafka event in the same transaction (transactional outbox pattern). +3. Downstream services consume the event and react. + +This guarantees that a service never publishes an event about a state change that was rolled back. + +--- + +## Choreography Saga Pattern + +### Why Choreography over Orchestration? + +An orchestrator is a single service that tells every other service what to do next. It becomes a central point of failure and a deployment bottleneck. In choreography, each service knows only its own role and reacts to events it cares about — there is no coordinator. + +### Booking Saga Steps + +| Step | Event In | Actor | Local Action | Event Out | +|---|---|---|---|---| +| 1 | `POST /bookings` (HTTP) | Booking Service | Insert booking (PENDING) | `booking.initiated` | +| 2 | `booking.initiated` | Inventory Service | Redis SETNX per seat, update DB (LOCKED) | `seats.locked` or `seats.lock-failed` | +| 3a | `seats.locked` | Booking Service | No state change | `payment.requested` | +| 3b | `seats.lock-failed` | Booking Service | Update booking (FAILED) | `booking.failed` | +| 4 | `payment.requested` | Payment Service | Charge card, insert payment record | `payment.processed` (SUCCESS or FAILED) | +| 5a | `payment.processed` (SUCCESS) | Booking Service | Update booking (CONFIRMED) | `booking.confirmed`, `seats.confirm` | +| 5b | `payment.processed` (FAILED) | Booking Service | Update booking (FAILED) | `booking.failed`, `seats.release` | +| 6 | `seats.confirm` | Inventory Service | Update seats (RESERVED), delete Redis keys | — | +| 7 | `seats.release` | Inventory Service | Update seats (AVAILABLE), delete Redis keys | — | +| 8 | `booking.confirmed` | Notification Service | Send email, insert notification log | — | +| 9 | `booking.failed` | Notification Service | Send failure email | — | + +### Compensating Transactions + +| Failed Step | Compensation | +|---|---| +| Seat lock fails | Booking set FAILED, no payment attempted | +| Payment fails | `seats.release` sent, seats returned to AVAILABLE | +| Notification fails | Retry with exponential backoff; dead-letter after 5 attempts | +| Inventory Service down during confirm | Message sits in Kafka; processed when service recovers | + +--- + +## Database Selection Rationale + +| Service | Database | Why | +|---|---|---| +| User Service | PostgreSQL | Users require ACID transactions (unique email, atomic credential updates). Relational integrity is valuable for user-role associations. | +| Booking Service | PostgreSQL | Bookings are financial records. ACID is non-negotiable. Strong consistency for the booking state machine. | +| Inventory Service | PostgreSQL + Redis | PostgreSQL for durable seat records. Redis for fast distributed TTL-based seat locks (SETNX). | +| Event Service | MongoDB | Events have flexible, nested schemas (seat maps, metadata, ticket tiers). Document model fits naturally; no relations needed. | +| Payment Service | MongoDB | Payment records are append-only and schema-flexible (different payment provider response shapes). | +| Notification Service | MongoDB | Notification logs are append-only documents. Query patterns are simple (find recent by user). | + +--- + +## Kafka Topic Design + +### Naming Convention + +``` +ticketflow.. +``` + +- `ticketflow` — platform namespace (allows multi-tenant broker sharing) +- `` — the bounded context (`user`, `event`, `booking`, `seats`, `payment`) +- `` — past-tense verb describing what happened + +### Partition Strategy + +| Topic | Partitions | Partition Key | Rationale | +|---|---|---|---| +| `ticketflow.booking.initiated` | 6 | `bookingId` | Ensures ordered processing per booking | +| `ticketflow.seats.locked` | 6 | `bookingId` | Same booking must process in order | +| `ticketflow.payment.requested` | 6 | `bookingId` | Idempotent payment per booking | +| `ticketflow.payment.processed` | 6 | `bookingId` | Correlate back to booking | +| `ticketflow.booking.confirmed` | 3 | `userId` | Fan-out to notification, balanced | +| `ticketflow.user.registered` | 3 | `userId` | Low volume, user-scoped | +| `ticketflow.event.created` | 3 | `eventId` | Low volume | + +### Retention Policy + +- **Saga topics** (`booking.*`, `seats.*`, `payment.*`): 7-day retention, compaction disabled. Enables replay of recent saga steps. +- **Notification topics** (`user.registered`, `booking.confirmed`): 3-day retention. Notifications are best-effort. +- **Dead-letter topics** (`*.DLT`): 30-day retention for incident investigation. + +### Consumer Groups + +| Group ID | Topics Consumed | +|---|---| +| `inventory-service` | `booking.initiated`, `seats.confirm`, `seats.release`, `booking.cancelled` | +| `payment-service` | `payment.requested` | +| `booking-service` | `seats.locked`, `seats.lock-failed`, `payment.processed` | +| `notification-service` | `booking.confirmed`, `booking.failed`, `booking.cancelled`, `user.registered` | + +--- + +## Seat Locking Algorithm + +Seat locking prevents double-booking under concurrent load. The algorithm runs in the Inventory Service. + +### Step 1 — Acquire Locks (Redis SETNX) + +For each requested seat: + +``` +SET seat:{eventId}:{seatId} {bookingId} NX PX 300000 +``` + +- `NX` — only set if the key does not exist (atomic). +- `PX 300000` — TTL of 5 minutes; locks expire if the saga does not complete. + +If **all** seats are locked successfully → publish `seats.locked`. + +If **any** seat fails to lock (key already exists) → roll back already-acquired locks: + +``` +DEL seat:{eventId}:{seatId} # for each already-locked seat +``` + +Then publish `seats.lock-failed` with the conflicting seat IDs. + +### Step 2 — Confirm Locks (on `seats.confirm`) + +```sql +UPDATE seats SET status = 'RESERVED', booking_id = ? WHERE id = ? AND status = 'LOCKED' +``` + +Then delete Redis keys: + +``` +DEL seat:{eventId}:{seatId} +``` + +### Step 3 — Release Locks (on `seats.release` or TTL expiry) + +```sql +UPDATE seats SET status = 'AVAILABLE', booking_id = NULL WHERE id = ? AND booking_id = ? +``` + +Redis keys are either explicitly deleted or expire via TTL. + +### Idempotency + +The lock acquisition checks the Redis key value against the `bookingId`. If the same `bookingId` attempts to lock a seat it already holds (message replay), the operation succeeds without duplication. + +--- + +## JWT Authentication Flow + +``` +Client Gateway User Service + │ │ │ + │ POST /api/users/login │ │ + │ ─────────────────────────>│ │ + │ │ POST /internal/auth │ + │ │ ──────────────────────────>│ + │ │ JWT token │ + │ │ <──────────────────────────│ + │ {token: "..."} │ │ + │ <─────────────────────────│ │ + │ │ │ + │ GET /api/bookings/my │ │ + │ Authorization: Bearer … │ │ + │ ─────────────────────────>│ │ + │ │ Verify JWT locally │ + │ │ Inject X-User-Id header │ + │ │ ─────────────────────────> Booking Service + │ │ │ +``` + +Key points: + +1. The gateway holds the `JWT_SECRET` and verifies tokens locally — no round-trip to the User Service on every request. +2. On successful verification the gateway injects `X-User-Id` and `X-User-Role` headers before forwarding. +3. Downstream services trust these headers (they are not exposed externally). +4. Token expiry is enforced by the gateway; expired tokens receive `401 Unauthorized` before the request reaches any service. + +--- + +## Error Handling Strategy + +### HTTP Errors + +The gateway normalises all upstream errors to a consistent shape: + +```json +{ + "error": { + "code": "BOOKING_NOT_FOUND", + "message": "Booking abc123 does not exist", + "requestId": "req_01HX..." + } +} +``` + +HTTP status codes follow RFC 7807 semantics: `400` for client errors, `404` for not-found, `409` for conflicts, `500` for upstream failures. + +### Kafka Consumer Errors + +1. **Transient errors** (DB unavailable, Redis timeout): message is not acknowledged; Kafka re-delivers after the consumer's `max.poll.interval.ms`. +2. **Permanent errors** (schema violation, corrupt payload): after 3 retries with exponential backoff the message is forwarded to the Dead-Letter Topic (`*.DLT`). +3. **DLT monitoring**: Grafana alert fires when any DLT partition has messages. On-call engineer investigates and replays or discards. + +--- + +## Resilience Patterns + +| Pattern | Where Applied | Implementation | +|---|---|---| +| Retry with backoff | All Kafka consumers | Spring Retry / tenacity (Python) | +| Dead-letter topic | All Kafka consumers | Spring Kafka `DeadLetterPublishingRecoverer` | +| Circuit breaker | Gateway → downstream HTTP | Elysia middleware with half-open state | +| Idempotency keys | Payment Service | MongoDB unique index on `idempotencyKey` | +| TTL-based lock expiry | Inventory Service | Redis key TTL (5 minutes) | +| Transactional outbox | Booking Service | Spring `@Transactional` wrapping DB write + Kafka publish | +| Health checks | All services | `/health` or `/actuator/health` endpoints | +| Graceful shutdown | All services | SIGTERM → drain in-flight requests → close Kafka consumer | + +--- + +## Scalability Analysis + +| Service | Scaling Dimension | Bottleneck | KEDA Trigger | +|---|---|---|---| +| API Gateway | Request rate | CPU / connections | HTTP RPS (custom metric) | +| User Service | Login throughput | DB connection pool | CPU utilisation | +| Event Service | Read throughput | MongoDB read IOPS | HTTP RPS | +| Booking Service | Saga throughput | Kafka consumer lag | `ticketflow.booking.initiated` lag | +| Inventory Service | Lock throughput | Redis ops/sec | `ticketflow.booking.initiated` lag | +| Payment Service | Payment throughput | External API rate limits | `ticketflow.payment.requested` lag | +| Notification Service | Email throughput | SMTP rate limits | `ticketflow.booking.confirmed` lag | + +Inventory Service and Payment Service are the primary bottlenecks in the booking saga. KEDA monitors consumer group lag and adds pods when lag exceeds 50 messages per partition. + +--- + +## Security Considerations + +| Concern | Mitigation | +|---|---| +| JWT secret exposure | Secret stored in Kubernetes Secret, injected as env var, never logged | +| SQL injection | Parameterised queries via JPA / psycopg3 | +| MongoDB injection | Pydantic model validation before any DB operation | +| Kafka topic access | SASL/SCRAM authentication in production; ACLs per consumer group | +| Secrets in Docker Compose | `.env` excluded from git via `.gitignore`; `.env.example` has no real values | +| Stripe webhook verification | Signature verified using `STRIPE_WEBHOOK_SECRET` before processing | +| Cross-service trust | `X-User-Id` header only injected by the gateway; services behind internal network | +| Rate limiting | Gateway enforces per-IP rate limits on public endpoints | +| TLS | All traffic encrypted in production via NGINX TLS termination or Kubernetes Ingress with cert-manager | diff --git a/ticketflow/docs/deployment.md b/ticketflow/docs/deployment.md new file mode 100644 index 0000000..fcc6f54 --- /dev/null +++ b/ticketflow/docs/deployment.md @@ -0,0 +1,441 @@ +# Deployment Guide + +## Table of Contents + +- [Docker Compose (Production)](#docker-compose-production) +- [Kubernetes](#kubernetes) +- [KEDA Autoscaling](#keda-autoscaling) +- [Rolling Updates and Rollbacks](#rolling-updates-and-rollbacks) +- [Monitoring in Production](#monitoring-in-production) +- [Database Backup and Restore](#database-backup-and-restore) + +--- + +## Docker Compose (Production) + +Docker Compose production mode uses `docker-compose.prod.yml` which removes dev-only services (Mailpit, Mongo Express, Redpanda Console) and enables TLS through NGINX. + +### 1. Prepare environment + +```bash +cp .env.example .env +# Edit .env with production values: +# - Strong JWT_SECRET (min 64 chars) +# - Secure POSTGRES_PASSWORD, MONGO_INITDB_ROOT_PASSWORD, REDIS_PASSWORD +# - Real STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET +# - Production SMTP settings (replace Mailpit with real SMTP) +# - Set EMAIL_FROM to your domain +nano .env +``` + +### 2. Build images + +```bash +make build +# Equivalent to: docker compose -f docker-compose.yml -f docker-compose.prod.yml build +``` + +### 3. Start the stack + +```bash +make prod-up +# Equivalent to: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +### 4. Verify health + +```bash +make health +``` + +### NGINX TLS Termination + +Create `/etc/nginx/conf.d/ticketflow.conf`: + +```nginx +upstream ticketflow_gateway { + server 127.0.0.1:3000; +} + +server { + listen 80; + server_name tickets.yourdomain.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name tickets.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/tickets.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/tickets.yourdomain.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://ticketflow_gateway; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +``` + +Obtain a certificate with Certbot: + +```bash +certbot --nginx -d tickets.yourdomain.com +``` + +--- + +## Kubernetes + +### Prerequisites + +| Tool | Install | +|---|---| +| `kubectl` | [kubernetes.io/docs](https://kubernetes.io/docs/tasks/tools/) | +| `helm` | `brew install helm` | +| KEDA operator | Installed via Helm (see below) | +| Container registry | Docker Hub, GCR, ECR, or GHCR | + +### 1. Install KEDA + +```bash +helm repo add kedacore https://kedacore.github.io/charts +helm repo update +helm install keda kedacore/keda \ + --namespace keda \ + --create-namespace +``` + +Verify: + +```bash +kubectl get pods -n keda +``` + +### 2. Create the namespace + +```bash +kubectl apply -f k8s/namespace.yaml +# Creates: ticketflow namespace +``` + +### 3. Configure secrets + +Copy the secrets template and populate with production values: + +```bash +cp k8s/secrets/app-secrets.yaml.example k8s/secrets/app-secrets.yaml +nano k8s/secrets/app-secrets.yaml +``` + +The file uses base64-encoded values: + +```bash +# Encode a value: +echo -n "your-secret-value" | base64 +``` + +Apply: + +```bash +kubectl apply -f k8s/secrets/app-secrets.yaml +``` + +### 4. Update image registry + +Edit the image field in each deployment manifest to point to your registry: + +```bash +# Example: update all manifests to use your Docker Hub username +find k8s/ -name "deployment.yaml" \ + -exec sed -i 's|image: ticketflow/|image: yourdockerhubuser/ticketflow-|g' {} + +``` + +### 5. Deploy infrastructure + +```bash +# Deploy Kafka, PostgreSQL, MongoDB, Redis +kubectl apply -f k8s/infrastructure/ + +# Wait for infrastructure to be ready +kubectl wait --for=condition=ready pod \ + -l app=kafka \ + -n ticketflow \ + --timeout=120s +``` + +### 6. Deploy application services + +```bash +make k8s-apply +# Equivalent to: kubectl apply -f k8s/ --recursive +``` + +Or deploy services individually: + +```bash +kubectl apply -f k8s/gateway/ +kubectl apply -f k8s/services/user-service/ +kubectl apply -f k8s/services/event-service/ +kubectl apply -f k8s/services/booking-service/ +kubectl apply -f k8s/services/inventory-service/ +kubectl apply -f k8s/services/payment-service/ +kubectl apply -f k8s/services/notification-service/ +``` + +### 7. Verify deployment + +```bash +make k8s-status +# Equivalent to: kubectl get pods,services,deployments -n ticketflow +``` + +Expected output: + +``` +NAME READY STATUS RESTARTS AGE +pod/gateway-6b7d9c6d9f-xkj2p 1/1 Running 0 2m +pod/user-service-5c8d7b9c8f-pqr3s 1/1 Running 0 2m +pod/event-service-7f9c6d8b7f-lmn4t 1/1 Running 0 2m +pod/booking-service-8d6c7b5a9f-uvw5k 1/1 Running 0 2m +pod/inventory-service-4b9d8c7f6d-xyz6 1/1 Running 0 2m +pod/payment-service-3a8b7c6d5e-abc7 1/1 Running 0 2m +pod/notification-service-2b7c6d5f-def8 1/1 Running 0 2m +``` + +### 8. Accessing services in Kubernetes + +#### Port-forward for local testing + +```bash +# API Gateway +kubectl port-forward -n ticketflow svc/gateway 3000:3000 + +# Grafana +kubectl port-forward -n ticketflow svc/grafana 3010:3000 + +# Jaeger +kubectl port-forward -n ticketflow svc/jaeger 16686:16686 +``` + +#### Production Ingress + +Apply the Ingress resource (edit the hostname first): + +```bash +kubectl apply -f k8s/ingress.yaml +``` + +The Ingress uses `cert-manager` for automatic TLS. Install cert-manager if not present: + +```bash +helm repo add jetstack https://charts.jetstack.io +helm install cert-manager jetstack/cert-manager \ + --namespace cert-manager \ + --create-namespace \ + --set installCRDs=true +``` + +--- + +## KEDA Autoscaling + +KEDA ScaledObjects are defined in `k8s/keda/scaledobjects.yaml`. Each application service that consumes Kafka has a ScaledObject: + +```yaml +# Example: booking-service ScaledObject +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: booking-service-scaler + namespace: ticketflow +spec: + scaleTargetRef: + name: booking-service + minReplicaCount: 1 + maxReplicaCount: 10 + triggers: + - type: kafka + metadata: + bootstrapServers: kafka:9092 + consumerGroup: booking-service + topic: ticketflow.seats.locked + lagThreshold: "50" + offsetResetPolicy: latest +``` + +### How it works + +1. KEDA polls the Kafka consumer group lag every 30 seconds. +2. If lag on `ticketflow.seats.locked` exceeds 50 messages, KEDA adds replicas. +3. New replicas join the `booking-service` consumer group and Kafka redistributes partitions. +4. As lag drops, KEDA scales down to `minReplicaCount`. + +### Viewing ScaledObjects + +```bash +kubectl get scaledobjects -n ticketflow +kubectl describe scaledobject booking-service-scaler -n ticketflow +``` + +--- + +## Rolling Updates and Rollbacks + +### Update a service + +1. Build and push a new image: + +```bash +docker build -t yourreg/ticketflow-booking-service:v1.2.0 services/booking-service/ +docker push yourreg/ticketflow-booking-service:v1.2.0 +``` + +2. Update the image in the deployment: + +```bash +kubectl set image deployment/booking-service \ + booking-service=yourreg/ticketflow-booking-service:v1.2.0 \ + -n ticketflow +``` + +3. Monitor the rollout: + +```bash +kubectl rollout status deployment/booking-service -n ticketflow +``` + +### Rollback + +```bash +# Rollback to the previous version +kubectl rollout undo deployment/booking-service -n ticketflow + +# Rollback to a specific revision +kubectl rollout history deployment/booking-service -n ticketflow +kubectl rollout undo deployment/booking-service --to-revision=3 -n ticketflow +``` + +### Zero-downtime strategy + +All deployments use `RollingUpdate` strategy with: + +```yaml +strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 +``` + +This ensures at least one healthy pod is running throughout the update. + +--- + +## Monitoring in Production + +### Prometheus + +Prometheus scrapes metrics from all services via annotations: + +```yaml +annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3000" + prometheus.io/path: "/metrics" +``` + +Access: + +```bash +kubectl port-forward -n ticketflow svc/prometheus 9090:9090 +``` + +### Grafana + +Pre-built dashboards available at `infra/grafana/dashboards/`: + +- `overview.json` — platform-wide health: request rate, error rate, saga throughput. +- `booking-saga.json` — saga step latencies, failure rates per step. +- `kafka.json` — consumer group lag per topic. +- `jvm.json` — JVM heap, GC, thread count (Java services). +- `python.json` — Python process memory, CPU, event loop lag. + +Import dashboards: + +```bash +kubectl port-forward -n ticketflow svc/grafana 3010:3000 +# Open http://localhost:3010 +# Dashboards → Import → Upload JSON file +``` + +### Jaeger + +Trace a booking end-to-end: + +```bash +kubectl port-forward -n ticketflow svc/jaeger 16686:16686 +# Open http://localhost:16686 +# Search by Service: "gateway", Operation: "POST /api/bookings" +# The trace shows spans across: gateway → booking-service → kafka → inventory-service → payment-service → notification-service +``` + +### Viewing logs + +```bash +# Tail logs for a service +kubectl logs -f deployment/booking-service -n ticketflow + +# Last 100 lines +kubectl logs --tail=100 deployment/payment-service -n ticketflow + +# All pods in a deployment +kubectl logs -f -l app=booking-service -n ticketflow + +# Query logs in Grafana Loki +kubectl port-forward -n ticketflow svc/grafana 3010:3000 +# Explore → Loki → {app="booking-service"} |= "ERROR" +``` + +--- + +## Database Backup and Restore + +### PostgreSQL + +```bash +# Backup +kubectl exec -n ticketflow deployment/postgres -- \ + pg_dumpall -U ticketflow > backup-$(date +%Y%m%d).sql + +# Restore +kubectl exec -i -n ticketflow deployment/postgres -- \ + psql -U ticketflow < backup-20240101.sql +``` + +### MongoDB + +```bash +# Backup +kubectl exec -n ticketflow deployment/mongodb -- \ + mongodump --uri="mongodb://ticketflow:$MONGO_PASSWORD@localhost:27017" \ + --archive > mongo-backup-$(date +%Y%m%d).archive + +# Restore +kubectl exec -i -n ticketflow deployment/mongodb -- \ + mongorestore --uri="mongodb://ticketflow:$MONGO_PASSWORD@localhost:27017" \ + --archive < mongo-backup-20240101.archive +``` + +### Automated backup with CronJob + +A Kubernetes CronJob for daily backups is provided in `k8s/infrastructure/backup-cronjob.yaml`. It runs at 02:00 UTC and uploads backups to an S3-compatible bucket. diff --git a/ticketflow/docs/development.md b/ticketflow/docs/development.md new file mode 100644 index 0000000..177522d --- /dev/null +++ b/ticketflow/docs/development.md @@ -0,0 +1,461 @@ +# Development Guide + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Infrastructure Services](#infrastructure-services) +- [Running Individual Services Locally](#running-individual-services-locally) +- [Seeding Data](#seeding-data) +- [End-to-End Booking Walkthrough](#end-to-end-booking-walkthrough) +- [Viewing Emails in Mailpit](#viewing-emails-in-mailpit) +- [Viewing Kafka Messages](#viewing-kafka-messages) +- [Hot Reload](#hot-reload) +- [Running Tests](#running-tests) +- [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +| Tool | Minimum Version | Check | Install | +|---|---|---|---| +| Docker | 24.x | `docker --version` | [docs.docker.com](https://docs.docker.com/get-docker/) | +| Docker Compose | 2.x | `docker compose version` | Bundled with Docker Desktop | +| Make | 3.x | `make --version` | `brew install make` | +| Bun | 1.1+ | `bun --version` | `curl -fsSL https://bun.sh/install \| bash` | +| JDK | 21 | `java --version` | `sdk install java 21-graalce` (SDKMAN) | +| Python | 3.12 | `python3 --version` | `pyenv install 3.12` | +| Maven | 3.9+ | `mvn --version` | `brew install maven` | + +> For Docker Compose development you only need Docker + Make. Language runtimes are required only for running services outside containers. + +--- + +## Installation + +### 1. Clone the repository + +```bash +git clone https://github.com/your-org/ticketflow.git +cd ticketflow +``` + +### 2. Set up environment variables + +```bash +make setup +# Equivalent to: cp .env.example .env +``` + +Review `.env` and update any values you want to customise. Defaults work for local development without changes. + +### 3. Start the full stack + +```bash +make dev +``` + +This runs `docker compose up --build` and waits for all health checks to pass. First run downloads ~2 GB of images and may take 3–5 minutes. Subsequent runs start in under 30 seconds. + +### 4. Verify all services are healthy + +```bash +make health +``` + +### 5. Seed sample data + +```bash +make seed +``` + +Creates: +- 2 venues +- 4 events with seat maps +- 1 test user (`test@ticketflow.dev` / `Test1234!`) + +--- + +## Infrastructure Services + +When `make dev` runs, the following infrastructure containers start alongside the application services: + +| Container | Purpose | Port | +|---|---|---| +| `kafka` | Message broker | 9092 (internal), 29092 (host) | +| `zookeeper` | Kafka coordination | 2181 | +| `postgres` | Relational database | 5432 | +| `mongodb` | Document database | 27017 | +| `redis` | Seat lock cache | 6379 | +| `redpanda-console` | Kafka UI | 8080 | +| `mongo-express` | MongoDB UI | 8081 | +| `mailpit` | Email capture | 8025 (UI), 1025 (SMTP) | +| `prometheus` | Metrics scraper | 9090 | +| `grafana` | Dashboards | 3010 | +| `jaeger` | Distributed tracing | 16686 | +| `loki` | Log aggregation | 3100 | +| `promtail` | Log shipper | — | + +### Stop and restart infrastructure only + +```bash +# Stop everything +make down + +# Start only infrastructure (no application services) +make infra + +# Start application services (assumes infra is running) +make services +``` + +--- + +## Running Individual Services Locally + +Run a service outside Docker when you want faster iteration or need to attach a debugger. The service connects to the Dockerised infrastructure. + +### Prerequisites for local dev + +Ensure infrastructure is running: + +```bash +make infra +``` + +Export shared environment variables (or source `.env`): + +```bash +export $(grep -v '^#' .env | xargs) +``` + +### API Gateway (Bun + Elysia) + +```bash +cd gateway +bun install +bun dev +# Hot reload via Bun's built-in watcher +# Service available at http://localhost:3000 +``` + +### User Service (Java + Spring Boot 3) + +```bash +cd services/user-service +mvn spring-boot:run +# Or with a specific profile: +mvn spring-boot:run -Dspring-boot.run.profiles=local +# Service available at http://localhost:3001 +``` + +To enable the Spring Boot DevTools live-reload: + +```bash +mvn spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005" +``` + +Then attach your IDE debugger to port `5005`. + +### Event Service (Python + FastAPI) + +```bash +cd services/event-service +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 3002 +# Hot reload via uvicorn --reload +# Service available at http://localhost:3002 +# OpenAPI docs: http://localhost:3002/docs +``` + +### Booking Service (Java + Spring Boot 3) + +```bash +cd services/booking-service +mvn spring-boot:run +# Service available at http://localhost:3003 +``` + +### Inventory Service (Bun + Elysia) + +```bash +cd services/inventory-service +bun install +bun dev +# Service available at http://localhost:3004 +``` + +### Payment Service (Python + FastAPI) + +```bash +cd services/payment-service +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 3005 +# Service available at http://localhost:3005 +# OpenAPI docs: http://localhost:3005/docs +``` + +### Notification Service (Python + FastAPI) + +```bash +cd services/notification-service +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 3006 +# Service available at http://localhost:3006 +``` + +--- + +## Seeding Data + +```bash +make seed +# Equivalent to: bash scripts/seed.sh +``` + +The seed script: +1. Registers the test user. +2. Creates two venues (Madison Square Garden, O2 Arena). +3. Creates four events with full seat maps. + +To reset and re-seed: + +```bash +make reset-db +make seed +``` + +> **Warning:** `make reset-db` drops all databases. Do not run in production. + +--- + +## End-to-End Booking Walkthrough + +The following curl sequence walks through the entire booking saga manually. + +### Step 1 — Register (or use seeded test user) + +```bash +curl -s -X POST http://localhost:3000/api/users/register \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Alice Example", + "email": "alice@example.com", + "password": "Alice1234!" + }' | jq . +``` + +### Step 2 — Login and capture token + +```bash +TOKEN=$(curl -s -X POST http://localhost:3000/api/users/login \ + -H "Content-Type: application/json" \ + -d '{"email": "alice@example.com", "password": "Alice1234!"}' \ + | jq -r '.token') +echo "Token: $TOKEN" +``` + +### Step 3 — List events + +```bash +curl -s http://localhost:3000/api/events | jq '.[] | {id, name, date}' +``` + +Note an event ID from the output, e.g. `EVENT_ID=evt_01HX...`. + +### Step 4 — Check seat availability + +```bash +curl -s http://localhost:3000/api/inventory/events/$EVENT_ID/seats \ + | jq '[.[] | select(.status == "AVAILABLE")] | .[0:5]' +``` + +Note 2–3 seat IDs. + +### Step 5 — Create a booking (202 Accepted) + +```bash +BOOKING_RESPONSE=$(curl -s -X POST http://localhost:3000/api/bookings \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"eventId\": \"$EVENT_ID\", + \"seatIds\": [\"seat_01\", \"seat_02\"], + \"paymentMethod\": { + \"type\": \"card\", + \"token\": \"tok_visa\" + } + }") +echo $BOOKING_RESPONSE | jq . +BOOKING_ID=$(echo $BOOKING_RESPONSE | jq -r '.bookingId') +``` + +The response is `202 Accepted` with a `bookingId`. The saga is now running asynchronously. + +### Step 6 — Poll for booking status + +```bash +# Poll every 2 seconds until status is CONFIRMED or FAILED +for i in {1..15}; do + STATUS=$(curl -s http://localhost:3000/api/bookings/$BOOKING_ID \ + -H "Authorization: Bearer $TOKEN" | jq -r '.status') + echo "Attempt $i: $STATUS" + [ "$STATUS" = "CONFIRMED" ] || [ "$STATUS" = "FAILED" ] && break + sleep 2 +done +``` + +### Step 7 — Verify email in Mailpit + +Open http://localhost:8025 in your browser. You should see a booking confirmation email. + +--- + +## Viewing Emails in Mailpit + +Mailpit is a local SMTP server and web UI that captures all outbound email sent by the Notification Service. + +- **URL:** http://localhost:8025 +- All email sent during local development is captured here. +- No real email is sent; Mailpit acts as a sink. +- Use the web UI to inspect HTML/text bodies, headers, and attachments. + +To reset the Mailpit inbox: + +```bash +curl -s -X DELETE http://localhost:8025/api/v1/messages +``` + +--- + +## Viewing Kafka Messages + +### Redpanda Console (recommended) + +Open http://localhost:8080 in your browser. + +- **Topics** tab — browse all topics, view messages, offsets, partition distribution. +- **Consumer Groups** tab — view consumer lag per group per partition. High lag indicates a slow or stopped consumer. +- **Schema Registry** tab — not used (schemas are documented in `docs/event-catalog.md`). + +### CLI (kafkacat / kcat) + +```bash +# List topics +docker exec -it ticketflow-kafka-1 kafka-topics.sh \ + --bootstrap-server localhost:9092 --list + +# Consume the booking topic from the beginning +docker exec -it ticketflow-kafka-1 kafka-console-consumer.sh \ + --bootstrap-server localhost:9092 \ + --topic ticketflow.booking.initiated \ + --from-beginning \ + --property print.key=true +``` + +--- + +## Hot Reload + +| Service | Hot Reload | Mechanism | +|---|---|---| +| API Gateway | Yes | Bun built-in `--watch` | +| User Service | Partial | Spring Boot DevTools (class reload, no full restart) | +| Event Service | Yes | `uvicorn --reload` watches `app/` directory | +| Booking Service | Partial | Spring Boot DevTools | +| Inventory Service | Yes | Bun built-in `--watch` | +| Payment Service | Yes | `uvicorn --reload` | +| Notification Service | Yes | `uvicorn --reload` | +| Frontend | Yes | Vite HMR | + +When running in Docker Compose (via `make dev`), the source directories are bind-mounted into containers where supported, so hot reload works without rebuilding images. + +--- + +## Running Tests + +```bash +# All tests (runs in Docker to match CI) +make test + +# Unit tests for a specific service +cd services/booking-service && mvn test +cd services/event-service && pytest +cd gateway && bun test + +# Integration tests (requires running infra) +make test-integration +``` + +--- + +## Troubleshooting + +### Kafka consumer is not processing messages + +1. Check consumer group lag in Redpanda Console at http://localhost:8080. +2. Check the service logs: `docker compose logs -f booking-service`. +3. Ensure the topic exists: browse Topics in Redpanda Console. +4. Check if the consumer crashed due to a deserialization error — look for `DeserializationException` in logs. + +### Seats not locking (inventory service errors) + +1. Check Redis is healthy: `docker compose ps redis`. +2. Check Redis connectivity from inventory service: + ```bash + docker exec -it ticketflow-inventory-service-1 sh -c 'redis-cli -h redis ping' + ``` +3. Check for stale locks from a previous crashed saga: + ```bash + docker exec -it ticketflow-redis-1 redis-cli KEYS "seat:*" + ``` + Delete stale keys: `docker exec -it ticketflow-redis-1 redis-cli DEL seat:evt_01:A1` + +### Booking stays in PENDING indefinitely + +1. Open Redpanda Console and check the `ticketflow.booking.initiated` topic for the booking's message. +2. Check Inventory Service logs for errors processing that message. +3. Check the DLT topic `ticketflow.booking.initiated.DLT` for failed messages. + +### Payment service not receiving messages + +1. Confirm the Booking Service produced a `ticketflow.payment.requested` message in Redpanda Console. +2. Check the `payment-service` consumer group lag. +3. Verify `STRIPE_SECRET_KEY` is set in `.env`. + +### Database migration failed + +```bash +# Re-run PostgreSQL migrations +docker exec -it ticketflow-user-service-1 sh -c 'java -jar app.jar --spring.flyway.repair=true' + +# Re-run MongoDB index creation +docker exec -it ticketflow-mongodb-1 mongosh --eval "load('/docker-entrypoint-initdb.d/init.js')" +``` + +### Port conflict + +If port 3000 or another port is already in use: + +```bash +# Find what is using port 3000 +lsof -i :3000 + +# Change the port in .env and docker-compose.yml +``` + +### Docker Compose build fails + +```bash +# Clear build cache and rebuild +docker compose down -v +docker builder prune -f +make dev +``` diff --git a/ticketflow/docs/event-catalog.md b/ticketflow/docs/event-catalog.md new file mode 100644 index 0000000..ecb8149 --- /dev/null +++ b/ticketflow/docs/event-catalog.md @@ -0,0 +1,579 @@ +# Event Catalog + +All asynchronous inter-service communication in TicketFlow flows through Apache Kafka. This document is the authoritative reference for every topic, payload schema, and consumer group assignment. + +## Table of Contents + +- [Naming Convention](#naming-convention) +- [Broker Configuration](#broker-configuration) +- [Consumer Groups](#consumer-groups) +- [Topic Reference](#topic-reference) + - [ticketflow.user.registered](#ticketflowuserregistered) + - [ticketflow.event.created](#ticketfloweventcreated) + - [ticketflow.booking.initiated](#ticketflowbookinginitiated) + - [ticketflow.seats.locked](#ticketflowseatslocked) + - [ticketflow.seats.lock-failed](#ticketflowseatslock-failed) + - [ticketflow.seats.confirm](#ticketflowseatsconfirm) + - [ticketflow.seats.release](#ticketflowseatsrelease) + - [ticketflow.payment.requested](#ticketflowpaymentrequested) + - [ticketflow.payment.processed](#ticketflowpaymentprocessed) + - [ticketflow.booking.confirmed](#ticketflowbookingconfirmed) + - [ticketflow.booking.failed](#ticketflowbookingfailed) + - [ticketflow.booking.cancelled](#ticketflowbookingcancelled) +- [Dead-Letter Topics](#dead-letter-topics) + +--- + +## Naming Convention + +``` +ticketflow.. +``` + +| Segment | Example | Description | +|---|---|---| +| `ticketflow` | `ticketflow` | Platform namespace; allows multi-tenant broker sharing | +| `` | `booking`, `seats`, `payment` | Bounded context of the producing service | +| `` | `initiated`, `confirmed`, `processed` | Past-tense verb describing what happened | + +Event names describe facts about the world, not commands. A service publishes what happened; it does not tell other services what to do. + +--- + +## Broker Configuration + +| Property | Value | +|---|---| +| Bootstrap servers | `kafka:9092` (internal) | +| Replication factor (dev) | 1 | +| Replication factor (prod) | 3 | +| Min in-sync replicas (prod) | 2 | +| Compression | `snappy` | +| Message max bytes | `1 MB` | +| Serialization | JSON (UTF-8) | + +--- + +## Consumer Groups + +| Consumer Group ID | Topics Consumed | +|---|---| +| `inventory-service` | `ticketflow.booking.initiated`, `ticketflow.seats.confirm`, `ticketflow.seats.release`, `ticketflow.booking.cancelled` | +| `payment-service` | `ticketflow.payment.requested` | +| `booking-service` | `ticketflow.seats.locked`, `ticketflow.seats.lock-failed`, `ticketflow.payment.processed` | +| `notification-service` | `ticketflow.booking.confirmed`, `ticketflow.booking.failed`, `ticketflow.booking.cancelled`, `ticketflow.user.registered` | + +--- + +## Topic Reference + +--- + +### ticketflow.user.registered + +**Producer:** User Service +**Consumers:** Notification Service +**Partitions:** 3 +**Partition key:** `userId` +**Retention:** 3 days +**Purpose:** Triggers a welcome email after a new user registers. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "user.registered", + "occurredAt": "ISO-8601 datetime", + "payload": { + "userId": "string — unique user identifier", + "name": "string — full name", + "email": "string — email address", + "registeredAt": "ISO-8601 datetime" + } +} +``` + +#### Example + +```json +{ + "eventId": "evt_01HX9ABC123", + "eventType": "user.registered", + "occurredAt": "2024-06-01T10:00:00Z", + "payload": { + "userId": "usr_01HX9ABC456", + "name": "Alice Example", + "email": "alice@example.com", + "registeredAt": "2024-06-01T10:00:00Z" + } +} +``` + +--- + +### ticketflow.event.created + +**Producer:** Event Service +**Consumers:** None (analytics / future use) +**Partitions:** 3 +**Partition key:** `eventId` +**Retention:** 7 days +**Purpose:** Publishes a fact when a new ticketed event is created. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "event.created", + "occurredAt": "ISO-8601 datetime", + "payload": { + "eventId": "string — unique event identifier", + "name": "string — event name", + "venueId": "string — venue identifier", + "startsAt": "ISO-8601 datetime", + "totalSeats": "integer — total capacity", + "ticketTiers": [ + { + "tier": "string — e.g. GENERAL, VIP", + "price": "number — price in minor currency units (pence/cents)", + "currency": "string — ISO 4217 code e.g. GBP" + } + ] + } +} +``` + +#### Example + +```json +{ + "eventId": "evt_02HY1DEF789", + "eventType": "event.created", + "occurredAt": "2024-06-01T09:00:00Z", + "payload": { + "eventId": "evnt_01HX1ABC", + "name": "Coldplay: Music of the Spheres", + "venueId": "ven_01HX2DEF", + "startsAt": "2024-09-15T19:30:00Z", + "totalSeats": 20000, + "ticketTiers": [ + {"tier": "GENERAL", "price": 7500, "currency": "GBP"}, + {"tier": "VIP", "price": 25000, "currency": "GBP"} + ] + } +} +``` + +--- + +### ticketflow.booking.initiated + +**Producer:** Booking Service +**Consumers:** Inventory Service +**Partitions:** 6 +**Partition key:** `bookingId` +**Retention:** 7 days +**Purpose:** Starts the booking saga. Inventory Service reacts by attempting to lock the requested seats. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "booking.initiated", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string — unique booking identifier", + "userId": "string — user who initiated the booking", + "eventId": "string — ticketed event identifier", + "seatIds": ["string — array of requested seat IDs"], + "totalAmount": "integer — total price in minor currency units", + "currency": "string — ISO 4217", + "paymentMethodToken": "string — tokenised payment method (e.g. Stripe token)", + "idempotencyKey": "string — UUID for exactly-once payment" + } +} +``` + +#### Example + +```json +{ + "eventId": "evt_03HZ2GHI012", + "eventType": "booking.initiated", + "occurredAt": "2024-06-15T14:22:00Z", + "payload": { + "bookingId": "bkg_01HZ3ABC", + "userId": "usr_01HX9ABC456", + "eventId": "evnt_01HX1ABC", + "seatIds": ["seat_A1", "seat_A2"], + "totalAmount": 15000, + "currency": "GBP", + "paymentMethodToken": "tok_visa", + "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000" + } +} +``` + +--- + +### ticketflow.seats.locked + +**Producer:** Inventory Service +**Consumers:** Booking Service +**Partitions:** 6 +**Partition key:** `bookingId` +**Retention:** 7 days +**Purpose:** Confirms all requested seats have been locked in Redis and the DB. Booking Service proceeds to request payment. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "seats.locked", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "eventId": "string", + "seatIds": ["string — locked seat IDs"], + "lockExpiresAt": "ISO-8601 datetime — Redis TTL expiry" + } +} +``` + +#### Example + +```json +{ + "eventId": "evt_04HA3JKL345", + "eventType": "seats.locked", + "occurredAt": "2024-06-15T14:22:01Z", + "payload": { + "bookingId": "bkg_01HZ3ABC", + "eventId": "evnt_01HX1ABC", + "seatIds": ["seat_A1", "seat_A2"], + "lockExpiresAt": "2024-06-15T14:27:01Z" + } +} +``` + +--- + +### ticketflow.seats.lock-failed + +**Producer:** Inventory Service +**Consumers:** Booking Service +**Partitions:** 6 +**Partition key:** `bookingId` +**Retention:** 7 days +**Purpose:** Reports that one or more requested seats could not be locked (already held by another booking). Booking Service marks the booking FAILED and terminates the saga. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "seats.lock-failed", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "eventId": "string", + "requestedSeatIds": ["string — all requested seats"], + "unavailableSeatIds": ["string — seats that could not be locked"], + "reason": "string — human-readable explanation" + } +} +``` + +--- + +### ticketflow.seats.confirm + +**Producer:** Booking Service +**Consumers:** Inventory Service +**Partitions:** 6 +**Partition key:** `bookingId` +**Retention:** 7 days +**Purpose:** Instructs Inventory Service to move seats from LOCKED to RESERVED and remove Redis keys. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "seats.confirm", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "eventId": "string", + "seatIds": ["string"] + } +} +``` + +--- + +### ticketflow.seats.release + +**Producer:** Booking Service or Payment Service +**Consumers:** Inventory Service +**Partitions:** 6 +**Partition key:** `bookingId` +**Retention:** 7 days +**Purpose:** Compensating transaction. Instructs Inventory Service to move seats from LOCKED back to AVAILABLE and remove Redis keys. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "seats.release", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "eventId": "string", + "seatIds": ["string"], + "reason": "string — e.g. PAYMENT_FAILED, BOOKING_CANCELLED" + } +} +``` + +--- + +### ticketflow.payment.requested + +**Producer:** Booking Service +**Consumers:** Payment Service +**Partitions:** 6 +**Partition key:** `bookingId` +**Retention:** 7 days +**Purpose:** Instructs Payment Service to charge the customer's payment method. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "payment.requested", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "userId": "string", + "amount": "integer — in minor currency units", + "currency": "string — ISO 4217", + "paymentMethodToken": "string", + "idempotencyKey": "string — UUID, must be preserved from booking.initiated" + } +} +``` + +--- + +### ticketflow.payment.processed + +**Producer:** Payment Service +**Consumers:** Booking Service +**Partitions:** 6 +**Partition key:** `bookingId` +**Retention:** 7 days +**Purpose:** Reports the outcome of the payment attempt. Booking Service uses this to confirm or fail the booking. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "payment.processed", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "paymentId": "string — Payment Service internal ID", + "status": "string — SUCCESS | FAILED", + "amount": "integer", + "currency": "string", + "providerTransactionId": "string — Stripe charge ID or null", + "failureReason": "string | null — reason if FAILED" + } +} +``` + +#### Example (success) + +```json +{ + "eventId": "evt_05HB4MNO678", + "eventType": "payment.processed", + "occurredAt": "2024-06-15T14:22:03Z", + "payload": { + "bookingId": "bkg_01HZ3ABC", + "paymentId": "pay_01HZ5DEF", + "status": "SUCCESS", + "amount": 15000, + "currency": "GBP", + "providerTransactionId": "ch_3PQR7sSt8uVwXyZ9", + "failureReason": null + } +} +``` + +#### Example (failure) + +```json +{ + "payload": { + "bookingId": "bkg_01HZ3ABC", + "status": "FAILED", + "failureReason": "card_declined", + "providerTransactionId": null + } +} +``` + +--- + +### ticketflow.booking.confirmed + +**Producer:** Booking Service +**Consumers:** Notification Service +**Partitions:** 3 +**Partition key:** `userId` +**Retention:** 3 days +**Purpose:** Booking is fully confirmed. Notification Service sends the confirmation email. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "booking.confirmed", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "userId": "string", + "userEmail": "string", + "userName": "string", + "eventId": "string", + "eventName": "string", + "eventDate": "ISO-8601 datetime", + "venueName": "string", + "seatIds": ["string"], + "totalAmount": "integer", + "currency": "string", + "paymentId": "string" + } +} +``` + +--- + +### ticketflow.booking.failed + +**Producer:** Booking Service +**Consumers:** Notification Service +**Partitions:** 3 +**Partition key:** `userId` +**Retention:** 3 days +**Purpose:** Booking failed (seat unavailability or payment failure). Notification Service sends a failure email. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "booking.failed", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "userId": "string", + "userEmail": "string", + "userName": "string", + "eventId": "string", + "eventName": "string", + "reason": "string — SEATS_UNAVAILABLE | PAYMENT_FAILED | INTERNAL_ERROR", + "failureDetail": "string | null" + } +} +``` + +--- + +### ticketflow.booking.cancelled + +**Producer:** Booking Service +**Consumers:** Notification Service, Inventory Service +**Partitions:** 3 +**Partition key:** `userId` +**Retention:** 7 days +**Purpose:** User cancelled a confirmed booking. Inventory Service releases seats; Notification Service sends cancellation email. + +#### Payload Schema + +```json +{ + "eventId": "string", + "eventType": "booking.cancelled", + "occurredAt": "ISO-8601 datetime", + "payload": { + "bookingId": "string", + "userId": "string", + "userEmail": "string", + "userName": "string", + "eventId": "string", + "eventName": "string", + "seatIds": ["string"], + "refundAmount": "integer — in minor currency units", + "currency": "string", + "cancelledAt": "ISO-8601 datetime" + } +} +``` + +--- + +## Dead-Letter Topics + +Each consumer that handles saga-critical messages has a corresponding Dead-Letter Topic (DLT). Messages are forwarded to the DLT after 3 retries with exponential backoff. + +| DLT Topic | Source Topic | Retention | +|---|---|---| +| `ticketflow.booking.initiated.DLT` | `ticketflow.booking.initiated` | 30 days | +| `ticketflow.payment.requested.DLT` | `ticketflow.payment.requested` | 30 days | +| `ticketflow.seats.locked.DLT` | `ticketflow.seats.locked` | 30 days | +| `ticketflow.booking.confirmed.DLT` | `ticketflow.booking.confirmed` | 30 days | + +### DLT Envelope + +Messages forwarded to a DLT are wrapped with additional metadata: + +```json +{ + "originalTopic": "string — source topic", + "originalPartition": "integer", + "originalOffset": "integer", + "failureReason": "string — exception class name", + "failureMessage": "string — exception message", + "attempts": "integer — number of attempts before DLT", + "failedAt": "ISO-8601 datetime", + "originalPayload": { ... } +} +``` + +### Replaying DLT Messages + +To replay a DLT message after fixing the underlying issue: + +```bash +# Using Redpanda Console: Topics → select DLT topic → Messages → Re-publish to original topic +# Or via CLI: +docker exec -it ticketflow-kafka-1 \ + kafka-console-consumer.sh \ + --bootstrap-server localhost:9092 \ + --topic ticketflow.payment.requested.DLT \ + --from-beginning | \ + kafka-console-producer.sh \ + --bootstrap-server localhost:9092 \ + --topic ticketflow.payment.requested +``` diff --git a/ticketflow/docs/observability.md b/ticketflow/docs/observability.md new file mode 100644 index 0000000..54462ff --- /dev/null +++ b/ticketflow/docs/observability.md @@ -0,0 +1,439 @@ +# Observability Guide + +TicketFlow ships with a complete observability stack: Prometheus for metrics, Grafana for dashboards, Jaeger for distributed tracing, and Loki + Promtail for log aggregation. + +## Table of Contents + +- [Metrics with Prometheus](#metrics-with-prometheus) +- [Dashboards with Grafana](#dashboards-with-grafana) +- [Distributed Tracing with Jaeger](#distributed-tracing-with-jaeger) +- [Log Aggregation with Loki](#log-aggregation-with-loki) +- [Alerting](#alerting) + +--- + +## Metrics with Prometheus + +### Access + +- **URL:** http://localhost:9090 + +### What is scraped + +Prometheus is configured in `infra/prometheus/prometheus.yml` to scrape all application services: + +```yaml +scrape_configs: + - job_name: gateway + static_configs: + - targets: ['gateway:3000'] + metrics_path: /metrics + + - job_name: user-service + static_configs: + - targets: ['user-service:3001'] + metrics_path: /actuator/prometheus + + - job_name: event-service + static_configs: + - targets: ['event-service:3002'] + metrics_path: /metrics + + - job_name: booking-service + static_configs: + - targets: ['booking-service:3003'] + metrics_path: /actuator/prometheus + + - job_name: inventory-service + static_configs: + - targets: ['inventory-service:3004'] + metrics_path: /metrics + + - job_name: payment-service + static_configs: + - targets: ['payment-service:3005'] + metrics_path: /metrics + + - job_name: notification-service + static_configs: + - targets: ['notification-service:3006'] + metrics_path: /metrics + + - job_name: kafka + static_configs: + - targets: ['kafka-exporter:9308'] +``` + +### Key metrics per service + +#### API Gateway + +| Metric | Description | +|---|---| +| `http_requests_total{service="gateway"}` | Request count by method, path, status | +| `http_request_duration_seconds` | Request latency histogram | +| `gateway_upstream_errors_total` | Upstream service errors | + +#### User Service (Spring Boot — Micrometer) + +| Metric | Description | +|---|---| +| `http_server_requests_seconds` | Request latency by endpoint | +| `jvm_memory_used_bytes` | JVM heap and non-heap usage | +| `jvm_gc_pause_seconds` | GC pause duration | +| `hikaricp_connections_active` | Active DB connection pool connections | + +#### Booking Service (Spring Boot — Micrometer) + +| Metric | Description | +|---|---| +| `booking_saga_initiated_total` | Sagas started | +| `booking_saga_confirmed_total` | Sagas completed successfully | +| `booking_saga_failed_total` | Sagas failed | +| `booking_saga_duration_seconds` | End-to-end saga latency | +| `kafka_consumer_lag` | Consumer group lag | + +#### Inventory Service (Bun — custom Prometheus client) + +| Metric | Description | +|---|---| +| `seat_lock_operations_total{result="success|failure"}` | Lock attempt outcomes | +| `seat_lock_duration_ms` | Time to acquire all locks for a booking | +| `redis_operations_total` | Redis operation count | +| `redis_operation_duration_ms` | Redis operation latency | + +#### Payment Service (Python — prometheus-client) + +| Metric | Description | +|---|---| +| `payment_processed_total{status="success|failure"}` | Payment outcomes | +| `payment_processing_duration_seconds` | Time to process a payment | +| `stripe_api_errors_total` | Stripe API error count | + +### Adding custom metrics + +**Python (FastAPI):** + +```python +from prometheus_client import Counter, Histogram + +PAYMENTS_PROCESSED = Counter( + 'payment_processed_total', + 'Total payments processed', + ['status'] +) +PAYMENT_DURATION = Histogram( + 'payment_processing_duration_seconds', + 'Payment processing duration' +) + +# Usage: +with PAYMENT_DURATION.time(): + result = await process_payment(payload) +PAYMENTS_PROCESSED.labels(status=result.status).inc() +``` + +**Java (Spring Boot + Micrometer):** + +```java +@Autowired +private MeterRegistry meterRegistry; + +Counter bookingConfirmed = Counter.builder("booking_saga_confirmed_total") + .description("Confirmed bookings") + .register(meterRegistry); + +bookingConfirmed.increment(); +``` + +**TypeScript (Bun + prom-client):** + +```typescript +import { Counter } from 'prom-client'; + +const lockAttempts = new Counter({ + name: 'seat_lock_operations_total', + help: 'Seat lock operation outcomes', + labelNames: ['result'], +}); + +lockAttempts.labels({ result: 'success' }).inc(); +``` + +--- + +## Dashboards with Grafana + +### Access + +- **URL:** http://localhost:3010 +- **Credentials:** admin / admin (change on first login) + +### Pre-built dashboards + +Located in `infra/grafana/dashboards/`: + +| Dashboard | File | Description | +|---|---|---| +| Platform Overview | `overview.json` | Request rate, error rate, p99 latency across all services | +| Booking Saga | `booking-saga.json` | Saga throughput, step latencies, success/failure rates | +| Kafka Consumer Lag | `kafka.json` | Lag per consumer group per topic | +| JVM Health | `jvm.json` | Heap, GC, threads for Java services | +| Python Services | `python.json` | Memory, CPU, event loop lag for Python services | +| Seat Inventory | `inventory.json` | Lock rates, Redis ops/sec, availability trends | + +### Key panels in the Booking Saga dashboard + +1. **Saga Throughput** — rate of bookings initiated vs confirmed vs failed. +2. **Step Latency Breakdown** — time spent in each saga step (seat lock, payment, confirmation). +3. **Consumer Lag Heatmap** — per-partition lag across all saga topics. +4. **DLT Message Rate** — messages forwarded to dead-letter topics (should be zero in normal operation). +5. **Redis Lock Duration p95** — 95th percentile seat lock acquisition time. + +--- + +## Distributed Tracing with Jaeger + +### Access + +- **URL:** http://localhost:16686 + +### How tracing works + +All services are instrumented with OpenTelemetry. A unique `traceId` is generated by the gateway for each inbound request and propagated: + +1. **HTTP requests** — via standard W3C `traceparent` header. +2. **Kafka messages** — via a `traceparent` Kafka header included in every produced message. + +This means a single booking request produces a trace that spans: + +``` +gateway [POST /api/bookings] + └── booking-service [POST /internal/bookings] + └── kafka produce [booking.initiated] + └── inventory-service [consume booking.initiated] + └── redis [SETNX seat:*] + └── postgres [UPDATE seats] + └── kafka produce [seats.locked] + └── booking-service [consume seats.locked] + └── kafka produce [payment.requested] + └── payment-service [consume payment.requested] + └── stripe [charges.create] + └── mongodb [insert payment] + └── kafka produce [payment.processed] + └── booking-service [consume payment.processed] + └── postgres [UPDATE bookings] + └── kafka produce [booking.confirmed] + └── notification-service [consume booking.confirmed] + └── smtp [send email] +``` + +### Tracing a booking end-to-end + +1. Initiate a booking and capture the `bookingId`. +2. Open Jaeger UI at http://localhost:16686. +3. In the search panel: + - **Service:** `gateway` + - **Operation:** `POST /api/bookings` + - **Tags:** `bookingId=bkg_01HZ3ABC` +4. Click the trace to see the full waterfall. + +### OpenTelemetry configuration + +**Java (Spring Boot):** + +Add to `application.yml`: + +```yaml +management: + tracing: + sampling: + probability: 1.0 +spring: + application: + name: booking-service +``` + +Set environment variable: + +``` +OTEL_EXPORTER_JAEGER_ENDPOINT=http://jaeger:14268/api/traces +``` + +**Python (FastAPI):** + +```python +from opentelemetry import trace +from opentelemetry.exporter.jaeger.thrift import JaegerExporter +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.kafka import KafkaInstrumentor + +tracer_provider = TracerProvider() +jaeger_exporter = JaegerExporter(collector_endpoint=os.getenv("OTEL_EXPORTER_JAEGER_ENDPOINT")) +tracer_provider.add_span_processor(BatchSpanProcessor(jaeger_exporter)) +trace.set_tracer_provider(tracer_provider) + +FastAPIInstrumentor.instrument_app(app) +KafkaInstrumentor().instrument() +``` + +**TypeScript (Bun):** + +```typescript +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { JaegerExporter } from '@opentelemetry/exporter-jaeger'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; + +const provider = new NodeTracerProvider(); +provider.addSpanProcessor( + new BatchSpanProcessor(new JaegerExporter({ + endpoint: process.env.OTEL_EXPORTER_JAEGER_ENDPOINT, + })) +); +provider.register(); +``` + +--- + +## Log Aggregation with Loki + +### Access + +Loki is queried through Grafana Explore: http://localhost:3010/explore + +Select **Loki** as the data source. + +### Log format + +All services emit structured JSON logs: + +```json +{ + "timestamp": "2024-06-15T14:22:01.234Z", + "level": "INFO", + "service": "booking-service", + "traceId": "7f3c2a1b4d5e6f7a", + "spanId": "1a2b3c4d", + "bookingId": "bkg_01HZ3ABC", + "message": "Booking confirmed", + "duration_ms": 4231 +} +``` + +### Promtail configuration + +Promtail is configured in `infra/loki/promtail.yml` to collect Docker container logs and label them by service name: + +```yaml +scrape_configs: + - job_name: containers + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + target_label: service +``` + +### Useful LogQL queries + +```logql +# All errors across all services +{job="containers"} |= "ERROR" + +# Booking saga errors for a specific booking +{service="booking-service"} |= "bkg_01HZ3ABC" | json | level = "ERROR" + +# Payment failures in the last hour +{service="payment-service"} | json | status = "FAILED" + +# Slow requests (over 2 seconds) +{service="gateway"} | json | duration_ms > 2000 + +# DLT messages forwarded +{job="containers"} |= "DeadLetterPublishing" + +# Count errors per service (metric query) +sum by (service) (rate({job="containers"} |= "ERROR" [5m])) +``` + +--- + +## Alerting + +Alert rules are defined in `infra/prometheus/alerts.yml` and loaded automatically. + +### Example alert rules + +```yaml +groups: + - name: ticketflow + rules: + + - alert: HighBookingFailureRate + expr: | + rate(booking_saga_failed_total[5m]) / + rate(booking_saga_initiated_total[5m]) > 0.05 + for: 2m + labels: + severity: warning + annotations: + summary: "High booking failure rate" + description: "More than 5% of bookings are failing over the last 5 minutes." + + - alert: KafkaConsumerLagHigh + expr: kafka_consumer_group_lag > 500 + for: 5m + labels: + severity: warning + annotations: + summary: "Kafka consumer lag is high" + description: "Consumer group {{ $labels.consumergroup }} has lag > 500 on topic {{ $labels.topic }}" + + - alert: DLTMessagesDetected + expr: kafka_topic_partitions_current_offset{topic=~".*\\.DLT"} > 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Dead-letter topic has messages" + description: "Topic {{ $labels.topic }} has messages in the DLT. Manual investigation required." + + - alert: ServiceDown + expr: up == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Service is down" + description: "Service {{ $labels.job }} has been down for more than 1 minute." + + - alert: HighErrorRate + expr: | + rate(http_requests_total{status=~"5.."}[5m]) / + rate(http_requests_total[5m]) > 0.01 + for: 2m + labels: + severity: warning + annotations: + summary: "High HTTP error rate" + description: "Service {{ $labels.service }} has > 1% 5xx error rate." + + - alert: PaymentServiceDown + expr: up{job="payment-service"} == 0 + for: 30s + labels: + severity: critical + annotations: + summary: "Payment service is unreachable" + description: "The payment service has been down for 30 seconds. Active sagas will stall." +``` + +### Grafana alert channels + +Configure Grafana notification channels in the UI: +1. Go to **Alerting → Contact points**. +2. Add a Slack webhook, PagerDuty integration, or email. +3. Create a **Notification policy** that routes `critical` alerts to PagerDuty and `warning` alerts to Slack. diff --git a/ticketflow/docs/services/booking-service.md b/ticketflow/docs/services/booking-service.md new file mode 100644 index 0000000..68e8d97 --- /dev/null +++ b/ticketflow/docs/services/booking-service.md @@ -0,0 +1,179 @@ +# Booking Service + +## Responsibility + +The Booking Service is the choreography saga coordinator for the booking lifecycle. It: + +- Accepts booking requests from the gateway and creates a `PENDING` booking record. +- Drives the saga forward by producing Kafka events at each step. +- Reacts to events from Inventory Service and Payment Service to advance or abort the saga. +- Provides the booking status polling endpoint. +- Handles booking cancellation. + +The Booking Service has the most complex Kafka logic: it both produces and consumes multiple topics. + +--- + +## Technology Stack + +| Component | Technology | +|---|---| +| Runtime | JDK 21 | +| Framework | Spring Boot 3.3 | +| Language | Java | +| Database | PostgreSQL 16 | +| ORM | Spring Data JPA | +| Migrations | Flyway | +| Kafka | Spring Kafka (producer + consumer) | +| Metrics | Micrometer → Prometheus | +| Tracing | OpenTelemetry Java Agent | + +--- + +## API Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/bookings` | Yes (X-User-Id) | Initiate booking (202) | +| GET | `/bookings/my` | Yes | List user's bookings | +| GET | `/bookings/{id}` | Yes | Get booking status | +| POST | `/bookings/{id}/cancel` | Yes | Cancel booking | +| GET | `/actuator/health` | No | Health check | +| GET | `/actuator/prometheus` | No | Prometheus metrics | + +--- + +## Database Schema + +```sql +-- V1__create_bookings.sql +CREATE TABLE bookings ( + id VARCHAR(26) PRIMARY KEY, + user_id VARCHAR(26) NOT NULL, + event_id VARCHAR(26) NOT NULL, + seat_ids TEXT[] NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + total_amount INTEGER NOT NULL, + currency VARCHAR(3) NOT NULL, + payment_method_token VARCHAR(255) NOT NULL, + idempotency_key VARCHAR(36) NOT NULL UNIQUE, + payment_id VARCHAR(26), + failure_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + confirmed_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ +); + +CREATE INDEX idx_bookings_user_id ON bookings(user_id); +CREATE INDEX idx_bookings_status ON bookings(status); +CREATE INDEX idx_bookings_event_id ON bookings(event_id); +``` + +### Booking Status State Machine + +``` +PENDING → [seats.locked] → (produce payment.requested) +PENDING → [seats.lock-failed] → FAILED +PENDING/PAYMENT_PENDING → [payment.processed SUCCESS] → CONFIRMED +PENDING/PAYMENT_PENDING → [payment.processed FAILED] → FAILED +CONFIRMED → [cancel request] → CANCELLED +``` + +--- + +## Kafka Topics + +| Topic | Direction | When | +|---|---|---| +| `ticketflow.booking.initiated` | **Produced** | After inserting PENDING booking | +| `ticketflow.payment.requested` | **Produced** | After receiving `seats.locked` | +| `ticketflow.booking.confirmed` | **Produced** | After payment SUCCESS | +| `ticketflow.booking.failed` | **Produced** | After payment FAILED or seat lock failed | +| `ticketflow.seats.confirm` | **Produced** | After payment SUCCESS | +| `ticketflow.seats.release` | **Produced** | After payment FAILED or cancellation | +| `ticketflow.booking.cancelled` | **Produced** | After cancellation | +| `ticketflow.seats.locked` | **Consumed** | Advance saga to payment step | +| `ticketflow.seats.lock-failed` | **Consumed** | Abort saga | +| `ticketflow.payment.processed` | **Consumed** | Confirm or fail booking | + +### Consumer configuration + +```java +@KafkaListener( + topics = "ticketflow.seats.locked", + groupId = "booking-service", + containerFactory = "kafkaListenerContainerFactory" +) +@Transactional +public void onSeatsLocked(SeatsLockedEvent event) { + // Idempotency check + if (bookingRepo.findById(event.getBookingId()) + .map(b -> b.getStatus() != BookingStatus.PENDING) + .orElse(true)) { + return; // already processed or booking not found + } + kafkaTemplate.send("ticketflow.payment.requested", + event.getBookingId(), + buildPaymentRequestedEvent(event)); +} +``` + +--- + +## Key Business Logic + +### Transactional outbox pattern + +Booking inserts and Kafka publishes are wrapped in a single Spring `@Transactional` block. If the Kafka publish fails, the DB write is rolled back. This prevents a state where the booking is saved but `booking.initiated` is never published. + +```java +@Transactional +public BookingResponse createBooking(CreateBookingRequest req, String userId) { + Booking booking = bookingRepo.save(buildBooking(req, userId)); + kafkaTemplate.send("ticketflow.booking.initiated", + booking.getId(), + buildInitiatedEvent(booking)); + return new BookingResponse(booking.getId(), "PENDING"); +} +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `SERVER_PORT` | Listening port | `3003` | +| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL | `jdbc:postgresql://postgres:5432/ticketflow` | +| `SPRING_DATASOURCE_USERNAME` | DB username | `ticketflow` | +| `SPRING_DATASOURCE_PASSWORD` | DB password | — | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker | `kafka:9092` | +| `CANCELLATION_WINDOW_HOURS` | Hours before event that cancellation is allowed | `24` | + +--- + +## Local Development + +```bash +cd services/booking-service +mvn spring-boot:run -Dspring-boot.run.profiles=local +``` + +--- + +## Docker Build + +```dockerfile +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /app +COPY pom.xml ./ +COPY src ./src +RUN ./mvnw package -DskipTests + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/target/*.jar app.jar +EXPOSE 3003 +ENTRYPOINT ["java", "-jar", "app.jar"] +``` diff --git a/ticketflow/docs/services/event-service.md b/ticketflow/docs/services/event-service.md new file mode 100644 index 0000000..1140420 --- /dev/null +++ b/ticketflow/docs/services/event-service.md @@ -0,0 +1,172 @@ +# Event Service + +## Responsibility + +The Event Service is the authority on all ticketed events and venues. It manages: + +- Creating and updating events (admin only) +- Creating and managing venues +- Serving event listings and details to the frontend +- Publishing `event.created` Kafka events + +--- + +## Technology Stack + +| Component | Technology | +|---|---| +| Runtime | Python 3.12 | +| Framework | FastAPI | +| Language | Python | +| Database | MongoDB 7 | +| ODM | Motor (async MongoDB driver) + Pydantic | +| Kafka | confluent-kafka-python | +| Metrics | prometheus-client | +| Tracing | OpenTelemetry + opentelemetry-instrumentation-fastapi | +| Auto docs | FastAPI → OpenAPI 3.1 (at `/docs`) | + +--- + +## API Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/events` | No | List upcoming events | +| GET | `/events/{id}` | No | Get event details | +| POST | `/events` | Yes (ADMIN) | Create event | +| PUT | `/events/{id}` | Yes (ADMIN) | Update event | +| POST | `/venues` | Yes (ADMIN) | Create venue | +| GET | `/venues/{id}` | No | Get venue details | +| GET | `/health` | No | Health check | +| GET | `/metrics` | No | Prometheus metrics | + +--- + +## Database Schema (MongoDB Collections) + +### `events` collection + +```javascript +{ + _id: ObjectId, + id: String, // ULID — used as public ID + name: String, + description: String, + venueId: String, + startsAt: Date, + endsAt: Date, + status: String, // DRAFT | PUBLISHED | CANCELLED + ticketTiers: [ + { + tier: String, // GENERAL | VIP | STANDING + price: Number, // minor currency units + currency: String, // ISO 4217 + capacity: Number + } + ], + createdAt: Date, + updatedAt: Date +} +``` + +Indexes: +- `{ startsAt: 1, status: 1 }` — for listing upcoming published events +- `{ venueId: 1 }` — for filtering by venue + +### `venues` collection + +```javascript +{ + _id: ObjectId, + id: String, + name: String, + address: String, + city: String, + country: String, // ISO 3166-1 alpha-2 + capacity: Number, + createdAt: Date +} +``` + +Indexes: +- `{ city: 1 }` — for location-based browsing + +--- + +## Kafka Topics + +| Action | Topic | Direction | +|---|---|---| +| Event created | `ticketflow.event.created` | Produced | + +--- + +## Key Business Logic + +### Pydantic models (request validation) + +```python +class CreateEventRequest(BaseModel): + name: str = Field(min_length=1, max_length=200) + description: str | None = None + venueId: str + startsAt: datetime + endsAt: datetime + ticketTiers: list[TicketTier] = Field(min_length=1) + + @model_validator(mode='after') + def ends_after_starts(self) -> 'CreateEventRequest': + if self.endsAt <= self.startsAt: + raise ValueError('endsAt must be after startsAt') + return self +``` + +### Async MongoDB queries + +```python +async def list_upcoming_events(page: int, per_page: int) -> list[Event]: + cursor = db.events.find( + {"status": "PUBLISHED", "startsAt": {"$gte": datetime.utcnow()}}, + sort=[("startsAt", ASCENDING)] + ).skip((page - 1) * per_page).limit(per_page) + return [Event(**doc) async for doc in cursor] +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Listening port | `3002` | +| `MONGODB_URL` | MongoDB connection string | `mongodb://ticketflow:pass@mongodb:27017/ticketflow_events` | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker | `kafka:9092` | +| `LOG_LEVEL` | Log level | `info` | +| `OTEL_EXPORTER_JAEGER_ENDPOINT` | Jaeger endpoint | `http://jaeger:14268/api/traces` | + +--- + +## Local Development + +```bash +cd services/event-service +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 3002 +# OpenAPI docs: http://localhost:3002/docs +``` + +--- + +## Docker Build + +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +EXPOSE 3002 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3002"] +``` diff --git a/ticketflow/docs/services/gateway.md b/ticketflow/docs/services/gateway.md new file mode 100644 index 0000000..a839e7d --- /dev/null +++ b/ticketflow/docs/services/gateway.md @@ -0,0 +1,162 @@ +# API Gateway + +## Responsibility + +The API Gateway is the single entry point for all client traffic. It handles: + +- **JWT authentication** — verifies tokens locally and rejects unauthenticated requests to protected routes. +- **Request routing** — proxies requests to the appropriate downstream service based on path prefix. +- **Header injection** — adds `X-User-Id` and `X-User-Role` headers before forwarding to downstream services. +- **Rate limiting** — per-IP rate limiting on all public endpoints. +- **Request/response logging** — structured JSON logs with `requestId`, method, path, status, and duration. +- **Circuit breaking** — half-open circuit breaker for each upstream service. + +The gateway does **not** contain business logic. It knows nothing about bookings, events, or payments. + +--- + +## Technology Stack + +| Component | Technology | +|---|---| +| Runtime | Bun 1.1 | +| Framework | Elysia | +| Language | TypeScript | +| Auth | JWT (HS256) via `@elysiajs/jwt` | +| HTTP Client | Bun native `fetch` | +| Metrics | `prom-client` | +| Tracing | OpenTelemetry | + +--- + +## API Endpoints + +| Method | Path | Auth | Proxies To | +|---|---|---|---| +| GET | `/health` | No | — (gateway itself) | +| POST | `/api/users/register` | No | user-service:3001 | +| POST | `/api/users/login` | No | user-service:3001 | +| GET | `/api/users/me` | Yes | user-service:3001 | +| GET | `/api/events` | No | event-service:3002 | +| GET | `/api/events/:id` | No | event-service:3002 | +| POST | `/api/events` | Yes (ADMIN) | event-service:3002 | +| PUT | `/api/events/:id` | Yes (ADMIN) | event-service:3002 | +| POST | `/api/venues` | Yes (ADMIN) | event-service:3002 | +| GET | `/api/venues/:id` | No | event-service:3002 | +| GET | `/api/inventory/events/:eventId/seats` | No | inventory-service:3004 | +| POST | `/api/bookings` | Yes | booking-service:3003 | +| GET | `/api/bookings/my` | Yes | booking-service:3003 | +| GET | `/api/bookings/:id` | Yes | booking-service:3003 | +| POST | `/api/bookings/:id/cancel` | Yes | booking-service:3003 | +| GET | `/api/payments/:id` | Yes | payment-service:3005 | +| GET | `/api/payments/booking/:bookingId` | Yes | payment-service:3005 | +| GET | `/api/notifications/recent` | Yes | notification-service:3006 | +| GET | `/api/notifications/health` | No | notification-service:3006 | + +--- + +## Kafka Topics + +The gateway does not produce or consume Kafka topics directly. + +--- + +## Key Algorithms + +### JWT Verification Middleware + +```typescript +// middleware/auth.ts +export const authMiddleware = (app: Elysia) => + app.derive(async ({ headers, jwt, set }) => { + const authHeader = headers['authorization']; + if (!authHeader?.startsWith('Bearer ')) { + set.status = 401; + throw new Error('Missing or invalid Authorization header'); + } + const token = authHeader.slice(7); + const payload = await jwt.verify(token); + if (!payload) { + set.status = 401; + throw new Error('Invalid or expired token'); + } + return { + userId: payload.sub as string, + userRole: payload.role as string, + }; + }); +``` + +### Proxy Handler + +```typescript +// routes/proxy.ts +async function proxyTo(targetUrl: string, request: Request, additionalHeaders?: Record) { + const response = await fetch(targetUrl, { + method: request.method, + headers: { + ...Object.fromEntries(request.headers), + ...additionalHeaders, + }, + body: request.method !== 'GET' && request.method !== 'HEAD' + ? await request.arrayBuffer() + : undefined, + }); + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); +} +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Gateway listening port | `3000` | +| `JWT_SECRET` | HMAC secret for token verification | — | +| `JWT_EXPIRY_HOURS` | Token lifetime | `24` | +| `USER_SERVICE_URL` | User service base URL | `http://user-service:3001` | +| `EVENT_SERVICE_URL` | Event service base URL | `http://event-service:3002` | +| `BOOKING_SERVICE_URL` | Booking service base URL | `http://booking-service:3003` | +| `INVENTORY_SERVICE_URL` | Inventory service base URL | `http://inventory-service:3004` | +| `PAYMENT_SERVICE_URL` | Payment service base URL | `http://payment-service:3005` | +| `NOTIFICATION_SERVICE_URL` | Notification service base URL | `http://notification-service:3006` | +| `RATE_LIMIT_MAX` | Max requests per window per IP | `100` | +| `RATE_LIMIT_WINDOW_MS` | Rate limit window in ms | `60000` | +| `LOG_LEVEL` | Log level | `info` | + +--- + +## Local Development + +```bash +cd gateway +bun install +bun dev +# Hot reload via Bun --watch +# Service available at http://localhost:3000 +``` + +--- + +## Docker Build + +```dockerfile +FROM oven/bun:1.1-alpine AS builder +WORKDIR /app +COPY package.json bun.lockb ./ +RUN bun install --frozen-lockfile +COPY src/ ./src/ +COPY tsconfig.json ./ +RUN bun build src/index.ts --outdir dist --target bun + +FROM oven/bun:1.1-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +EXPOSE 3000 +CMD ["bun", "dist/index.js"] +``` diff --git a/ticketflow/docs/services/inventory-service.md b/ticketflow/docs/services/inventory-service.md new file mode 100644 index 0000000..d7838d5 --- /dev/null +++ b/ticketflow/docs/services/inventory-service.md @@ -0,0 +1,190 @@ +# Inventory Service + +## Responsibility + +The Inventory Service is the authority on seat availability. It: + +- Serves real-time seat availability queries (high read throughput). +- Implements distributed seat locking using Redis SETNX (atomic, TTL-based). +- Reacts to saga events to lock, confirm, or release seats. +- Maintains the durable seat record in PostgreSQL. + +The Inventory Service is the hottest service in the platform — seat availability is queried on every event page view and during booking. + +--- + +## Technology Stack + +| Component | Technology | +|---|---| +| Runtime | Bun 1.1 | +| Framework | Elysia | +| Language | TypeScript | +| Database | PostgreSQL 16 | +| Cache / Lock | Redis 7 | +| DB Client | `postgres` (bun-native) | +| Redis Client | `ioredis` | +| Kafka | `kafkajs` | +| Metrics | `prom-client` | +| Tracing | OpenTelemetry | + +--- + +## API Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/inventory/events/:eventId/seats` | No | List all seats with status | +| GET | `/health` | No | Health check | +| GET | `/metrics` | No | Prometheus metrics | + +--- + +## Database Schema + +```sql +-- init.sql +CREATE TABLE seats ( + id VARCHAR(26) PRIMARY KEY, + event_id VARCHAR(26) NOT NULL, + row_label VARCHAR(10) NOT NULL, + seat_number INTEGER NOT NULL, + tier VARCHAR(20) NOT NULL, + price INTEGER NOT NULL, -- minor currency units + currency VARCHAR(3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + booking_id VARCHAR(26), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (event_id, row_label, seat_number) +); + +CREATE INDEX idx_seats_event_status ON seats(event_id, status); +CREATE INDEX idx_seats_booking_id ON seats(booking_id); +``` + +### Seat Status + +| Status | Description | +|---|---| +| `AVAILABLE` | Can be booked | +| `LOCKED` | Temporarily held in Redis (max 5 min TTL) | +| `RESERVED` | Sold — booking confirmed | + +--- + +## Kafka Topics + +| Topic | Direction | When | +|---|---|---| +| `ticketflow.seats.locked` | **Produced** | All requested seats successfully locked | +| `ticketflow.seats.lock-failed` | **Produced** | One or more seats unavailable | +| `ticketflow.booking.initiated` | **Consumed** | Attempt to lock seats | +| `ticketflow.seats.confirm` | **Consumed** | Move seats LOCKED → RESERVED | +| `ticketflow.seats.release` | **Consumed** | Move seats LOCKED → AVAILABLE | +| `ticketflow.booking.cancelled` | **Consumed** | Move seats RESERVED → AVAILABLE | + +--- + +## Key Algorithms + +### Distributed Seat Lock (Redis SETNX) + +```typescript +async function lockSeats(bookingId: string, eventId: string, seatIds: string[]): Promise { + const locked: string[] = []; + const TTL_MS = 5 * 60 * 1000; // 5 minutes + + for (const seatId of seatIds) { + const key = `seat:${eventId}:${seatId}`; + const result = await redis.set(key, bookingId, 'NX', 'PX', TTL_MS); + if (result === 'OK') { + locked.push(seatId); + } else { + // Check if this booking already owns the lock (idempotent replay) + const existing = await redis.get(key); + if (existing === bookingId) { + locked.push(seatId); + } else { + // Rollback: release all locks acquired in this attempt + for (const lockedSeatId of locked) { + await redis.del(`seat:${eventId}:${lockedSeatId}`); + } + return { success: false, unavailableSeatIds: [seatId] }; + } + } + } + + // Persist LOCKED status to PostgreSQL + await sql` + UPDATE seats + SET status = 'LOCKED', booking_id = ${bookingId}, updated_at = NOW() + WHERE id = ANY(${seatIds}) + AND event_id = ${eventId} + AND status = 'AVAILABLE' + `; + + return { success: true, lockedSeatIds: locked }; +} +``` + +### Confirm Seats + +```typescript +async function confirmSeats(bookingId: string, eventId: string, seatIds: string[]) { + // Atomic DB update + await sql` + UPDATE seats + SET status = 'RESERVED', updated_at = NOW() + WHERE id = ANY(${seatIds}) + AND booking_id = ${bookingId} + AND status = 'LOCKED' + `; + // Delete Redis locks + const keys = seatIds.map(id => `seat:${eventId}:${id}`); + await redis.del(...keys); +} +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Listening port | `3004` | +| `DATABASE_URL` | PostgreSQL connection string | `postgresql://ticketflow:pass@postgres:5432/ticketflow` | +| `REDIS_URL` | Redis connection string | `redis://:pass@redis:6379` | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker | `kafka:9092` | +| `SEAT_LOCK_TTL_MS` | Redis lock TTL in ms | `300000` | + +--- + +## Local Development + +```bash +cd services/inventory-service +bun install +bun dev +# Service available at http://localhost:3004 +``` + +--- + +## Docker Build + +```dockerfile +FROM oven/bun:1.1-alpine AS builder +WORKDIR /app +COPY package.json bun.lockb ./ +RUN bun install --frozen-lockfile +COPY src/ ./src/ +RUN bun build src/index.ts --outdir dist --target bun + +FROM oven/bun:1.1-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +EXPOSE 3004 +CMD ["bun", "dist/index.js"] +``` diff --git a/ticketflow/docs/services/notification-service.md b/ticketflow/docs/services/notification-service.md new file mode 100644 index 0000000..44aa8bc --- /dev/null +++ b/ticketflow/docs/services/notification-service.md @@ -0,0 +1,215 @@ +# Notification Service + +## Responsibility + +The Notification Service is a pure Kafka consumer. It: + +- Listens for booking lifecycle events and user registration events. +- Renders HTML email templates using Jinja2. +- Sends emails via SMTP (Mailpit in dev, real SMTP in prod). +- Persists a notification log entry in MongoDB for each sent notification. +- Provides a read endpoint for a user's recent notifications. + +The Notification Service has no HTTP write endpoints — it only reacts to Kafka events. + +--- + +## Technology Stack + +| Component | Technology | +|---|---| +| Runtime | Python 3.12 | +| Framework | FastAPI | +| Language | Python | +| Database | MongoDB 7 | +| DB Driver | Motor (async) | +| Kafka | confluent-kafka-python | +| Email | aiosmtplib + Jinja2 | +| Validation | Pydantic v2 | +| Metrics | prometheus-client | + +--- + +## API Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/notifications/recent` | Yes (X-User-Id) | Last 20 notifications for user | +| GET | `/health` | No | Health check (Kafka + MongoDB + SMTP) | +| GET | `/metrics` | No | Prometheus metrics | + +--- + +## Database Schema (MongoDB `notifications` collection) + +```javascript +{ + _id: ObjectId, + id: String, // ULID + userId: String, + type: String, // WELCOME | BOOKING_CONFIRMED | BOOKING_FAILED | BOOKING_CANCELLED + subject: String, + recipientEmail: String, + bookingId: String, // null for WELCOME notifications + status: String, // SENT | FAILED + sentAt: Date, + errorMessage: String, // null if SENT + createdAt: Date +} +``` + +Indexes: +- `{ userId: 1, createdAt: -1 }` — for recent notifications endpoint +- `{ bookingId: 1 }` — for looking up notification by booking + +--- + +## Kafka Topics + +| Topic | Direction | When | +|---|---|---| +| `ticketflow.booking.confirmed` | **Consumed** | Send booking confirmation email | +| `ticketflow.booking.failed` | **Consumed** | Send booking failure email | +| `ticketflow.booking.cancelled` | **Consumed** | Send cancellation email | +| `ticketflow.user.registered` | **Consumed** | Send welcome email | + +--- + +## Email Templates + +Templates are in `app/templates/`: + +| Template File | Used For | +|---|---| +| `welcome.html` | `user.registered` event | +| `booking_confirmed.html` | `booking.confirmed` event | +| `booking_failed.html` | `booking.failed` event | +| `booking_cancelled.html` | `booking.cancelled` event | + +Example template excerpt (`booking_confirmed.html`): + +```html + + + +

Your booking is confirmed!

+

Hi {{ user_name }},

+

+ Your booking for {{ event_name }} on + {{ event_date | datetimeformat }} at {{ venue_name }} is confirmed. +

+ + + + + +
Booking ID{{ booking_id }}
Seats{{ seat_ids | join(', ') }}
Total{{ total_amount | currency(currency) }}
Payment ID{{ payment_id }}
+ + +``` + +--- + +## Key Business Logic + +### Event handler pattern + +```python +async def handle_booking_confirmed(event: BookingConfirmedEvent): + html_body = render_template("booking_confirmed.html", { + "user_name": event.payload.user_name, + "event_name": event.payload.event_name, + "event_date": event.payload.event_date, + "venue_name": event.payload.venue_name, + "booking_id": event.payload.booking_id, + "seat_ids": event.payload.seat_ids, + "total_amount": event.payload.total_amount, + "currency": event.payload.currency, + "payment_id": event.payload.payment_id, + }) + + notification_id = ulid() + try: + await send_email( + to=event.payload.user_email, + subject=f"Booking confirmed — {event.payload.event_name}", + html_body=html_body, + ) + status = "SENT" + error_message = None + except SMTPException as e: + status = "FAILED" + error_message = str(e) + logger.error(f"Failed to send confirmation email for booking {event.payload.booking_id}: {e}") + + await db.notifications.insert_one({ + "id": notification_id, + "userId": event.payload.user_id, + "type": "BOOKING_CONFIRMED", + "subject": f"Booking confirmed — {event.payload.event_name}", + "recipientEmail": event.payload.user_email, + "bookingId": event.payload.booking_id, + "status": status, + "sentAt": datetime.utcnow(), + "errorMessage": error_message, + "createdAt": datetime.utcnow(), + }) +``` + +### SMTP configuration + +```python +async def send_email(to: str, subject: str, html_body: str): + message = MIMEMultipart("alternative") + message["From"] = settings.email_from + message["To"] = to + message["Subject"] = subject + message.attach(MIMEText(html_body, "html")) + + async with aiosmtplib.SMTP( + hostname=settings.smtp_host, + port=settings.smtp_port, + ) as smtp: + await smtp.send_message(message) +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Listening port | `3006` | +| `MONGODB_URL` | MongoDB connection string | `mongodb://ticketflow:pass@mongodb:27017/ticketflow_notifications` | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker | `kafka:9092` | +| `SMTP_HOST` | SMTP server hostname | `mailpit` | +| `SMTP_PORT` | SMTP port | `1025` | +| `EMAIL_FROM` | Sender address | `noreply@ticketflow.dev` | + +--- + +## Local Development + +```bash +cd services/notification-service +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 3006 + +# Emails are captured in Mailpit at http://localhost:8025 +``` + +--- + +## Docker Build + +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +EXPOSE 3006 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3006"] +``` diff --git a/ticketflow/docs/services/payment-service.md b/ticketflow/docs/services/payment-service.md new file mode 100644 index 0000000..9ecb3ec --- /dev/null +++ b/ticketflow/docs/services/payment-service.md @@ -0,0 +1,190 @@ +# Payment Service + +## Responsibility + +The Payment Service processes payments on behalf of the booking saga. It: + +- Consumes `payment.requested` Kafka events. +- Charges the customer's payment method via the Stripe API. +- Persists a payment record in MongoDB. +- Publishes `payment.processed` with the result (SUCCESS or FAILED). +- Provides a read endpoint for payment details. + +The Payment Service does **not** initiate payments proactively. It only acts in response to `payment.requested` events. + +--- + +## Technology Stack + +| Component | Technology | +|---|---| +| Runtime | Python 3.12 | +| Framework | FastAPI | +| Language | Python | +| Database | MongoDB 7 | +| DB Driver | Motor (async) | +| Kafka | confluent-kafka-python | +| Payment Provider | Stripe Python SDK | +| Validation | Pydantic v2 | +| Metrics | prometheus-client | +| Tracing | OpenTelemetry | + +--- + +## API Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/payments/{id}` | Yes (X-User-Id) | Get payment details | +| GET | `/payments/booking/{bookingId}` | Yes | Get payment by booking | +| GET | `/health` | No | Health check | +| GET | `/metrics` | No | Prometheus metrics | + +--- + +## Database Schema (MongoDB `payments` collection) + +```javascript +{ + _id: ObjectId, + id: String, // ULID + bookingId: String, + userId: String, + amount: Number, // minor currency units + currency: String, // ISO 4217 + status: String, // PENDING | SUCCESS | FAILED + idempotencyKey: String, // UUID from booking.initiated — unique index + providerTransactionId: String, // Stripe charge ID (null if failed) + failureReason: String, // null if success + stripeResponse: Object, // raw Stripe API response (for audit) + processedAt: Date, + createdAt: Date +} +``` + +Indexes: +- `{ idempotencyKey: 1 }` — unique, enforces exactly-once payment +- `{ bookingId: 1 }` — for booking lookup endpoint +- `{ userId: 1, createdAt: -1 }` — for user payment history + +--- + +## Kafka Topics + +| Topic | Direction | When | +|---|---|---| +| `ticketflow.payment.processed` | **Produced** | After charge attempt (success or failure) | +| `ticketflow.payment.requested` | **Consumed** | Process payment | + +--- + +## Key Business Logic + +### Idempotent payment processing + +The `idempotencyKey` (UUID carried from `booking.initiated`) is used as the Stripe idempotency key AND as a unique MongoDB key. This prevents double-charging if the Kafka message is replayed. + +```python +async def process_payment(event: PaymentRequestedEvent) -> PaymentRecord: + # Check if already processed (idempotent replay protection) + existing = await db.payments.find_one({"idempotencyKey": event.idempotency_key}) + if existing: + return PaymentRecord(**existing) + + try: + charge = stripe.PaymentIntent.create( + amount=event.amount, + currency=event.currency.lower(), + payment_method=event.payment_method_token, + confirm=True, + idempotency_key=event.idempotency_key, + ) + status = "SUCCESS" + provider_tx_id = charge.id + failure_reason = None + except stripe.error.CardError as e: + status = "FAILED" + provider_tx_id = None + failure_reason = e.code # e.g. "card_declined", "insufficient_funds" + + payment = PaymentRecord( + id=ulid(), + booking_id=event.booking_id, + user_id=event.user_id, + amount=event.amount, + currency=event.currency, + status=status, + idempotency_key=event.idempotency_key, + provider_transaction_id=provider_tx_id, + failure_reason=failure_reason, + processed_at=datetime.utcnow(), + ) + await db.payments.insert_one(payment.dict()) + return payment +``` + +### Kafka consumer with retry + +```python +@app.on_event("startup") +async def start_consumer(): + consumer = AIOKafkaConsumer( + "ticketflow.payment.requested", + bootstrap_servers=settings.kafka_bootstrap_servers, + group_id="payment-service", + enable_auto_commit=False, + ) + await consumer.start() + asyncio.create_task(consume_loop(consumer)) + +async def consume_loop(consumer): + async for msg in consumer: + try: + event = PaymentRequestedEvent.model_validate_json(msg.value) + payment = await process_payment(event) + await publish_payment_processed(payment) + await consumer.commit() + except Exception as e: + logger.error(f"Failed to process payment message: {e}", exc_info=True) + # Do not commit — message will be redelivered +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Listening port | `3005` | +| `MONGODB_URL` | MongoDB connection string | `mongodb://ticketflow:pass@mongodb:27017/ticketflow_payments` | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker | `kafka:9092` | +| `STRIPE_SECRET_KEY` | Stripe API secret key | — | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | — | + +--- + +## Local Development + +```bash +cd services/payment-service +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 3005 +``` + +For testing without a real Stripe key, set `STRIPE_SECRET_KEY=sk_test_...` and use Stripe test tokens (`tok_visa`, `tok_chargeDeclined`). + +--- + +## Docker Build + +```dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY app/ ./app/ +EXPOSE 3005 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3005"] +``` diff --git a/ticketflow/docs/services/user-service.md b/ticketflow/docs/services/user-service.md new file mode 100644 index 0000000..2f1e0b2 --- /dev/null +++ b/ticketflow/docs/services/user-service.md @@ -0,0 +1,163 @@ +# User Service + +## Responsibility + +The User Service manages user identities, credentials, and JWT issuance. It is the authority on who a user is and whether their credentials are valid. + +Responsibilities: +- User registration (with password hashing using bcrypt) +- User login (credential validation, JWT generation) +- User profile retrieval +- Publishing `user.registered` events to Kafka (triggers welcome email) + +--- + +## Technology Stack + +| Component | Technology | +|---|---| +| Runtime | JDK 21 | +| Framework | Spring Boot 3.3 | +| Language | Java | +| Database | PostgreSQL 16 | +| ORM | Spring Data JPA (Hibernate) | +| Migrations | Flyway | +| Auth | Spring Security + JJWT | +| Kafka | Spring Kafka | +| Metrics | Micrometer → Prometheus | +| Tracing | OpenTelemetry Java Agent | + +--- + +## API Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/register` | No | Register a new user | +| POST | `/login` | No | Authenticate, receive JWT | +| GET | `/me` | Yes (X-User-Id header) | Get current user profile | +| GET | `/actuator/health` | No | Spring Boot health check | +| GET | `/actuator/prometheus` | No | Prometheus metrics | + +--- + +## Database Schema + +```sql +-- V1__create_users.sql +CREATE TABLE users ( + id VARCHAR(26) PRIMARY KEY, -- ULID + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, -- bcrypt hash + role VARCHAR(20) NOT NULL DEFAULT 'USER', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_email ON users(email); +``` + +--- + +## Kafka Topics + +| Action | Topic | Direction | +|---|---|---| +| User registered | `ticketflow.user.registered` | Produced | + +### Producer configuration + +```java +@Bean +public ProducerFactory producerFactory() { + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + return new DefaultKafkaProducerFactory<>(props); +} +``` + +--- + +## Key Business Logic + +### Password hashing + +```java +@Bean +public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); // cost factor 12 +} +``` + +### JWT generation + +```java +public String generateToken(User user) { + return Jwts.builder() + .subject(user.getId()) + .claim("name", user.getName()) + .claim("email", user.getEmail()) + .claim("role", user.getRole().name()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expiryMs)) + .signWith(signingKey) + .compact(); +} +``` + +The gateway verifies tokens using the same `JWT_SECRET`; the User Service does not need to validate tokens on every request. + +--- + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `SERVER_PORT` | Listening port | `3001` | +| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL | `jdbc:postgresql://postgres:5432/ticketflow` | +| `SPRING_DATASOURCE_USERNAME` | DB username | `ticketflow` | +| `SPRING_DATASOURCE_PASSWORD` | DB password | — | +| `JWT_SECRET` | HMAC signing key | — | +| `JWT_EXPIRY_HOURS` | Token lifetime | `24` | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker | `kafka:9092` | +| `OTEL_EXPORTER_JAEGER_ENDPOINT` | Jaeger endpoint | `http://jaeger:14268/api/traces` | + +--- + +## Local Development + +```bash +cd services/user-service + +# Run with local profile (uses localhost for DB and Kafka) +mvn spring-boot:run -Dspring-boot.run.profiles=local + +# Run tests +mvn test + +# Build fat JAR +mvn package -DskipTests +``` + +--- + +## Docker Build + +```dockerfile +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /app +COPY pom.xml ./ +COPY src ./src +RUN ./mvnw package -DskipTests + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/target/*.jar app.jar +EXPOSE 3001 +ENTRYPOINT ["java", "-jar", "app.jar"] +``` diff --git a/ticketflow/frontend/Dockerfile b/ticketflow/frontend/Dockerfile new file mode 100644 index 0000000..8de2ec2 --- /dev/null +++ b/ticketflow/frontend/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: Build +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Stage 2: Serve with Nginx +FROM nginx:1.27-alpine AS runner + +# Remove default nginx static assets +RUN rm -rf /usr/share/nginx/html/* + +# Copy built assets from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy custom nginx config for SPA routing +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/ticketflow/frontend/nginx.conf b/ticketflow/frontend/nginx.conf new file mode 100644 index 0000000..4bc61a6 --- /dev/null +++ b/ticketflow/frontend/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback — all routes serve index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; +} diff --git a/ticketflow/frontend/src/lib/api.ts b/ticketflow/frontend/src/lib/api.ts index 32ea5c3..9832c71 100644 --- a/ticketflow/frontend/src/lib/api.ts +++ b/ticketflow/frontend/src/lib/api.ts @@ -70,6 +70,13 @@ export interface Booking { updatedAt: string } +// Returned immediately from POST /bookings (202 Accepted) +export interface BookingAccepted { + bookingId: string + status: 'PENDING' + message: string +} + // Auth API export const authApi = { register: async (data: { name: string; email: string; password: string }) => { @@ -108,8 +115,14 @@ export const inventoryApi = { // Bookings API export const bookingsApi = { - create: async (data: { eventId: string; seatIds: string[] }) => { - const res = await api.post<{ booking: Booking }>('/bookings', data) + /** + * Initiates a booking. Returns 202 Accepted immediately. + * Use `pollBookingStatus` to wait for the final status. + */ + create: async (data: { eventId: string; seatIds: string[] }): Promise => { + const res = await api.post('/bookings', data, { + validateStatus: (status) => status === 202, + }) return res.data }, getMyBookings: async () => { @@ -117,11 +130,32 @@ export const bookingsApi = { return res.data }, getById: async (id: string) => { - const res = await api.get<{ booking: Booking }>(`/bookings/${id}`) + const res = await api.get(`/bookings/${id}`) return res.data }, cancel: async (id: string) => { - const res = await api.post<{ booking: Booking }>(`/bookings/${id}/cancel`) + const res = await api.post(`/bookings/${id}/cancel`) return res.data }, } + +const POLL_INTERVAL_MS = 1500 +const POLL_TIMEOUT_MS = 30_000 + +/** + * Polls GET /bookings/:id every 1.5s until the booking leaves PENDING state, + * or until the 30-second timeout is reached. + */ +export async function pollBookingStatus(bookingId: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS + + while (Date.now() < deadline) { + const booking = await bookingsApi.getById(bookingId) + if (booking.status !== 'PENDING') { + return booking + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + + throw new Error('Booking confirmation timed out. Check "My Bookings" for the final status.') +} diff --git a/ticketflow/frontend/src/pages/EventPage.tsx b/ticketflow/frontend/src/pages/EventPage.tsx index 54c92f4..9d301d6 100644 --- a/ticketflow/frontend/src/pages/EventPage.tsx +++ b/ticketflow/frontend/src/pages/EventPage.tsx @@ -1,12 +1,14 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useAuth } from '@/contexts/AuthContext' -import { bookingsApi, Event, eventsApi, inventoryApi, Seat } from '@/lib/api' +import { bookingsApi, Event, eventsApi, inventoryApi, pollBookingStatus, Seat } from '@/lib/api' import { formatCurrency, formatDateTime } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +type BookingPhase = 'idle' | 'submitting' | 'polling' | 'done' + export function EventPage() { const { id } = useParams() const navigate = useNavigate() @@ -16,7 +18,7 @@ export function EventPage() { const [seats, setSeats] = useState([]) const [selected, setSelected] = useState([]) const [loading, setLoading] = useState(true) - const [submitting, setSubmitting] = useState(false) + const [phase, setPhase] = useState('idle') const [error, setError] = useState(null) useEffect(() => { @@ -54,19 +56,40 @@ export function EventPage() { return } - setSubmitting(true) + setPhase('submitting') setError(null) try { - await bookingsApi.create({ eventId: id, seatIds: selected }) - navigate('/bookings') - } catch { - setError('Booking failed. The selected seats may have been taken.') - } finally { - setSubmitting(false) + // POST /bookings → 202 Accepted + const { bookingId } = await bookingsApi.create({ eventId: id, seatIds: selected }) + + // Poll until booking leaves PENDING state + setPhase('polling') + const booking = await pollBookingStatus(bookingId) + + if (booking.status === 'CONFIRMED') { + setPhase('done') + navigate('/bookings') + } else { + setPhase('idle') + setError('Booking could not be confirmed. The seats may have been taken or payment failed.') + } + } catch (err: unknown) { + setPhase('idle') + if (err instanceof Error) { + setError(err.message) + } else { + setError('Booking failed. Please try again.') + } } } + const submitLabel = () => { + if (phase === 'submitting') return 'Placing order...' + if (phase === 'polling') return 'Confirming...' + return 'Confirm Booking' + } + if (loading) { return
Loading event...
} @@ -100,9 +123,11 @@ export function EventPage() { {seats.map((seat) => { const isSelected = selected.includes(seat.id) - const disabled = seat.status !== 'AVAILABLE' + const disabled = seat.status !== 'AVAILABLE' || busy + + const busy = phase === 'submitting' || phase === 'polling' - return ( + return ( diff --git a/ticketflow/gateway/Dockerfile b/ticketflow/gateway/Dockerfile index 99bafae..be5b37f 100644 --- a/ticketflow/gateway/Dockerfile +++ b/ticketflow/gateway/Dockerfile @@ -1,9 +1,12 @@ -FROM node:20-alpine +FROM oven/bun:1.1-alpine AS base WORKDIR /app -RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install --frozen-lockfile + +FROM base AS deps +COPY package.json bun.lockb* ./ +RUN bun install --frozen-lockfile + +FROM base AS runner +COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN pnpm run build EXPOSE 3000 -CMD ["node", "dist/src/index.js"] +CMD ["bun", "src/index.ts"] diff --git a/ticketflow/gateway/jest.config.js b/ticketflow/gateway/jest.config.js deleted file mode 100644 index aa47a1c..0000000 --- a/ticketflow/gateway/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts'], - moduleNameMapper: { - '^@ticketflow/shared$': '/../shared/src/index.ts', - }, -}; diff --git a/ticketflow/gateway/package.json b/ticketflow/gateway/package.json index 3567455..3669278 100644 --- a/ticketflow/gateway/package.json +++ b/ticketflow/gateway/package.json @@ -1,28 +1,18 @@ { - "name": "gateway", + "name": "@ticketflow/gateway", "version": "1.0.0", "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/src/index.js", - "test": "jest --forceExit --passWithNoTests" + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "build": "bun build src/index.ts --outdir dist" }, "dependencies": { - "@ticketflow/shared": "workspace:*", - "dotenv": "^16.3.1", - "express": "^4.18.2", - "express-rate-limit": "^7.1.5", - "http-proxy-middleware": "^2.0.6", - "jsonwebtoken": "^9.0.2" + "elysia": "^1.1.25", + "@elysiajs/cors": "^1.1.1", + "@elysiajs/bearer": "^1.1.0", + "jose": "^5.9.6" }, "devDependencies": { - "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/jsonwebtoken": "^9.0.5", - "@types/node": "^20.10.6", - "jest": "^29.7.0", - "ts-jest": "^29.1.1", - "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" + "bun-types": "^1.1.29" } } diff --git a/ticketflow/gateway/src/app.ts b/ticketflow/gateway/src/app.ts index edf2b51..a730a1d 100644 --- a/ticketflow/gateway/src/app.ts +++ b/ticketflow/gateway/src/app.ts @@ -1,13 +1,145 @@ -import express from 'express'; -import { errorHandler } from '@ticketflow/shared'; -import { rateLimiter } from './middleware/rateLimiter'; -import { gatewayAuth } from './middleware/auth'; -import { setupRoutes } from './routes'; - -export const app = express(); - -app.use(express.json()); -app.use(rateLimiter); -app.use(gatewayAuth); -setupRoutes(app); -app.use(errorHandler); +import Elysia from "elysia"; +import { cors } from "@elysiajs/cors"; +import { config } from "./config"; +import { decodeToken } from "./middleware/auth"; +import { checkRateLimit } from "./middleware/rateLimiter"; +import { proxyRequest } from "./proxy"; + +const rateLimitedError = () => + new Response( + JSON.stringify({ error: { code: "RATE_LIMITED", message: "Too many requests" } }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); + +function getIp(request: Request): string { + return ( + request.headers.get("cf-connecting-ip") || + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + "unknown" + ); +} + +function getAuthHeaders(request: Request): Record { + const auth = decodeToken(request.headers.get("Authorization")); + if (!auth) return {}; + return { + "x-user-id": auth.userId ?? "", + "x-user-email": auth.email ?? "", + "x-user-role": auth.role ?? "USER", + }; +} + +export const createApp = () => { + const app = new Elysia() + .use(cors()) + + // Basic gateway health check + .get("/health", () => ({ + status: "up", + service: "gateway", + timestamp: new Date().toISOString(), + services: config.services, + })) + + // Aggregate health: fan out to all downstream services + .get("/health/all", async () => { + const results = await Promise.allSettled( + Object.entries(config.services).map(async ([name, url]) => { + try { + const res = await fetch(`${url}/health`, { + signal: AbortSignal.timeout(2000), + }); + const data = await res.json(); + return { name, status: "up", ...data }; + } catch { + return { name, status: "down" }; + } + }) + ); + return { + gateway: "up", + services: results.map((r) => + r.status === "fulfilled" ? r.value : { status: "down" } + ), + }; + }) + + // User Service routes + .all("/api/users/*", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.user}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) + + // Event Service routes + .all("/api/events/*", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.event}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) + + // Venues → Event Service + .all("/api/venues/*", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.event}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) + + // Booking Service routes + .all("/api/bookings/*", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.booking}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) + + // Inventory Service routes + .all("/api/inventory/*", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.inventory}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) + + // Payment Service routes + .all("/api/payments/*", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.payment}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) + + // Notification Service routes + .all("/api/notifications/*", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.notification}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }); + + return app; +}; diff --git a/ticketflow/gateway/src/config.ts b/ticketflow/gateway/src/config.ts new file mode 100644 index 0000000..5a288f3 --- /dev/null +++ b/ticketflow/gateway/src/config.ts @@ -0,0 +1,16 @@ +export const config = { + port: parseInt(process.env.PORT || "3000"), + jwtSecret: process.env.JWT_SECRET || "default-secret-change-in-production-min-32-chars", + services: { + user: process.env.USER_SERVICE_URL || "http://user-service:3001", + event: process.env.EVENT_SERVICE_URL || "http://event-service:3002", + booking: process.env.BOOKING_SERVICE_URL || "http://booking-service:3003", + inventory: process.env.INVENTORY_SERVICE_URL || "http://inventory-service:3004", + payment: process.env.PAYMENT_SERVICE_URL || "http://payment-service:3005", + notification: process.env.NOTIFICATION_SERVICE_URL || "http://notification-service:3006", + }, + rateLimit: { + windowMs: 60_000, + maxRequests: 100, + }, +}; diff --git a/ticketflow/gateway/src/index.ts b/ticketflow/gateway/src/index.ts index 2ed0de9..dad9f5f 100644 --- a/ticketflow/gateway/src/index.ts +++ b/ticketflow/gateway/src/index.ts @@ -1,8 +1,9 @@ -import 'dotenv/config'; -import { app } from './app'; +import { createApp } from "./app"; +import { config } from "./config"; -const PORT = process.env.PORT ?? 3000; +const app = createApp(); -app.listen(PORT, () => { - console.log(`API Gateway listening on port ${PORT}`); +app.listen(config.port, () => { + console.log(`[Gateway] Running on port ${config.port}`); + console.log(`[Gateway] Routing to:`, config.services); }); diff --git a/ticketflow/gateway/src/middleware/auth.ts b/ticketflow/gateway/src/middleware/auth.ts index 5b1fcf4..4cbbc11 100644 --- a/ticketflow/gateway/src/middleware/auth.ts +++ b/ticketflow/gateway/src/middleware/auth.ts @@ -1,29 +1,22 @@ -import { Request, Response, NextFunction } from 'express'; -import jwt from 'jsonwebtoken'; -import { JwtPayload } from '@ticketflow/shared'; +import { decodeJwt } from "jose"; -export function gatewayAuth(req: Request, res: Response, next: NextFunction): void { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) { - next(); - return; - } +export interface AuthPayload { + userId: string; + email: string; + role: string; +} +export function decodeToken(authHeader: string | null): AuthPayload | null { + if (!authHeader || !authHeader.startsWith("Bearer ")) return null; const token = authHeader.slice(7); - const secret = process.env.JWT_SECRET; - if (!secret) { - next(); - return; - } - try { - const decoded = jwt.verify(token, secret) as JwtPayload; - req.headers['x-user-id'] = decoded.sub; - req.headers['x-user-email'] = decoded.email; - req.headers['x-user-role'] = decoded.role; + const payload = decodeJwt(token); + return { + userId: payload.sub as string, + email: payload.email as string, + role: (payload.role as string) || "USER", + }; } catch { - // Invalid token — let downstream services handle it + return null; } - - next(); } diff --git a/ticketflow/gateway/src/middleware/rateLimiter.ts b/ticketflow/gateway/src/middleware/rateLimiter.ts index f74a494..8e8144b 100644 --- a/ticketflow/gateway/src/middleware/rateLimiter.ts +++ b/ticketflow/gateway/src/middleware/rateLimiter.ts @@ -1,14 +1,24 @@ -import rateLimit from 'express-rate-limit'; - -export const rateLimiter = rateLimit({ - windowMs: 60 * 1000, // 1 minute - max: 100, - standardHeaders: true, - legacyHeaders: false, - message: { - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: 'Too many requests, please try again later.', - }, - }, -}); +// Simple in-memory rate limiter +const requests = new Map(); + +export function checkRateLimit(ip: string, maxRequests: number, windowMs: number): boolean { + const now = Date.now(); + const record = requests.get(ip); + + if (!record || now > record.resetAt) { + requests.set(ip, { count: 1, resetAt: now + windowMs }); + return true; + } + + if (record.count >= maxRequests) return false; + record.count++; + return true; +} + +// Cleanup old entries every minute +setInterval(() => { + const now = Date.now(); + for (const [key, val] of requests.entries()) { + if (now > val.resetAt) requests.delete(key); + } +}, 60_000); diff --git a/ticketflow/gateway/src/proxy.ts b/ticketflow/gateway/src/proxy.ts new file mode 100644 index 0000000..654d6a9 --- /dev/null +++ b/ticketflow/gateway/src/proxy.ts @@ -0,0 +1,47 @@ +// Generic proxy function using Bun's native fetch +export async function proxyRequest( + targetUrl: string, + request: Request, + additionalHeaders: Record = {} +): Promise { + const headers = new Headers(request.headers); + + for (const [key, value] of Object.entries(additionalHeaders)) { + headers.set(key, value); + } + + // Remove host header to prevent issues + headers.delete("host"); + + let body: BodyInit | null = null; + if (request.method !== "GET" && request.method !== "HEAD") { + body = await request.arrayBuffer(); + } + + try { + const response = await fetch(targetUrl, { + method: request.method, + headers, + body, + }); + + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + } catch (err) { + console.error(`[Proxy] Failed to reach ${targetUrl}:`, err); + return new Response( + JSON.stringify({ + error: { + code: "SERVICE_UNAVAILABLE", + message: "Upstream service is unavailable", + }, + }), + { + status: 503, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/ticketflow/gateway/src/routes.ts b/ticketflow/gateway/src/routes.ts deleted file mode 100644 index 0daba62..0000000 --- a/ticketflow/gateway/src/routes.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Express, Request, Response } from 'express'; -import { createProxyMiddleware, fixRequestBody, Options } from 'http-proxy-middleware'; - -const USER_SERVICE_URL = process.env.USER_SERVICE_URL ?? 'http://localhost:3001'; -const EVENT_SERVICE_URL = process.env.EVENT_SERVICE_URL ?? 'http://localhost:3002'; -const BOOKING_SERVICE_URL = process.env.BOOKING_SERVICE_URL ?? 'http://localhost:3003'; -const INVENTORY_SERVICE_URL = process.env.INVENTORY_SERVICE_URL ?? 'http://localhost:3004'; -const PAYMENT_SERVICE_URL = process.env.PAYMENT_SERVICE_URL ?? 'http://localhost:3005'; - -function proxy(target: string): ReturnType { - const options: Options = { - target, - changeOrigin: true, - onProxyReq: fixRequestBody, - onError: (err: Error, _req: Request, res: Response) => { - console.error(`Proxy error to ${target}:`, err.message); - res.status(503).json({ - error: { code: 'SERVICE_UNAVAILABLE', message: 'Upstream service is unavailable' }, - }); - }, - }; - return createProxyMiddleware(options); -} - -export function setupRoutes(app: Express): void { - app.use('/api/users', proxy(USER_SERVICE_URL)); - app.use('/api/events', proxy(EVENT_SERVICE_URL)); - app.use('/api/bookings', proxy(BOOKING_SERVICE_URL)); - app.use('/api/inventory', proxy(INVENTORY_SERVICE_URL)); - app.use('/api/payments', proxy(PAYMENT_SERVICE_URL)); -} diff --git a/ticketflow/gateway/tsconfig.json b/ticketflow/gateway/tsconfig.json index b821d79..cc85826 100644 --- a/ticketflow/gateway/tsconfig.json +++ b/ticketflow/gateway/tsconfig.json @@ -1,15 +1,9 @@ { "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] + "types": ["bun-types"] + } } diff --git a/ticketflow/infra/kafka/create-topics.sh b/ticketflow/infra/kafka/create-topics.sh new file mode 100644 index 0000000..d997d83 --- /dev/null +++ b/ticketflow/infra/kafka/create-topics.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# TicketFlow Kafka Topic Initialization +# Run this script after Kafka starts to create all required topics + +set -e + +KAFKA_BROKER="${KAFKA_BROKER:-localhost:9092}" +PARTITIONS="${PARTITIONS:-3}" +REPLICATION_FACTOR="${REPLICATION_FACTOR:-1}" +RETENTION_MS="${RETENTION_MS:-604800000}" # 7 days + +KAFKA_TOPICS=( + "ticketflow.user.registered" + "ticketflow.event.created" + "ticketflow.booking.initiated" + "ticketflow.seats.locked" + "ticketflow.seats.lock-failed" + "ticketflow.seats.confirm" + "ticketflow.seats.release" + "ticketflow.payment.requested" + "ticketflow.payment.processed" + "ticketflow.booking.confirmed" + "ticketflow.booking.failed" + "ticketflow.booking.cancelled" +) + +# Dead-letter topics (DLT) for consumer retry failures +DLT_TOPICS=( + "ticketflow.booking.initiated.DLT" + "ticketflow.payment.requested.DLT" + "ticketflow.booking.confirmed.DLT" + "ticketflow.booking.failed.DLT" +) + +echo "Waiting for Kafka to be ready at ${KAFKA_BROKER}..." +until kafka-topics.sh --bootstrap-server "${KAFKA_BROKER}" --list &>/dev/null 2>&1; do + echo " Kafka not ready yet, retrying in 3s..." + sleep 3 +done +echo "Kafka is ready!" + +echo "" +echo "Creating main topics..." +for TOPIC in "${KAFKA_TOPICS[@]}"; do + if kafka-topics.sh --bootstrap-server "${KAFKA_BROKER}" --describe --topic "${TOPIC}" &>/dev/null 2>&1; then + echo " [SKIP] ${TOPIC} (already exists)" + else + kafka-topics.sh --bootstrap-server "${KAFKA_BROKER}" \ + --create \ + --topic "${TOPIC}" \ + --partitions "${PARTITIONS}" \ + --replication-factor "${REPLICATION_FACTOR}" \ + --config "retention.ms=${RETENTION_MS}" \ + --config "compression.type=snappy" + echo " [CREATE] ${TOPIC}" + fi +done + +echo "" +echo "Creating dead-letter topics..." +for TOPIC in "${DLT_TOPICS[@]}"; do + if kafka-topics.sh --bootstrap-server "${KAFKA_BROKER}" --describe --topic "${TOPIC}" &>/dev/null 2>&1; then + echo " [SKIP] ${TOPIC} (already exists)" + else + kafka-topics.sh --bootstrap-server "${KAFKA_BROKER}" \ + --create \ + --topic "${TOPIC}" \ + --partitions 1 \ + --replication-factor "${REPLICATION_FACTOR}" \ + --config "retention.ms=2592000000" # 30 days for DLT + echo " [CREATE] ${TOPIC}" + fi +done + +echo "" +echo "Topic creation complete. Listing all TicketFlow topics:" +kafka-topics.sh --bootstrap-server "${KAFKA_BROKER}" --list | grep "ticketflow" diff --git a/ticketflow/infra/mongo/init.js b/ticketflow/infra/mongo/init.js new file mode 100644 index 0000000..7a29b75 --- /dev/null +++ b/ticketflow/infra/mongo/init.js @@ -0,0 +1,29 @@ +// TicketFlow MongoDB Initialization +// Creates all MongoDB databases and collections for services that use MongoDB + +// Event Service Database +db = db.getSiblingDB('ticketflow_events'); +db.createCollection('events'); +db.createCollection('venues'); +db.events.createIndex({ "name": "text", "description": "text" }); +db.events.createIndex({ "date": 1 }); +db.events.createIndex({ "created_at": -1 }); +db.venues.createIndex({ "city": 1, "country": 1 }); + +// Payment Service Database +db = db.getSiblingDB('ticketflow_payments'); +db.createCollection('payments'); +db.payments.createIndex({ "booking_id": 1 }, { unique: false }); +db.payments.createIndex({ "user_id": 1 }); +db.payments.createIndex({ "status": 1 }); +db.payments.createIndex({ "created_at": -1 }); + +// Notification Service Database +db = db.getSiblingDB('ticketflow_notifications'); +db.createCollection('notifications'); +db.notifications.createIndex({ "recipient_email": 1 }); +db.notifications.createIndex({ "type": 1 }); +db.notifications.createIndex({ "status": 1 }); +db.notifications.createIndex({ "created_at": -1 }); + +print('MongoDB initialization complete for TicketFlow databases'); diff --git a/ticketflow/infra/postgres/init.sql b/ticketflow/infra/postgres/init.sql index e202cbe..766e55a 100644 --- a/ticketflow/infra/postgres/init.sql +++ b/ticketflow/infra/postgres/init.sql @@ -1,5 +1,18 @@ -CREATE DATABASE users; -CREATE DATABASE events; -CREATE DATABASE bookings; -CREATE DATABASE inventory; -CREATE DATABASE payments; +-- TicketFlow PostgreSQL Initialization +-- Creates all PostgreSQL databases for services that use PostgreSQL + +CREATE DATABASE ticketflow_users; +CREATE DATABASE ticketflow_bookings; +CREATE DATABASE ticketflow_inventory; + +-- Connect to users DB and create extensions +\connect ticketflow_users +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Connect to bookings DB and create extensions +\connect ticketflow_bookings +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Connect to inventory DB and create extensions +\connect ticketflow_inventory +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; diff --git a/ticketflow/k8s/apps/booking-service/deployment.yaml b/ticketflow/k8s/apps/booking-service/deployment.yaml new file mode 100644 index 0000000..4a6e9ca --- /dev/null +++ b/ticketflow/k8s/apps/booking-service/deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: booking-service + namespace: ticketflow + labels: + app: booking-service + app.kubernetes.io/component: backend +spec: + replicas: 2 + selector: + matchLabels: + app: booking-service + template: + metadata: + labels: + app: booking-service + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3003" + prometheus.io/path: "/metrics" + spec: + containers: + - name: booking-service + image: ticketflow/booking-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3003 + envFrom: + - configMapRef: + name: ticketflow-config + - secretRef: + name: ticketflow-secrets + env: + - name: PORT + value: "3003" + # JDBC URL for PostgreSQL bookings database + - name: BOOKING_DB_URL + value: "jdbc:postgresql://postgres:5432/ticketflow_bookings" + resources: + # JVM services require more memory + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + readinessProbe: + httpGet: + path: /api/bookings/health + port: 3003 + # JVM startup takes longer + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /api/bookings/health + port: 3003 + initialDelaySeconds: 90 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: booking-service + namespace: ticketflow + labels: + app: booking-service + app.kubernetes.io/component: backend +spec: + selector: + app: booking-service + ports: + - port: 3003 + targetPort: 3003 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/booking-service/hpa.yaml b/ticketflow/k8s/apps/booking-service/hpa.yaml new file mode 100644 index 0000000..0ec39e3 --- /dev/null +++ b/ticketflow/k8s/apps/booking-service/hpa.yaml @@ -0,0 +1,27 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: booking-service + namespace: ticketflow + labels: + app: booking-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: booking-service + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/ticketflow/k8s/apps/event-service/deployment.yaml b/ticketflow/k8s/apps/event-service/deployment.yaml new file mode 100644 index 0000000..6af3ceb --- /dev/null +++ b/ticketflow/k8s/apps/event-service/deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: event-service + namespace: ticketflow + labels: + app: event-service + app.kubernetes.io/component: backend +spec: + replicas: 2 + selector: + matchLabels: + app: event-service + template: + metadata: + labels: + app: event-service + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3002" + prometheus.io/path: "/metrics" + spec: + containers: + - name: event-service + image: ticketflow/event-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3002 + envFrom: + - configMapRef: + name: ticketflow-config + - secretRef: + name: ticketflow-secrets + env: + - name: PORT + value: "3002" + # MongoDB connection string using credentials from secrets + - name: MONGODB_URL + value: "mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@mongodb:27017" + - name: MONGODB_DB + value: "ticketflow_events" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: /health + port: 3002 + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 3002 + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: event-service + namespace: ticketflow + labels: + app: event-service + app.kubernetes.io/component: backend +spec: + selector: + app: event-service + ports: + - port: 3002 + targetPort: 3002 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/event-service/hpa.yaml b/ticketflow/k8s/apps/event-service/hpa.yaml new file mode 100644 index 0000000..efe8fea --- /dev/null +++ b/ticketflow/k8s/apps/event-service/hpa.yaml @@ -0,0 +1,27 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: event-service + namespace: ticketflow + labels: + app: event-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: event-service + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/ticketflow/k8s/apps/frontend/deployment.yaml b/ticketflow/k8s/apps/frontend/deployment.yaml new file mode 100644 index 0000000..97be849 --- /dev/null +++ b/ticketflow/k8s/apps/frontend/deployment.yaml @@ -0,0 +1,68 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: ticketflow + labels: + app: frontend + app.kubernetes.io/component: frontend +spec: + replicas: 2 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + app.kubernetes.io/component: frontend + spec: + containers: + - name: frontend + image: ticketflow/frontend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + env: + - name: VITE_API_BASE_URL + value: "https://api.ticketflow.example.com" + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 20 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: ticketflow + labels: + app: frontend + app.kubernetes.io/component: frontend +spec: + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/gateway/deployment.yaml b/ticketflow/k8s/apps/gateway/deployment.yaml new file mode 100644 index 0000000..c82cbae --- /dev/null +++ b/ticketflow/k8s/apps/gateway/deployment.yaml @@ -0,0 +1,107 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gateway + namespace: ticketflow + labels: + app: gateway + app.kubernetes.io/component: backend +spec: + replicas: 2 + selector: + matchLabels: + app: gateway + template: + metadata: + labels: + app: gateway + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3000" + prometheus.io/path: "/metrics" + spec: + containers: + - name: gateway + image: ticketflow/gateway:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3000 + envFrom: + - configMapRef: + name: ticketflow-config + - secretRef: + name: ticketflow-secrets + env: + - name: PORT + value: "3000" + - name: USER_SERVICE_URL + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: USER_SERVICE_URL + - name: EVENT_SERVICE_URL + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: EVENT_SERVICE_URL + - name: BOOKING_SERVICE_URL + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: BOOKING_SERVICE_URL + - name: INVENTORY_SERVICE_URL + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: INVENTORY_SERVICE_URL + - name: PAYMENT_SERVICE_URL + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: PAYMENT_SERVICE_URL + - name: NOTIFICATION_SERVICE_URL + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: NOTIFICATION_SERVICE_URL + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: gateway + namespace: ticketflow + labels: + app: gateway + app.kubernetes.io/component: backend +spec: + selector: + app: gateway + ports: + - port: 3000 + targetPort: 3000 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/gateway/hpa.yaml b/ticketflow/k8s/apps/gateway/hpa.yaml new file mode 100644 index 0000000..7caafb2 --- /dev/null +++ b/ticketflow/k8s/apps/gateway/hpa.yaml @@ -0,0 +1,27 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: gateway + namespace: ticketflow + labels: + app: gateway +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: gateway + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/ticketflow/k8s/apps/inventory-service/deployment.yaml b/ticketflow/k8s/apps/inventory-service/deployment.yaml new file mode 100644 index 0000000..228647a --- /dev/null +++ b/ticketflow/k8s/apps/inventory-service/deployment.yaml @@ -0,0 +1,83 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: inventory-service + namespace: ticketflow + labels: + app: inventory-service + app.kubernetes.io/component: backend +spec: + replicas: 2 + selector: + matchLabels: + app: inventory-service + template: + metadata: + labels: + app: inventory-service + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3004" + prometheus.io/path: "/metrics" + spec: + containers: + - name: inventory-service + image: ticketflow/inventory-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3004 + envFrom: + - configMapRef: + name: ticketflow-config + - secretRef: + name: ticketflow-secrets + env: + - name: PORT + value: "3004" + # PostgreSQL connection using libpq-style URL (Bun/Node compatible) + - name: INVENTORY_DB_URL + value: "postgresql://$(DB_USERNAME):$(DB_PASSWORD)@postgres:5432/ticketflow_inventory" + # Redis for caching inventory counts + - name: REDIS_URL + value: "redis://:$(REDIS_PASSWORD)@redis:6379" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: /health + port: 3004 + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 3004 + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: inventory-service + namespace: ticketflow + labels: + app: inventory-service + app.kubernetes.io/component: backend +spec: + selector: + app: inventory-service + ports: + - port: 3004 + targetPort: 3004 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/inventory-service/hpa.yaml b/ticketflow/k8s/apps/inventory-service/hpa.yaml new file mode 100644 index 0000000..2c13e6d --- /dev/null +++ b/ticketflow/k8s/apps/inventory-service/hpa.yaml @@ -0,0 +1,27 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: inventory-service + namespace: ticketflow + labels: + app: inventory-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: inventory-service + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/ticketflow/k8s/apps/notification-service/deployment.yaml b/ticketflow/k8s/apps/notification-service/deployment.yaml new file mode 100644 index 0000000..6ddfb16 --- /dev/null +++ b/ticketflow/k8s/apps/notification-service/deployment.yaml @@ -0,0 +1,107 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notification-service + namespace: ticketflow + labels: + app: notification-service + app.kubernetes.io/component: backend +spec: + # replicas: 1 — KEDA will handle autoscaling based on Kafka lag + replicas: 1 + selector: + matchLabels: + app: notification-service + template: + metadata: + labels: + app: notification-service + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3006" + prometheus.io/path: "/metrics" + spec: + containers: + - name: notification-service + image: ticketflow/notification-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3006 + envFrom: + - configMapRef: + name: ticketflow-config + - secretRef: + name: ticketflow-secrets + env: + - name: PORT + value: "3006" + - name: MONGODB_URL + value: "mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@mongodb:27017" + - name: MONGODB_DB + value: "ticketflow_notifications" + - name: SMTP_HOST + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: SMTP_HOST + - name: SMTP_PORT + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: SMTP_PORT + - name: SMTP_FROM + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: SMTP_FROM + - name: SMTP_USERNAME + valueFrom: + secretKeyRef: + name: ticketflow-secrets + key: SMTP_USERNAME + - name: SMTP_PASSWORD + valueFrom: + secretKeyRef: + name: ticketflow-secrets + key: SMTP_PASSWORD + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: /health + port: 3006 + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 3006 + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: notification-service + namespace: ticketflow + labels: + app: notification-service + app.kubernetes.io/component: backend +spec: + selector: + app: notification-service + ports: + - port: 3006 + targetPort: 3006 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/notification-service/hpa.yaml b/ticketflow/k8s/apps/notification-service/hpa.yaml new file mode 100644 index 0000000..e12bbc9 --- /dev/null +++ b/ticketflow/k8s/apps/notification-service/hpa.yaml @@ -0,0 +1,28 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: notification-service + namespace: ticketflow + labels: + app: notification-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: notification-service + # minReplicas: 1 — KEDA ScaledObject will handle the primary scaling trigger + minReplicas: 1 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/ticketflow/k8s/apps/payment-service/deployment.yaml b/ticketflow/k8s/apps/payment-service/deployment.yaml new file mode 100644 index 0000000..9dc7470 --- /dev/null +++ b/ticketflow/k8s/apps/payment-service/deployment.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: payment-service + namespace: ticketflow + labels: + app: payment-service + app.kubernetes.io/component: backend +spec: + replicas: 2 + selector: + matchLabels: + app: payment-service + template: + metadata: + labels: + app: payment-service + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3005" + prometheus.io/path: "/metrics" + spec: + containers: + - name: payment-service + image: ticketflow/payment-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3005 + envFrom: + - configMapRef: + name: ticketflow-config + - secretRef: + name: ticketflow-secrets + env: + - name: PORT + value: "3005" + - name: MONGODB_URL + value: "mongodb://$(MONGO_USERNAME):$(MONGO_PASSWORD)@mongodb:27017" + - name: MONGODB_DB + value: "ticketflow_payments" + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: /health + port: 3005 + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 3005 + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: payment-service + namespace: ticketflow + labels: + app: payment-service + app.kubernetes.io/component: backend +spec: + selector: + app: payment-service + ports: + - port: 3005 + targetPort: 3005 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/payment-service/hpa.yaml b/ticketflow/k8s/apps/payment-service/hpa.yaml new file mode 100644 index 0000000..c38b0ff --- /dev/null +++ b/ticketflow/k8s/apps/payment-service/hpa.yaml @@ -0,0 +1,28 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: payment-service + namespace: ticketflow + labels: + app: payment-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: payment-service + # minReplicas: 1 — KEDA ScaledObject will handle the primary scaling trigger + minReplicas: 1 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/ticketflow/k8s/apps/user-service/deployment.yaml b/ticketflow/k8s/apps/user-service/deployment.yaml new file mode 100644 index 0000000..bd32ba9 --- /dev/null +++ b/ticketflow/k8s/apps/user-service/deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service + namespace: ticketflow + labels: + app: user-service + app.kubernetes.io/component: backend +spec: + replicas: 2 + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + app.kubernetes.io/component: backend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3001" + prometheus.io/path: "/metrics" + spec: + containers: + - name: user-service + image: ticketflow/user-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 3001 + envFrom: + - configMapRef: + name: ticketflow-config + - secretRef: + name: ticketflow-secrets + env: + - name: PORT + value: "3001" + # JDBC URL for PostgreSQL users database + - name: USER_DB_URL + value: "jdbc:postgresql://postgres:5432/ticketflow_users" + resources: + # JVM services require more memory + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "1Gi" + cpu: "1000m" + readinessProbe: + httpGet: + path: /api/users/health + port: 3001 + # JVM startup takes longer + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /api/users/health + port: 3001 + initialDelaySeconds: 90 + periodSeconds: 30 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: user-service + namespace: ticketflow + labels: + app: user-service + app.kubernetes.io/component: backend +spec: + selector: + app: user-service + ports: + - port: 3001 + targetPort: 3001 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/apps/user-service/hpa.yaml b/ticketflow/k8s/apps/user-service/hpa.yaml new file mode 100644 index 0000000..79ddc2f --- /dev/null +++ b/ticketflow/k8s/apps/user-service/hpa.yaml @@ -0,0 +1,27 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: user-service + namespace: ticketflow + labels: + app: user-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: user-service + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/ticketflow/k8s/configmaps/app-config.yaml b/ticketflow/k8s/configmaps/app-config.yaml new file mode 100644 index 0000000..a601832 --- /dev/null +++ b/ticketflow/k8s/configmaps/app-config.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ticketflow-config + namespace: ticketflow + labels: + app.kubernetes.io/name: ticketflow + app.kubernetes.io/component: config +data: + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + USER_SERVICE_URL: http://user-service:3001 + EVENT_SERVICE_URL: http://event-service:3002 + BOOKING_SERVICE_URL: http://booking-service:3003 + INVENTORY_SERVICE_URL: http://inventory-service:3004 + PAYMENT_SERVICE_URL: http://payment-service:3005 + NOTIFICATION_SERVICE_URL: http://notification-service:3006 + SMTP_HOST: mailpit + SMTP_PORT: "1025" + SMTP_FROM: noreply@ticketflow.com + PAYMENT_SUCCESS_RATE: "0.95" + DB_USERNAME: postgres + MONGO_DB_PREFIX: ticketflow diff --git a/ticketflow/k8s/infra/kafka.yaml b/ticketflow/k8s/infra/kafka.yaml new file mode 100644 index 0000000..4637893 --- /dev/null +++ b/ticketflow/k8s/infra/kafka.yaml @@ -0,0 +1,180 @@ +--- +# Zookeeper - required by Kafka for coordination +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: zookeeper + namespace: ticketflow + labels: + app: zookeeper + app.kubernetes.io/component: messaging +spec: + serviceName: zookeeper + replicas: 1 + selector: + matchLabels: + app: zookeeper + template: + metadata: + labels: + app: zookeeper + app.kubernetes.io/component: messaging + spec: + containers: + - name: zookeeper + image: confluentinc/cp-zookeeper:7.7.0 + ports: + - containerPort: 2181 + name: client + env: + - name: ZOOKEEPER_CLIENT_PORT + value: "2181" + - name: ZOOKEEPER_TICK_TIME + value: "2000" + volumeMounts: + - name: zookeeper-data + mountPath: /var/lib/zookeeper/data + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + readinessProbe: + exec: + command: + - sh + - -c + - echo ruok | nc localhost 2181 | grep imok + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 5 + volumeClaimTemplates: + - metadata: + name: zookeeper-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 2Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: zookeeper + namespace: ticketflow + labels: + app: zookeeper + app.kubernetes.io/component: messaging +spec: + selector: + app: zookeeper + ports: + - port: 2181 + targetPort: 2181 + name: client + type: ClusterIP + +--- +# Kafka - message broker for inter-service communication +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: kafka + namespace: ticketflow + labels: + app: kafka + app.kubernetes.io/component: messaging +spec: + serviceName: kafka + replicas: 1 + selector: + matchLabels: + app: kafka + template: + metadata: + labels: + app: kafka + app.kubernetes.io/component: messaging + spec: + containers: + - name: kafka + image: confluentinc/cp-kafka:7.7.0 + ports: + - containerPort: 9092 + name: kafka + env: + - name: KAFKA_BROKER_ID + value: "1" + - name: KAFKA_ZOOKEEPER_CONNECT + value: zookeeper:2181 + - name: KAFKA_ADVERTISED_LISTENERS + value: PLAINTEXT://kafka:9092 + - name: KAFKA_LISTENERS + value: PLAINTEXT://0.0.0.0:9092 + - name: KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR + value: "1" + - name: KAFKA_AUTO_CREATE_TOPICS_ENABLE + value: "true" + - name: KAFKA_LOG_RETENTION_HOURS + value: "168" + - name: KAFKA_LOG_SEGMENT_BYTES + value: "1073741824" + volumeMounts: + - name: kafka-data + mountPath: /var/lib/kafka/data + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "1000m" + readinessProbe: + exec: + command: + - kafka-topics.sh + - --bootstrap-server + - localhost:9092 + - --list + initialDelaySeconds: 30 + periodSeconds: 10 + failureThreshold: 5 + livenessProbe: + exec: + command: + - kafka-topics.sh + - --bootstrap-server + - localhost:9092 + - --list + initialDelaySeconds: 60 + periodSeconds: 30 + failureThreshold: 3 + volumeClaimTemplates: + - metadata: + name: kafka-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 5Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: kafka + namespace: ticketflow + labels: + app: kafka + app.kubernetes.io/component: messaging +spec: + selector: + app: kafka + ports: + - port: 9092 + targetPort: 9092 + name: kafka + type: ClusterIP diff --git a/ticketflow/k8s/infra/mongodb.yaml b/ticketflow/k8s/infra/mongodb.yaml new file mode 100644 index 0000000..25aeefb --- /dev/null +++ b/ticketflow/k8s/infra/mongodb.yaml @@ -0,0 +1,91 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mongodb + namespace: ticketflow + labels: + app: mongodb + app.kubernetes.io/component: database +spec: + serviceName: mongodb + replicas: 1 + selector: + matchLabels: + app: mongodb + template: + metadata: + labels: + app: mongodb + app.kubernetes.io/component: database + spec: + containers: + - name: mongodb + image: mongo:7.0 + ports: + - containerPort: 27017 + name: mongodb + env: + - name: MONGO_INITDB_ROOT_USERNAME + valueFrom: + secretKeyRef: + name: ticketflow-secrets + key: MONGO_USERNAME + - name: MONGO_INITDB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: ticketflow-secrets + key: MONGO_PASSWORD + volumeMounts: + - name: mongodb-data + mountPath: /data/db + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "2Gi" + cpu: "500m" + readinessProbe: + exec: + command: + - mongosh + - --eval + - "db.adminCommand('ping')" + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 5 + livenessProbe: + exec: + command: + - mongosh + - --eval + - "db.adminCommand('ping')" + initialDelaySeconds: 30 + periodSeconds: 30 + failureThreshold: 3 + volumeClaimTemplates: + - metadata: + name: mongodb-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: mongodb + namespace: ticketflow + labels: + app: mongodb + app.kubernetes.io/component: database +spec: + selector: + app: mongodb + ports: + - port: 27017 + targetPort: 27017 + name: mongodb + type: ClusterIP diff --git a/ticketflow/k8s/infra/postgres.yaml b/ticketflow/k8s/infra/postgres.yaml new file mode 100644 index 0000000..f780585 --- /dev/null +++ b/ticketflow/k8s/infra/postgres.yaml @@ -0,0 +1,122 @@ +--- +# ConfigMap with database initialization SQL +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgres-init + namespace: ticketflow + labels: + app: postgres + app.kubernetes.io/component: database +data: + init.sql: | + -- Create databases for all services that use PostgreSQL + CREATE DATABASE ticketflow_users; + CREATE DATABASE ticketflow_bookings; + CREATE DATABASE ticketflow_inventory; + + -- Grant privileges + GRANT ALL PRIVILEGES ON DATABASE ticketflow_users TO postgres; + GRANT ALL PRIVILEGES ON DATABASE ticketflow_bookings TO postgres; + GRANT ALL PRIVILEGES ON DATABASE ticketflow_inventory TO postgres; + +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: ticketflow + labels: + app: postgres + app.kubernetes.io/component: database +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + app.kubernetes.io/component: database + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: ticketflow-config + key: DB_USERNAME + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: ticketflow-secrets + key: DB_PASSWORD + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + - name: init-scripts + mountPath: /docker-entrypoint-initdb.d/ + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + readinessProbe: + exec: + command: + - pg_isready + - -U + - postgres + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 5 + livenessProbe: + exec: + command: + - pg_isready + - -U + - postgres + initialDelaySeconds: 30 + periodSeconds: 30 + failureThreshold: 3 + volumes: + - name: init-scripts + configMap: + name: postgres-init + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 10Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: ticketflow + labels: + app: postgres + app.kubernetes.io/component: database +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 + name: postgres + type: ClusterIP diff --git a/ticketflow/k8s/infra/redis.yaml b/ticketflow/k8s/infra/redis.yaml new file mode 100644 index 0000000..2aa424b --- /dev/null +++ b/ticketflow/k8s/infra/redis.yaml @@ -0,0 +1,90 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: ticketflow + labels: + app: redis + app.kubernetes.io/component: cache +spec: + serviceName: redis + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + app.kubernetes.io/component: cache + spec: + containers: + - name: redis + image: redis:7-alpine + command: + - sh + - -c + - redis-server --requirepass $(REDIS_PASSWORD) --save 60 1 --loglevel warning + ports: + - containerPort: 6379 + name: redis + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: ticketflow-secrets + key: REDIS_PASSWORD + volumeMounts: + - name: redis-data + mountPath: /data + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "250m" + readinessProbe: + exec: + command: + - sh + - -c + - redis-cli -a "$REDIS_PASSWORD" ping + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 5 + livenessProbe: + exec: + command: + - sh + - -c + - redis-cli -a "$REDIS_PASSWORD" ping + initialDelaySeconds: 30 + periodSeconds: 30 + failureThreshold: 3 + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 2Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: ticketflow + labels: + app: redis + app.kubernetes.io/component: cache +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 + name: redis + type: ClusterIP diff --git a/ticketflow/k8s/ingress/nginx-ingress.yaml b/ticketflow/k8s/ingress/nginx-ingress.yaml new file mode 100644 index 0000000..dacf193 --- /dev/null +++ b/ticketflow/k8s/ingress/nginx-ingress.yaml @@ -0,0 +1,43 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ticketflow-ingress + namespace: ticketflow + labels: + app.kubernetes.io/name: ticketflow + app.kubernetes.io/component: ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" + # cert-manager will automatically provision a TLS certificate + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: + - api.ticketflow.example.com + - ticketflow.example.com + secretName: ticketflow-tls + rules: + - host: api.ticketflow.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: gateway + port: + number: 3000 + - host: ticketflow.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: frontend + port: + number: 80 diff --git a/ticketflow/k8s/keda/keda-install.yaml b/ticketflow/k8s/keda/keda-install.yaml new file mode 100644 index 0000000..8c30be0 --- /dev/null +++ b/ticketflow/k8s/keda/keda-install.yaml @@ -0,0 +1,25 @@ +# KEDA (Kubernetes Event-Driven Autoscaling) Installation +# +# KEDA is NOT installed via a manifest in this file — it is installed via Helm +# or the official KEDA operator. Run ONE of the following commands before +# applying the ScaledObjects in scaled-objects.yaml: +# +# Option 1 — Helm (recommended): +# helm repo add kedacore https://kedacore.github.io/charts +# helm repo update +# helm install keda kedacore/keda --namespace keda --create-namespace +# +# Option 2 — kubectl apply (latest release): +# kubectl apply --server-side -f https://github.com/kedacore/keda/releases/download/v2.15.1/keda-2.15.1.yaml +# +# Verify installation: +# kubectl get pods -n keda +# +# After KEDA is running, apply the ScaledObjects: +# kubectl apply -f k8s/keda/scaled-objects.yaml +# +# NOTE: When using KEDA ScaledObjects alongside HPAs that target the same +# Deployment, KEDA will take precedence for replica management. The HPA files +# for payment-service and notification-service use minReplicas: 1 to avoid +# conflicts. Alternatively, remove those HPA files entirely and let KEDA +# handle all scaling for those two services. diff --git a/ticketflow/k8s/keda/scaled-objects.yaml b/ticketflow/k8s/keda/scaled-objects.yaml new file mode 100644 index 0000000..32bf1fb --- /dev/null +++ b/ticketflow/k8s/keda/scaled-objects.yaml @@ -0,0 +1,45 @@ +--- +# ScaledObject for payment-service — scales based on Kafka consumer lag +# on the ticketflow.payment.requested topic +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: payment-service-scaler + namespace: ticketflow +spec: + scaleTargetRef: + name: payment-service + minReplicaCount: 1 + maxReplicaCount: 20 + cooldownPeriod: 30 + triggers: + - type: kafka + metadata: + bootstrapServers: kafka:9092 + consumerGroup: payment-service + topic: ticketflow.payment.requested + lagThreshold: "5" + activationLagThreshold: "1" + +--- +# ScaledObject for notification-service — scales based on Kafka consumer lag +# on the ticketflow.booking.confirmed topic +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: notification-service-scaler + namespace: ticketflow +spec: + scaleTargetRef: + name: notification-service + minReplicaCount: 1 + maxReplicaCount: 10 + cooldownPeriod: 60 + triggers: + - type: kafka + metadata: + bootstrapServers: kafka:9092 + consumerGroup: notification-service + topic: ticketflow.booking.confirmed + lagThreshold: "10" + activationLagThreshold: "2" diff --git a/ticketflow/k8s/monitoring/grafana/grafana.yaml b/ticketflow/k8s/monitoring/grafana/grafana.yaml new file mode 100644 index 0000000..1bdc45a --- /dev/null +++ b/ticketflow/k8s/monitoring/grafana/grafana.yaml @@ -0,0 +1,157 @@ +--- +# ConfigMap with Grafana INI configuration and datasource provisioning +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-config + namespace: ticketflow + labels: + app: grafana + app.kubernetes.io/component: monitoring +data: + grafana.ini: | + [server] + root_url = http://localhost:3000 + + [security] + admin_user = admin + # admin_password is set via GF_SECURITY_ADMIN_PASSWORD env var + + [auth.anonymous] + enabled = false + + [analytics] + reporting_enabled = false + check_for_updates = false + + datasources.yaml: | + apiVersion: 1 + datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + jsonData: + timeInterval: "15s" + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + jsonData: + maxLines: 1000 + + - name: Jaeger + type: jaeger + access: proxy + url: http://jaeger:16686 + +--- +# PersistentVolumeClaim for Grafana dashboards and plugins +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grafana-data + namespace: ticketflow + labels: + app: grafana + app.kubernetes.io/component: monitoring +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: ticketflow + labels: + app: grafana + app.kubernetes.io/component: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + app.kubernetes.io/component: monitoring + spec: + containers: + - name: grafana + image: grafana/grafana:11.2.0 + ports: + - containerPort: 3000 + name: http + env: + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: ticketflow-secrets + key: GRAFANA_ADMIN_PASSWORD + - name: GF_AUTH_ANONYMOUS_ENABLED + value: "false" + - name: GF_PATHS_PROVISIONING + value: /etc/grafana/provisioning + volumeMounts: + - name: grafana-data + mountPath: /var/lib/grafana + - name: grafana-config + mountPath: /etc/grafana/grafana.ini + subPath: grafana.ini + - name: grafana-config + mountPath: /etc/grafana/provisioning/datasources/datasources.yaml + subPath: datasources.yaml + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 30 + volumes: + - name: grafana-data + persistentVolumeClaim: + claimName: grafana-data + - name: grafana-config + configMap: + name: grafana-config + terminationGracePeriodSeconds: 30 + +--- +# LoadBalancer so Grafana UI is accessible externally +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: ticketflow + labels: + app: grafana + app.kubernetes.io/component: monitoring +spec: + selector: + app: grafana + ports: + - port: 3000 + targetPort: 3000 + name: http + type: LoadBalancer diff --git a/ticketflow/k8s/monitoring/jaeger/jaeger.yaml b/ticketflow/k8s/monitoring/jaeger/jaeger.yaml new file mode 100644 index 0000000..5b1cb65 --- /dev/null +++ b/ticketflow/k8s/monitoring/jaeger/jaeger.yaml @@ -0,0 +1,109 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jaeger + namespace: ticketflow + labels: + app: jaeger + app.kubernetes.io/component: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: jaeger + template: + metadata: + labels: + app: jaeger + app.kubernetes.io/component: monitoring + spec: + containers: + - name: jaeger + image: jaegertracing/all-in-one:1.60 + ports: + - containerPort: 16686 + name: ui + - containerPort: 14268 + name: http-collector + - containerPort: 6831 + protocol: UDP + name: agent-udp + - containerPort: 4317 + name: otlp-grpc + - containerPort: 4318 + name: otlp-http + env: + - name: COLLECTOR_ZIPKIN_HOST_PORT + value: ":9411" + - name: MEMORY_MAX_TRACES + value: "50000" + - name: SPAN_STORAGE_TYPE + value: memory + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: / + port: 16686 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 16686 + initialDelaySeconds: 20 + periodSeconds: 30 + terminationGracePeriodSeconds: 30 + +--- +# ClusterIP service for the HTTP collector endpoint (used by services to ship traces) +apiVersion: v1 +kind: Service +metadata: + name: jaeger-collector + namespace: ticketflow + labels: + app: jaeger + app.kubernetes.io/component: monitoring +spec: + selector: + app: jaeger + ports: + - port: 14268 + targetPort: 14268 + name: http-collector + - port: 6831 + targetPort: 6831 + protocol: UDP + name: agent-udp + - port: 4317 + targetPort: 4317 + name: otlp-grpc + - port: 4318 + targetPort: 4318 + name: otlp-http + type: ClusterIP + +--- +# LoadBalancer service for the Jaeger UI — accessible externally +apiVersion: v1 +kind: Service +metadata: + name: jaeger + namespace: ticketflow + labels: + app: jaeger + app.kubernetes.io/component: monitoring +spec: + selector: + app: jaeger + ports: + - port: 16686 + targetPort: 16686 + name: ui + type: LoadBalancer diff --git a/ticketflow/k8s/monitoring/loki/loki.yaml b/ticketflow/k8s/monitoring/loki/loki.yaml new file mode 100644 index 0000000..2724f34 --- /dev/null +++ b/ticketflow/k8s/monitoring/loki/loki.yaml @@ -0,0 +1,177 @@ +--- +# ConfigMap with Loki local configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: loki-config + namespace: ticketflow + labels: + app: loki + app.kubernetes.io/component: monitoring +data: + local-config.yaml: | + auth_enabled: false + + server: + http_listen_port: 3100 + grpc_listen_port: 9096 + + ingester: + wal: + enabled: true + dir: /tmp/wal + lifecycler: + address: 127.0.0.1 + ring: + kvstore: + store: inmemory + replication_factor: 1 + final_sleep: 0s + chunk_idle_period: 1h + max_chunk_age: 1h + chunk_target_size: 1048576 + chunk_retain_period: 30s + + schema_config: + configs: + - from: 2024-01-01 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + + storage_config: + boltdb_shipper: + active_index_directory: /loki/boltdb-shipper-active + cache_location: /loki/boltdb-shipper-cache + cache_ttl: 24h + shared_store: filesystem + filesystem: + directory: /loki/chunks + + compactor: + working_directory: /loki/boltdb-shipper-compactor + shared_store: filesystem + + limits_config: + retention_period: 168h # 7 days + enforce_metric_name: false + reject_old_samples: true + reject_old_samples_max_age: 168h + + chunk_store_config: + max_look_back_period: 0s + + table_manager: + retention_deletes_enabled: true + retention_period: 168h + + ruler: + storage: + type: local + local: + directory: /loki/rules + rule_path: /loki/rules-temp + alertmanager_url: http://localhost:9093 + ring: + kvstore: + store: inmemory + enable_api: true + +--- +# PersistentVolumeClaim for Loki log storage +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: loki-data + namespace: ticketflow + labels: + app: loki + app.kubernetes.io/component: monitoring +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loki + namespace: ticketflow + labels: + app: loki + app.kubernetes.io/component: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: loki + template: + metadata: + labels: + app: loki + app.kubernetes.io/component: monitoring + spec: + containers: + - name: loki + image: grafana/loki:3.1.0 + args: + - -config.file=/etc/loki/local-config.yaml + ports: + - containerPort: 3100 + name: http + volumeMounts: + - name: loki-config + mountPath: /etc/loki + - name: loki-data + mountPath: /loki + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "250m" + readinessProbe: + httpGet: + path: /ready + port: 3100 + initialDelaySeconds: 15 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /ready + port: 3100 + initialDelaySeconds: 30 + periodSeconds: 30 + volumes: + - name: loki-config + configMap: + name: loki-config + - name: loki-data + persistentVolumeClaim: + claimName: loki-data + terminationGracePeriodSeconds: 30 + +--- +apiVersion: v1 +kind: Service +metadata: + name: loki + namespace: ticketflow + labels: + app: loki + app.kubernetes.io/component: monitoring +spec: + selector: + app: loki + ports: + - port: 3100 + targetPort: 3100 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/monitoring/prometheus/prometheus.yaml b/ticketflow/k8s/monitoring/prometheus/prometheus.yaml new file mode 100644 index 0000000..344d924 --- /dev/null +++ b/ticketflow/k8s/monitoring/prometheus/prometheus.yaml @@ -0,0 +1,223 @@ +--- +# ConfigMap containing the Prometheus scrape configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: ticketflow + labels: + app: prometheus + app.kubernetes.io/component: monitoring +data: + prometheus.yml: | + global: + scrape_interval: 15s + evaluation_interval: 15s + + scrape_configs: + # Scrape Prometheus itself + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # Scrape all pods that have the annotation prometheus.io/scrape: "true" + - job_name: 'kubernetes-pods' + kubernetes_sd_configs: + - role: pod + namespaces: + names: + - ticketflow + relabel_configs: + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] + action: keep + regex: "true" + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] + action: replace + target_label: __metrics_path__ + regex: (.+) + - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port] + action: replace + regex: ([^:]+)(?::\d+)?;(\d+) + replacement: $1:$2 + target_label: __address__ + - action: labelmap + regex: __meta_kubernetes_pod_label_(.+) + - source_labels: [__meta_kubernetes_namespace] + action: replace + target_label: kubernetes_namespace + - source_labels: [__meta_kubernetes_pod_name] + action: replace + target_label: kubernetes_pod_name + + # Scrape Kubernetes service endpoints + - job_name: 'kubernetes-service-endpoints' + kubernetes_sd_configs: + - role: endpoints + namespaces: + names: + - ticketflow + relabel_configs: + - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scrape] + action: keep + regex: "true" + - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_path] + action: replace + target_label: __metrics_path__ + regex: (.+) + - source_labels: [__address__, __meta_kubernetes_service_annotation_prometheus_io_port] + action: replace + regex: ([^:]+)(?::\d+)?;(\d+) + replacement: $1:$2 + target_label: __address__ + - action: labelmap + regex: __meta_kubernetes_service_label_(.+) + - source_labels: [__meta_kubernetes_namespace] + action: replace + target_label: kubernetes_namespace + - source_labels: [__meta_kubernetes_service_name] + action: replace + target_label: kubernetes_name + +--- +# PersistentVolumeClaim for Prometheus TSDB data +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: prometheus-data + namespace: ticketflow + labels: + app: prometheus + app.kubernetes.io/component: monitoring +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: ticketflow + labels: + app: prometheus + app.kubernetes.io/component: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + app.kubernetes.io/component: monitoring + spec: + serviceAccountName: prometheus + containers: + - name: prometheus + image: prom/prometheus:v2.54.0 + args: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--storage.tsdb.retention.time=15d" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - containerPort: 9090 + name: http + volumeMounts: + - name: config + mountPath: /etc/prometheus + - name: data + mountPath: /prometheus + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + readinessProbe: + httpGet: + path: /-/ready + port: 9090 + initialDelaySeconds: 15 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /-/healthy + port: 9090 + initialDelaySeconds: 30 + periodSeconds: 30 + volumes: + - name: config + configMap: + name: prometheus-config + - name: data + persistentVolumeClaim: + claimName: prometheus-data + terminationGracePeriodSeconds: 30 + +--- +# ServiceAccount + ClusterRole for Prometheus pod/endpoint discovery +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prometheus + namespace: ticketflow + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: prometheus +rules: + - apiGroups: [""] + resources: + - nodes + - nodes/proxy + - services + - endpoints + - pods + verbs: ["get", "list", "watch"] + - apiGroups: ["extensions", "networking.k8s.io"] + resources: + - ingresses + verbs: ["get", "list", "watch"] + - nonResourceURLs: ["/metrics"] + verbs: ["get"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: prometheus +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: prometheus +subjects: + - kind: ServiceAccount + name: prometheus + namespace: ticketflow + +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: ticketflow + labels: + app: prometheus + app.kubernetes.io/component: monitoring +spec: + selector: + app: prometheus + ports: + - port: 9090 + targetPort: 9090 + name: http + type: ClusterIP diff --git a/ticketflow/k8s/monitoring/promtail/promtail.yaml b/ticketflow/k8s/monitoring/promtail/promtail.yaml new file mode 100644 index 0000000..0e07901 --- /dev/null +++ b/ticketflow/k8s/monitoring/promtail/promtail.yaml @@ -0,0 +1,209 @@ +--- +# ServiceAccount for Promtail to read pod metadata from the Kubernetes API +apiVersion: v1 +kind: ServiceAccount +metadata: + name: promtail + namespace: ticketflow + labels: + app: promtail + app.kubernetes.io/component: monitoring + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: promtail + labels: + app: promtail +rules: + - apiGroups: [""] + resources: + - nodes + - services + - pods + - namespaces + verbs: ["get", "watch", "list"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: promtail + labels: + app: promtail +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: promtail +subjects: + - kind: ServiceAccount + name: promtail + namespace: ticketflow + +--- +# ConfigMap with Promtail configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: promtail-config + namespace: ticketflow + labels: + app: promtail + app.kubernetes.io/component: monitoring +data: + promtail.yaml: | + server: + http_listen_port: 9080 + grpc_listen_port: 0 + + positions: + filename: /run/promtail/positions.yaml + + clients: + - url: http://loki:3100/loki/api/v1/push + + scrape_configs: + # Scrape container logs from all pods + - job_name: kubernetes-pods + pipeline_stages: + - cri: {} + kubernetes_sd_configs: + - role: pod + relabel_configs: + - source_labels: + - __meta_kubernetes_pod_controller_name + regex: ([0-9a-z-.]+?)(-[0-9a-f]{8,10})? + action: replace + target_label: __tmp_controller_name + - source_labels: + - __meta_kubernetes_pod_label_app_kubernetes_io_name + - __meta_kubernetes_pod_label_app + - __tmp_controller_name + - __meta_kubernetes_pod_name + regex: ^;*([^;]+)(;.*)?$ + action: replace + target_label: app + - source_labels: + - __meta_kubernetes_pod_label_app_kubernetes_io_component + - __meta_kubernetes_pod_label_component + regex: ^;*([^;]+)(;.*)?$ + action: replace + target_label: component + - action: replace + source_labels: + - __meta_kubernetes_pod_node_name + target_label: node_name + - action: replace + source_labels: + - __meta_kubernetes_namespace + target_label: namespace + - action: replace + replacement: $1 + separator: / + source_labels: + - namespace + - app + target_label: job + - action: replace + source_labels: + - __meta_kubernetes_pod_name + target_label: pod + - action: replace + source_labels: + - __meta_kubernetes_pod_container_name + target_label: container + - action: replace + replacement: /var/log/pods/*$1/*.log + separator: / + source_labels: + - __meta_kubernetes_pod_uid + - __meta_kubernetes_pod_container_name + target_label: __path__ + - action: replace + regex: true/(.*) + replacement: /var/log/pods/*$1/*.log + separator: / + source_labels: + - __meta_kubernetes_pod_annotationpresent_kubernetes_io_config_hash + - __meta_kubernetes_pod_annotation_kubernetes_io_config_hash + - __meta_kubernetes_pod_container_name + target_label: __path__ + +--- +# DaemonSet — runs one Promtail pod on every node to ship logs to Loki +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: promtail + namespace: ticketflow + labels: + app: promtail + app.kubernetes.io/component: monitoring +spec: + selector: + matchLabels: + app: promtail + template: + metadata: + labels: + app: promtail + app.kubernetes.io/component: monitoring + spec: + serviceAccountName: promtail + containers: + - name: promtail + image: grafana/promtail:3.1.0 + args: + - -config.file=/etc/promtail/promtail.yaml + ports: + - containerPort: 9080 + name: http + env: + - name: HOSTNAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + volumeMounts: + - name: config + mountPath: /etc/promtail + - name: run + mountPath: /run/promtail + - name: varlog + mountPath: /var/log + readOnly: true + - name: varlibdockercontainers + mountPath: /var/lib/docker/containers + readOnly: true + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" + readinessProbe: + httpGet: + path: /ready + port: 9080 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: promtail-config + - name: run + hostPath: + path: /run/promtail + - name: varlog + hostPath: + path: /var/log + - name: varlibdockercontainers + hostPath: + path: /var/lib/docker/containers + tolerations: + # Run on all nodes including control-plane + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule + terminationGracePeriodSeconds: 30 diff --git a/ticketflow/k8s/namespace.yaml b/ticketflow/k8s/namespace.yaml new file mode 100644 index 0000000..f601f6b --- /dev/null +++ b/ticketflow/k8s/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ticketflow + labels: + app.kubernetes.io/name: ticketflow + app.kubernetes.io/managed-by: kubectl diff --git a/ticketflow/package.json b/ticketflow/package.json deleted file mode 100644 index 403992b..0000000 --- a/ticketflow/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "ticketflow", - "version": "1.0.0", - "private": true, - "scripts": { - "dev:backend": "set -a; . ./.env; set +a; concurrently \"pnpm --filter gateway run dev\" \"DATABASE_URL=$USER_DB_URL pnpm --filter user-service run dev\" \"DATABASE_URL=$EVENT_DB_URL pnpm --filter event-service run dev\" \"DATABASE_URL=$BOOKING_DB_URL pnpm --filter booking-service run dev\" \"DATABASE_URL=$INVENTORY_DB_URL pnpm --filter inventory-service run dev\" \"DATABASE_URL=$PAYMENT_DB_URL pnpm --filter payment-service run dev\" \"pnpm --filter notification-service run dev\"", - "dev:all": "concurrently \"pnpm run dev:backend\" \"pnpm --filter ticketflow-frontend run dev\"", - "build": "pnpm -r run build", - "test": "pnpm -r run test", - "test:integration": "pnpm -r run test:integration" - }, - "devDependencies": { - "concurrently": "^8.2.2" - } -} diff --git a/ticketflow/pnpm-lock.yaml b/ticketflow/pnpm-lock.yaml deleted file mode 100644 index c4cd5c8..0000000 --- a/ticketflow/pnpm-lock.yaml +++ /dev/null @@ -1,7271 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - concurrently: - specifier: ^8.2.2 - version: 8.2.2 - - frontend: - dependencies: - '@radix-ui/react-dialog': - specifier: ^1.1.4 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-label': - specifier: ^2.1.1 - version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-navigation-menu': - specifier: ^1.2.3 - version: 1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-separator': - specifier: ^1.1.1 - version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': - specifier: ^1.1.1 - version: 1.2.4(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-tabs': - specifier: ^1.1.2 - version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toast': - specifier: ^1.2.4 - version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tanstack/react-query': - specifier: ^5.62.9 - version: 5.95.2(react@18.3.1) - axios: - specifier: ^1.7.9 - version: 1.13.6 - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - gsap: - specifier: ^3.12.5 - version: 3.14.2 - lucide-react: - specifier: ^0.468.0 - version: 0.468.0(react@18.3.1) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - react-router-dom: - specifier: ^6.28.1 - version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - tailwind-merge: - specifier: ^2.5.5 - version: 2.6.1 - devDependencies: - '@types/node': - specifier: ^22.10.2 - version: 22.19.15 - '@types/react': - specifier: ^18.3.17 - version: 18.3.28 - '@types/react-dom': - specifier: ^18.3.5 - version: 18.3.7(@types/react@18.3.28) - '@typescript-eslint/eslint-plugin': - specifier: ^8.57.2 - version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^8.57.2 - version: 8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@1.21.7)) - autoprefixer: - specifier: ^10.4.20 - version: 10.4.27(postcss@8.5.8) - eslint: - specifier: ^10.1.0 - version: 10.1.0(jiti@1.21.7) - eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@10.1.0(jiti@1.21.7)) - eslint-plugin-react-refresh: - specifier: ^0.5.2 - version: 0.5.2(eslint@10.1.0(jiti@1.21.7)) - postcss: - specifier: ^8.4.49 - version: 8.5.8 - tailwindcss: - specifier: ^3.4.17 - version: 3.4.19 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - vite: - specifier: ^6.0.5 - version: 6.4.1(@types/node@22.19.15)(jiti@1.21.7) - - gateway: - dependencies: - '@ticketflow/shared': - specifier: workspace:* - version: link:../shared - dotenv: - specifier: ^16.3.1 - version: 16.6.1 - express: - specifier: ^4.18.2 - version: 4.22.1 - express-rate-limit: - specifier: ^7.1.5 - version: 7.5.1(express@4.22.1) - http-proxy-middleware: - specifier: ^2.0.6 - version: 2.0.9(@types/express@4.17.25) - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.3 - devDependencies: - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/jsonwebtoken': - specifier: ^9.0.5 - version: 9.0.10 - '@types/node': - specifier: ^20.10.6 - version: 20.19.37 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) - ts-node-dev: - specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.37)(typescript@5.9.3) - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - services/booking-service: - dependencies: - '@ticketflow/shared': - specifier: workspace:* - version: link:../../shared - amqplib: - specifier: ^0.10.3 - version: 0.10.9 - axios: - specifier: ^1.6.2 - version: 1.13.6 - dotenv: - specifier: ^16.3.1 - version: 16.6.1 - drizzle-orm: - specifier: ^0.36.4 - version: 0.36.4(@prisma/client@5.22.0(prisma@5.22.0))(@types/pg@8.20.0)(@types/react@18.3.28)(pg@8.20.0)(prisma@5.22.0)(react@18.3.1) - express: - specifier: ^4.18.2 - version: 4.22.1 - pg: - specifier: ^8.13.1 - version: 8.20.0 - zod: - specifier: ^3.22.4 - version: 3.25.76 - devDependencies: - '@types/amqplib': - specifier: ^0.10.4 - version: 0.10.8 - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/node': - specifier: ^20.10.6 - version: 20.19.37 - '@types/pg': - specifier: ^8.11.10 - version: 8.20.0 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - supertest: - specifier: ^6.3.4 - version: 6.3.4 - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) - ts-node-dev: - specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.37)(typescript@5.9.3) - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - services/event-service: - dependencies: - '@ticketflow/shared': - specifier: workspace:* - version: link:../../shared - dotenv: - specifier: ^16.3.1 - version: 16.6.1 - drizzle-orm: - specifier: ^0.36.4 - version: 0.36.4(@prisma/client@5.22.0(prisma@5.22.0))(@types/pg@8.20.0)(@types/react@18.3.28)(pg@8.20.0)(prisma@5.22.0)(react@18.3.1) - express: - specifier: ^4.18.2 - version: 4.22.1 - express-rate-limit: - specifier: ^7.4.1 - version: 7.5.1(express@4.22.1) - pg: - specifier: ^8.13.1 - version: 8.20.0 - zod: - specifier: ^3.22.4 - version: 3.25.76 - devDependencies: - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/node': - specifier: ^20.10.6 - version: 20.19.37 - '@types/pg': - specifier: ^8.11.10 - version: 8.20.0 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - supertest: - specifier: ^6.3.4 - version: 6.3.4 - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@20.19.37)(typescript@5.9.3) - ts-node-dev: - specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.37)(typescript@5.9.3) - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - services/inventory-service: - dependencies: - '@ticketflow/shared': - specifier: workspace:* - version: link:../../shared - dotenv: - specifier: ^16.3.1 - version: 16.6.1 - drizzle-orm: - specifier: ^0.36.4 - version: 0.36.4(@prisma/client@5.22.0(prisma@5.22.0))(@types/pg@8.20.0)(@types/react@18.3.28)(pg@8.20.0)(prisma@5.22.0)(react@18.3.1) - express: - specifier: ^4.18.2 - version: 4.22.1 - ioredis: - specifier: ^5.3.2 - version: 5.10.1 - pg: - specifier: ^8.13.1 - version: 8.20.0 - zod: - specifier: ^3.22.4 - version: 3.25.76 - devDependencies: - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/node': - specifier: ^20.10.6 - version: 20.19.37 - '@types/pg': - specifier: ^8.11.10 - version: 8.20.0 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - supertest: - specifier: ^6.3.4 - version: 6.3.4 - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) - ts-node-dev: - specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.37)(typescript@5.9.3) - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - services/notification-service: - dependencies: - '@ticketflow/shared': - specifier: workspace:* - version: link:../../shared - amqplib: - specifier: ^0.10.3 - version: 0.10.9 - dotenv: - specifier: ^16.3.1 - version: 16.6.1 - nodemailer: - specifier: ^7.0.11 - version: 7.0.13 - devDependencies: - '@types/amqplib': - specifier: ^0.10.4 - version: 0.10.8 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/node': - specifier: ^20.10.6 - version: 20.19.37 - '@types/nodemailer': - specifier: ^6.4.14 - version: 6.4.23 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) - ts-node-dev: - specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.37)(typescript@5.9.3) - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - services/payment-service: - dependencies: - '@ticketflow/shared': - specifier: workspace:* - version: link:../../shared - dotenv: - specifier: ^16.3.1 - version: 16.6.1 - drizzle-orm: - specifier: ^0.36.4 - version: 0.36.4(@prisma/client@5.22.0(prisma@5.22.0))(@types/pg@8.20.0)(@types/react@18.3.28)(pg@8.20.0)(prisma@5.22.0)(react@18.3.1) - express: - specifier: ^4.18.2 - version: 4.22.1 - pg: - specifier: ^8.13.1 - version: 8.20.0 - zod: - specifier: ^3.22.4 - version: 3.25.76 - devDependencies: - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/node': - specifier: ^20.10.6 - version: 20.19.37 - '@types/pg': - specifier: ^8.11.10 - version: 8.20.0 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - supertest: - specifier: ^6.3.4 - version: 6.3.4 - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) - ts-node-dev: - specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.37)(typescript@5.9.3) - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - services/user-service: - dependencies: - '@ticketflow/shared': - specifier: workspace:* - version: link:../../shared - bcryptjs: - specifier: ^2.4.3 - version: 2.4.3 - dotenv: - specifier: ^16.3.1 - version: 16.6.1 - drizzle-orm: - specifier: ^0.36.4 - version: 0.36.4(@prisma/client@5.22.0(prisma@5.22.0))(@types/pg@8.20.0)(@types/react@18.3.28)(pg@8.20.0)(prisma@5.22.0)(react@18.3.1) - express: - specifier: ^4.18.2 - version: 4.22.1 - express-rate-limit: - specifier: ^7.4.1 - version: 7.5.1(express@4.22.1) - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.3 - pg: - specifier: ^8.13.1 - version: 8.20.0 - zod: - specifier: ^3.22.4 - version: 3.25.76 - devDependencies: - '@types/bcryptjs': - specifier: ^2.4.6 - version: 2.4.6 - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 - '@types/jsonwebtoken': - specifier: ^9.0.5 - version: 9.0.10 - '@types/node': - specifier: ^20.10.6 - version: 20.19.37 - '@types/pg': - specifier: ^8.11.10 - version: 8.20.0 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - supertest: - specifier: ^6.3.4 - version: 6.3.4 - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) - ts-node-dev: - specifier: ^2.0.0 - version: 2.0.0(@types/node@20.19.37)(typescript@5.9.3) - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - shared: - dependencies: - express: - specifier: ^4.18.2 - version: 4.22.1 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.3 - zod: - specifier: ^3.22.4 - version: 3.25.76 - devDependencies: - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/jsonwebtoken': - specifier: ^9.0.5 - version: 9.0.10 - typescript: - specifier: ^5.3.3 - version: 5.9.3 - -packages: - - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.3': - resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.5.3': - resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.1.1': - resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/object-schema@3.0.3': - resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.6.1': - resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@ioredis/commands@1.5.1': - resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} - - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - - '@prisma/client@5.22.0': - resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} - engines: {node: '>=16.13'} - peerDependencies: - prisma: '*' - peerDependenciesMeta: - prisma: - optional: true - - '@prisma/debug@5.22.0': - resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} - - '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': - resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} - - '@prisma/engines@5.22.0': - resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} - - '@prisma/fetch-engine@5.22.0': - resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} - - '@prisma/get-platform@5.22.0': - resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-label@2.1.8': - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-navigation-menu@1.2.14': - resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-separator@1.1.8': - resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toast@1.2.15': - resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.60.0': - resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.0': - resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.0': - resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.0': - resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.0': - resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.0': - resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.0': - resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.60.0': - resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.60.0': - resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.60.0': - resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.60.0': - resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.60.0': - resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.60.0': - resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.60.0': - resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.60.0': - resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.60.0': - resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.60.0': - resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.60.0': - resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.60.0': - resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.60.0': - resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.0': - resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.0': - resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.0': - resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.0': - resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.0': - resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} - cpu: [x64] - os: [win32] - - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} - - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - - '@tanstack/query-core@5.95.2': - resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} - - '@tanstack/react-query@5.95.2': - resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==} - peerDependencies: - react: ^18 || ^19 - - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - - '@types/amqplib@0.10.8': - resolution: {integrity: sha512-vtDp8Pk1wsE/AuQ8/Rgtm6KUZYqcnTgNvEHwzCkX8rL7AGsC6zqAfKAAJhUZXFhM/Pp++tbnUHiam/8vVpPztA==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/bcryptjs@2.4.6': - resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} - - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} - - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} - - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - - '@types/http-proxy@1.17.17': - resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} - - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@20.19.37': - resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} - - '@types/node@22.19.15': - resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} - - '@types/nodemailer@6.4.23': - resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==} - - '@types/pg@8.20.0': - resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - - '@types/qs@6.15.0': - resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 - - '@types/react@18.3.28': - resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} - - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - - '@types/strip-bom@3.0.0': - resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} - - '@types/strip-json-comments@0.0.30': - resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} - - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - - '@typescript-eslint/eslint-plugin@8.57.2': - resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.57.2 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.57.2': - resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.57.2': - resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.57.2': - resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.57.2': - resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.57.2': - resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.57.2': - resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.57.2': - resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.57.2': - resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.57.2': - resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - - amqplib@0.10.9: - resolution: {integrity: sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==} - engines: {node: '>=10'} - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} - - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - autoprefixer@10.4.27: - resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} - - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.11: - resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} - engines: {node: '>=6.0.0'} - hasBin: true - - bcryptjs@2.4.3: - resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - buffer-more-ints@1.0.0: - resolution: {integrity: sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001781: - resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} - engines: {node: '>=0.10.0'} - - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - - collect-v8-coverage@1.0.3: - resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - concurrently@8.2.2: - resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} - engines: {node: ^14.13.0 || >=16.0.0} - hasBin: true - - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - date-fns@2.30.0: - resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} - engines: {node: '>=0.11'} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dedent@1.7.2: - resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - diff@4.0.4: - resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} - engines: {node: '>=0.3.1'} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - - drizzle-orm@0.36.4: - resolution: {integrity: sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA==} - peerDependencies: - '@aws-sdk/client-rds-data': '>=3' - '@cloudflare/workers-types': '>=3' - '@electric-sql/pglite': '>=0.2.0' - '@libsql/client': '>=0.10.0' - '@libsql/client-wasm': '>=0.10.0' - '@neondatabase/serverless': '>=0.10.0' - '@op-engineering/op-sqlite': '>=2' - '@opentelemetry/api': ^1.4.1 - '@planetscale/database': '>=1' - '@prisma/client': '*' - '@tidbcloud/serverless': '*' - '@types/better-sqlite3': '*' - '@types/pg': '*' - '@types/react': '>=18' - '@types/sql.js': '*' - '@vercel/postgres': '>=0.8.0' - '@xata.io/client': '*' - better-sqlite3: '>=7' - bun-types: '*' - expo-sqlite: '>=14.0.0' - knex: '*' - kysely: '*' - mysql2: '>=2' - pg: '>=8' - postgres: '>=3' - prisma: '*' - react: '>=18' - sql.js: '>=1' - sqlite3: '>=5' - peerDependenciesMeta: - '@aws-sdk/client-rds-data': - optional: true - '@cloudflare/workers-types': - optional: true - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - '@libsql/client-wasm': - optional: true - '@neondatabase/serverless': - optional: true - '@op-engineering/op-sqlite': - optional: true - '@opentelemetry/api': - optional: true - '@planetscale/database': - optional: true - '@prisma/client': - optional: true - '@tidbcloud/serverless': - optional: true - '@types/better-sqlite3': - optional: true - '@types/pg': - optional: true - '@types/react': - optional: true - '@types/sql.js': - optional: true - '@vercel/postgres': - optional: true - '@xata.io/client': - optional: true - better-sqlite3: - optional: true - bun-types: - optional: true - expo-sqlite: - optional: true - knex: - optional: true - kysely: - optional: true - mysql2: - optional: true - pg: - optional: true - postgres: - optional: true - prisma: - optional: true - react: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - dynamic-dedupe@0.3.0: - resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - electron-to-chromium@1.5.328: - resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} - - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - 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 - - eslint-plugin-react-refresh@0.5.2: - resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} - peerDependencies: - eslint: ^9 || ^10 - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.1.0: - resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} - - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - express-rate-limit@7.5.1: - resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - gsap@3.14.2: - resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==} - - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - http-proxy-middleware@2.0.9: - resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} - engines: {node: '>=12.0.0'} - peerDependencies: - '@types/express': ^4.17.13 - peerDependenciesMeta: - '@types/express': - optional: true - - http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ioredis@5.10.1: - resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} - engines: {node: '>=12.22.0'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-plain-obj@3.0.0: - resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} - engines: {node: '>=10'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isarguments@3.1.0: - resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.468.0: - resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} - - nodemailer@7.0.13: - resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==} - engines: {node: '>=6.0.0'} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-to-regexp@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - - pg-cloudflare@1.3.0: - resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - - pg-connection-string@2.12.0: - resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} - - pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - - pg-pool@3.13.0: - resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} - peerDependencies: - pg: '>=8.0' - - pg-protocol@1.13.0: - resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} - - pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - - pg@8.20.0: - resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} - engines: {node: '>= 16.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - - pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} - engines: {node: ^10 || ^12 || >=14} - - postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - - postgres-bytea@1.0.1: - resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} - engines: {node: '>=0.10.0'} - - postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - - postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - prisma@5.22.0: - resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} - engines: {node: '>=16.13'} - hasBin: true - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} - engines: {node: '>=0.6'} - - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} - engines: {node: '>=0.6'} - - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.8: - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.7.2: - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - - react-style-singleton@2.2.3: - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} - - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rimraf@2.7.1: - resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - rollup@4.60.0: - resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - spawn-command@0.0.2: - resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net - - supertest@6.3.4: - resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} - engines: {node: '>=6.4.0'} - deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tailwind-merge@2.6.1: - resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} - - tailwindcss@3.4.19: - resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} - engines: {node: '>=14.0.0'} - hasBin: true - - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - ts-jest@29.4.6: - resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - - ts-node-dev@2.0.0: - resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} - engines: {node: '>=0.8.0'} - hasBin: true - peerDependencies: - node-notifier: '*' - typescript: '*' - peerDependenciesMeta: - node-notifier: - optional: true - - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - - tsconfig@7.0.0: - resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - - use-callback-ref@1.3.3: - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.3: - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@alloc/quick-lru@5.2.0': {} - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@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.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/runtime@7.29.2': {} - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@0.2.3': {} - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@1.21.7))': - dependencies: - eslint: 10.1.0(jiti@1.21.7) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.3': - dependencies: - '@eslint/object-schema': 3.0.3 - debug: 4.4.3 - minimatch: 10.2.4 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.5.3': - dependencies: - '@eslint/core': 1.1.1 - - '@eslint/core@1.1.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/object-schema@3.0.3': {} - - '@eslint/plugin-kit@0.6.1': - dependencies: - '@eslint/core': 1.1.1 - levn: 0.4.1 - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@ioredis/commands@1.5.1': {} - - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} - - '@jest/console@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - jest-mock: 29.7.0 - - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - - '@jest/expect@29.7.0': - dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.37 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - '@jest/globals@29.7.0': - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/reporters@29.7.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 20.19.37 - chalk: 4.1.2 - collect-v8-coverage: 1.0.3 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.2.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.10 - - '@jest/source-map@29.6.3': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.3 - - '@jest/test-sequencer@29.7.0': - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.29.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.37 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@noble/hashes@1.8.0': {} - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@prisma/client@5.22.0(prisma@5.22.0)': - optionalDependencies: - prisma: 5.22.0 - optional: true - - '@prisma/debug@5.22.0': - optional: true - - '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': - optional: true - - '@prisma/engines@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 - '@prisma/fetch-engine': 5.22.0 - '@prisma/get-platform': 5.22.0 - optional: true - - '@prisma/fetch-engine@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 - '@prisma/get-platform': 5.22.0 - optional: true - - '@prisma/get-platform@5.22.0': - dependencies: - '@prisma/debug': 5.22.0 - optional: true - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@remix-run/router@1.23.2': {} - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.60.0': - optional: true - - '@rollup/rollup-android-arm64@4.60.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.0': - optional: true - - '@rollup/rollup-darwin-x64@4.60.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.0': - optional: true - - '@sinclair/typebox@0.27.10': {} - - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 - - '@tanstack/query-core@5.95.2': {} - - '@tanstack/react-query@5.95.2(react@18.3.1)': - dependencies: - '@tanstack/query-core': 5.95.2 - react: 18.3.1 - - '@tsconfig/node10@1.0.12': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - - '@types/amqplib@0.10.8': - dependencies: - '@types/node': 20.19.37 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/bcryptjs@2.4.6': {} - - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 20.19.37 - - '@types/connect@3.4.38': - dependencies: - '@types/node': 20.19.37 - - '@types/cookiejar@2.1.5': {} - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.8': {} - - '@types/express-serve-static-core@4.19.8': - dependencies: - '@types/node': 20.19.37 - '@types/qs': 6.15.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@4.17.25': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.15.0 - '@types/serve-static': 1.15.10 - - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 20.19.37 - - '@types/http-errors@2.0.5': {} - - '@types/http-proxy@1.17.17': - dependencies: - '@types/node': 20.19.37 - - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - - '@types/json-schema@7.0.15': {} - - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 20.19.37 - - '@types/methods@1.1.4': {} - - '@types/mime@1.3.5': {} - - '@types/ms@2.1.0': {} - - '@types/node@20.19.37': - dependencies: - undici-types: 6.21.0 - - '@types/node@22.19.15': - dependencies: - undici-types: 6.21.0 - - '@types/nodemailer@6.4.23': - dependencies: - '@types/node': 20.19.37 - - '@types/pg@8.20.0': - dependencies: - '@types/node': 22.19.15 - pg-protocol: 1.13.0 - pg-types: 2.2.0 - - '@types/prop-types@15.7.15': {} - - '@types/qs@6.15.0': {} - - '@types/range-parser@1.2.7': {} - - '@types/react-dom@18.3.7(@types/react@18.3.28)': - dependencies: - '@types/react': 18.3.28 - - '@types/react@18.3.28': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 20.19.37 - - '@types/send@1.2.1': - dependencies: - '@types/node': 20.19.37 - - '@types/serve-static@1.15.10': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 20.19.37 - '@types/send': 0.17.6 - - '@types/stack-utils@2.0.3': {} - - '@types/strip-bom@3.0.0': {} - - '@types/strip-json-comments@0.0.30': {} - - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 20.19.37 - form-data: 4.0.5 - - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 - - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 - eslint: 10.1.0(jiti@1.21.7) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 - eslint: 10.1.0(jiti@1.21.7) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.57.2': - dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 - - '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.1.0(jiti@1.21.7) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.57.2': {} - - '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 - minimatch: 10.2.4 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - eslint: 10.1.0(jiti@1.21.7) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.57.2': - dependencies: - '@typescript-eslint/types': 8.57.2 - eslint-visitor-keys: 5.0.1 - - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@1.21.7))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.1(@types/node@22.19.15)(jiti@1.21.7) - transitivePeerDependencies: - - supports-color - - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn-walk@8.3.5: - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - amqplib@0.10.9: - dependencies: - buffer-more-ints: 1.0.0 - url-parse: 1.5.10 - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - arg@4.1.3: {} - - arg@5.0.2: {} - - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - - aria-hidden@1.2.6: - dependencies: - tslib: 2.8.1 - - array-flatten@1.1.1: {} - - asap@2.0.6: {} - - asynckit@0.4.0: {} - - autoprefixer@10.4.27(postcss@8.5.8): - dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001781 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - - axios@1.13.6: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - babel-jest@29.7.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.0) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-istanbul@6.1.1: - dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-jest-hoist@29.6.3: - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 - - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) - - babel-preset-jest@29.6.3(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.11: {} - - bcryptjs@2.4.3: {} - - binary-extensions@2.3.0: {} - - body-parser@1.20.4: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.10.11 - caniuse-lite: 1.0.30001781 - electron-to-chromium: 1.5.328 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - - bs-logger@0.2.6: - dependencies: - fast-json-stable-stringify: 2.1.0 - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 - - buffer-equal-constant-time@1.0.1: {} - - buffer-from@1.1.2: {} - - buffer-more-ints@1.0.0: {} - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - camelcase-css@2.0.1: {} - - camelcase@5.3.1: {} - - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001781: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - char-regex@1.0.2: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - ci-info@3.9.0: {} - - cjs-module-lexer@1.4.3: {} - - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clsx@2.1.1: {} - - cluster-key-slot@1.1.2: {} - - co@4.6.0: {} - - collect-v8-coverage@1.0.3: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@4.1.1: {} - - component-emitter@1.3.1: {} - - concat-map@0.0.1: {} - - concurrently@8.2.2: - dependencies: - chalk: 4.1.2 - date-fns: 2.30.0 - lodash: 4.17.23 - rxjs: 7.8.2 - shell-quote: 1.8.3 - spawn-command: 0.0.2 - supports-color: 8.1.1 - tree-kill: 1.2.2 - yargs: 17.7.2 - - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.0.7: {} - - cookie@0.7.2: {} - - cookiejar@2.1.4: {} - - create-jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - create-require@1.1.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - cssesc@3.0.0: {} - - csstype@3.2.3: {} - - date-fns@2.30.0: - dependencies: - '@babel/runtime': 7.29.2 - - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - dedent@1.7.2: {} - - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} - - delayed-stream@1.0.0: {} - - denque@2.1.0: {} - - depd@2.0.0: {} - - destroy@1.2.0: {} - - detect-newline@3.1.0: {} - - detect-node-es@1.1.0: {} - - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - didyoumean@1.2.2: {} - - diff-sequences@29.6.3: {} - - diff@4.0.4: {} - - dlv@1.1.3: {} - - dotenv@16.6.1: {} - - drizzle-orm@0.36.4(@prisma/client@5.22.0(prisma@5.22.0))(@types/pg@8.20.0)(@types/react@18.3.28)(pg@8.20.0)(prisma@5.22.0)(react@18.3.1): - optionalDependencies: - '@prisma/client': 5.22.0(prisma@5.22.0) - '@types/pg': 8.20.0 - '@types/react': 18.3.28 - pg: 8.20.0 - prisma: 5.22.0 - react: 18.3.1 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - dynamic-dedupe@0.3.0: - dependencies: - xtend: 4.0.2 - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - ee-first@1.1.1: {} - - electron-to-chromium@1.5.328: {} - - emittery@0.13.1: {} - - emoji-regex@8.0.0: {} - - encodeurl@2.0.0: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@2.0.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@1.21.7)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - eslint: 10.1.0(jiti@1.21.7) - hermes-parser: 0.25.1 - zod: 3.25.76 - zod-validation-error: 4.0.2(zod@3.25.76) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@1.21.7)): - dependencies: - eslint: 10.1.0(jiti@1.21.7) - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.1.0(jiti@1.21.7): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.3 - '@eslint/config-helpers': 0.5.3 - '@eslint/core': 1.1.1 - '@eslint/plugin-kit': 0.6.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - 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.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 1.21.7 - transitivePeerDependencies: - - supports-color - - espree@11.2.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esprima@4.0.1: {} - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - etag@1.8.1: {} - - eventemitter3@4.0.7: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - exit@0.1.2: {} - - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - - express-rate-limit@7.5.1(express@4.22.1): - dependencies: - express: 4.22.1 - - express@4.22.1: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.13 - proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-safe-stringify@2.1.1: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - follow-redirects@1.15.11: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.15.0 - - forwarded@0.2.0: {} - - fraction.js@5.3.4: {} - - fresh@0.5.2: {} - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-nonce@1.0.1: {} - - get-package-type@0.1.0: {} - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@6.0.1: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - gsap@3.14.2: {} - - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - - html-escaper@2.0.2: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - http-proxy-middleware@2.0.9(@types/express@4.17.25): - dependencies: - '@types/http-proxy': 1.17.17 - http-proxy: 1.18.1 - is-glob: 4.0.3 - is-plain-obj: 3.0.0 - micromatch: 4.0.8 - optionalDependencies: - '@types/express': 4.17.25 - transitivePeerDependencies: - - debug - - http-proxy@1.18.1: - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.11 - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - - human-signals@2.1.0: {} - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - ioredis@5.10.1: - dependencies: - '@ioredis/commands': 1.5.1 - cluster-key-slot: 1.1.2 - debug: 4.4.3 - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - - ipaddr.js@1.9.1: {} - - is-arrayish@0.2.1: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-generator-fn@2.1.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-plain-obj@3.0.0: {} - - is-stream@2.0.1: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@4.0.1: - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - jest-changed-files@29.7.0: - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.2 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-config@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.0 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.37 - ts-node: 10.9.2(@types/node@20.19.37)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-docblock@29.7.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@29.7.0: - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 - - jest-environment-node@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - jest-get-type@29.6.3: {} - - jest-haste-map@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.37 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.29.0 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - jest-util: 29.7.0 - - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 - - jest-regex-util@29.6.3: {} - - jest-resolve-dependencies@29.7.0: - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - jest-resolve@29.7.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.11 - resolve.exports: 2.0.3 - slash: 3.0.0 - - jest-runner@29.7.0: - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color - - jest-runtime@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.3 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color - - jest-snapshot@29.7.0: - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/types': 7.29.0 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.2 - - jest-validate@29.7.0: - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 - - jest-watcher@29.7.0: - dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.37 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 - - jest-worker@29.7.0: - dependencies: - '@types/node': 20.19.37 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jiti@1.21.7: {} - - js-tokens@4.0.0: {} - - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@2.3.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - jsonwebtoken@9.0.3: - dependencies: - jws: 4.0.1 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.4 - - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@3.0.3: {} - - leven@3.1.0: {} - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.defaults@4.2.0: {} - - lodash.includes@4.3.0: {} - - lodash.isarguments@3.1.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - - lodash.memoize@4.1.2: {} - - lodash.once@4.1.1: {} - - lodash@4.17.23: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.468.0(react@18.3.1): - dependencies: - react: 18.3.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.4 - - make-error@1.3.6: {} - - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - - math-intrinsics@1.1.0: {} - - media-typer@0.3.0: {} - - merge-descriptors@1.0.3: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - methods@1.1.2: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@1.6.0: {} - - mime@2.6.0: {} - - mimic-fn@2.1.0: {} - - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.5 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.13 - - minimist@1.2.8: {} - - mkdirp@1.0.4: {} - - ms@2.0.0: {} - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - negotiator@0.6.3: {} - - neo-async@2.6.2: {} - - node-int64@0.4.0: {} - - node-releases@2.0.36: {} - - nodemailer@7.0.13: {} - - normalize-path@3.0.0: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - object-assign@4.1.1: {} - - object-hash@3.0.0: {} - - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - p-try@2.2.0: {} - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parseurl@1.3.3: {} - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - path-to-regexp@0.1.13: {} - - pg-cloudflare@1.3.0: - optional: true - - pg-connection-string@2.12.0: {} - - pg-int8@1.0.1: {} - - pg-pool@3.13.0(pg@8.20.0): - dependencies: - pg: 8.20.0 - - pg-protocol@1.13.0: {} - - pg-types@2.2.0: - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.1 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - - pg@8.20.0: - dependencies: - pg-connection-string: 2.12.0 - pg-pool: 3.13.0(pg@8.20.0) - pg-protocol: 1.13.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.3.0 - - pgpass@1.0.5: - dependencies: - split2: 4.2.0 - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - pify@2.3.0: {} - - pirates@4.0.7: {} - - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 - - postcss-import@15.1.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.11 - - postcss-js@4.1.0(postcss@8.5.8): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.8 - - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 1.21.7 - postcss: 8.5.8 - - postcss-nested@6.2.0(postcss@8.5.8): - dependencies: - postcss: 8.5.8 - postcss-selector-parser: 6.1.2 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postgres-array@2.0.0: {} - - postgres-bytea@1.0.1: {} - - postgres-date@1.0.7: {} - - postgres-interval@1.2.0: - dependencies: - xtend: 4.0.2 - - prelude-ls@1.2.1: {} - - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - prisma@5.22.0: - dependencies: - '@prisma/engines': 5.22.0 - optionalDependencies: - fsevents: 2.3.3 - optional: true - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - proxy-from-env@1.1.0: {} - - punycode@2.3.1: {} - - pure-rand@6.1.0: {} - - qs@6.14.2: - dependencies: - side-channel: 1.1.0 - - qs@6.15.0: - dependencies: - side-channel: 1.1.0 - - querystringify@2.2.0: {} - - queue-microtask@1.2.3: {} - - range-parser@1.2.1: {} - - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - - react-is@18.3.1: {} - - react-refresh@0.17.0: {} - - react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1): - dependencies: - react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - react-remove-scroll@2.7.2(@types/react@18.3.28)(react@18.3.1): - dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.28)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.28)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - - react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.3(react@18.3.1) - - react-router@6.30.3(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - - react-style-singleton@2.2.3(@types/react@18.3.28)(react@18.3.1): - dependencies: - get-nonce: 1.0.1 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - - redis-errors@1.2.0: {} - - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - - require-directory@2.1.1: {} - - requires-port@1.0.0: {} - - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - - resolve-from@5.0.0: {} - - resolve.exports@2.0.3: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - rimraf@2.7.1: - dependencies: - glob: 7.2.3 - - rollup@4.60.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.0 - '@rollup/rollup-android-arm64': 4.60.0 - '@rollup/rollup-darwin-arm64': 4.60.0 - '@rollup/rollup-darwin-x64': 4.60.0 - '@rollup/rollup-freebsd-arm64': 4.60.0 - '@rollup/rollup-freebsd-x64': 4.60.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 - '@rollup/rollup-linux-arm-musleabihf': 4.60.0 - '@rollup/rollup-linux-arm64-gnu': 4.60.0 - '@rollup/rollup-linux-arm64-musl': 4.60.0 - '@rollup/rollup-linux-loong64-gnu': 4.60.0 - '@rollup/rollup-linux-loong64-musl': 4.60.0 - '@rollup/rollup-linux-ppc64-gnu': 4.60.0 - '@rollup/rollup-linux-ppc64-musl': 4.60.0 - '@rollup/rollup-linux-riscv64-gnu': 4.60.0 - '@rollup/rollup-linux-riscv64-musl': 4.60.0 - '@rollup/rollup-linux-s390x-gnu': 4.60.0 - '@rollup/rollup-linux-x64-gnu': 4.60.0 - '@rollup/rollup-linux-x64-musl': 4.60.0 - '@rollup/rollup-openbsd-x64': 4.60.0 - '@rollup/rollup-openharmony-arm64': 4.60.0 - '@rollup/rollup-win32-arm64-msvc': 4.60.0 - '@rollup/rollup-win32-ia32-msvc': 4.60.0 - '@rollup/rollup-win32-x64-gnu': 4.60.0 - '@rollup/rollup-win32-x64-msvc': 4.60.0 - fsevents: 2.3.3 - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - - semver@6.3.1: {} - - semver@7.7.4: {} - - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - shell-quote@1.8.3: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - sisteransi@1.0.5: {} - - slash@3.0.0: {} - - source-map-js@1.2.1: {} - - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - spawn-command@0.0.2: {} - - split2@4.2.0: {} - - sprintf-js@1.0.3: {} - - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - - standard-as-callback@2.1.0: {} - - statuses@2.0.2: {} - - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@3.0.0: {} - - strip-bom@4.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-json-comments@2.0.1: {} - - strip-json-comments@3.1.1: {} - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 - - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.15.0 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - supertest@6.3.4: - dependencies: - methods: 1.1.2 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - tailwind-merge@2.6.1: {} - - tailwindcss@3.4.19: - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.8 - postcss-import: 15.1.0(postcss@8.5.8) - postcss-js: 4.1.0(postcss@8.5.8) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8) - postcss-nested: 6.2.0(postcss@8.5.8) - postcss-selector-parser: 6.1.2 - resolve: 1.22.11 - sucrase: 3.35.1 - transitivePeerDependencies: - - tsx - - yaml - - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.5 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tmpl@1.0.5: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - tree-kill@1.2.2: {} - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - ts-interface-checker@0.1.13: {} - - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - jest-util: 29.7.0 - - ts-node-dev@2.0.0(@types/node@20.19.37)(typescript@5.9.3): - dependencies: - chokidar: 3.6.0 - dynamic-dedupe: 0.3.0 - minimist: 1.2.8 - mkdirp: 1.0.4 - resolve: 1.22.11 - rimraf: 2.7.1 - source-map-support: 0.5.21 - tree-kill: 1.2.2 - ts-node: 10.9.2(@types/node@20.19.37)(typescript@5.9.3) - tsconfig: 7.0.0 - typescript: 5.9.3 - transitivePeerDependencies: - - '@swc/core' - - '@swc/wasm' - - '@types/node' - - ts-node@10.9.2(@types/node@20.19.37)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.37 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - - tsconfig@7.0.0: - dependencies: - '@types/strip-bom': 3.0.0 - '@types/strip-json-comments': 0.0.30 - strip-bom: 3.0.0 - strip-json-comments: 2.0.1 - - tslib@2.8.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-detect@4.0.8: {} - - type-fest@0.21.3: {} - - type-fest@4.41.0: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - typescript@5.9.3: {} - - uglify-js@3.19.3: - optional: true - - undici-types@6.21.0: {} - - unpipe@1.0.0: {} - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - - use-callback-ref@1.3.3(@types/react@18.3.28)(react@18.3.1): - dependencies: - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - use-sidecar@1.1.3(@types/react@18.3.28)(react@18.3.1): - dependencies: - detect-node-es: 1.1.0 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - util-deprecate@1.0.2: {} - - utils-merge@1.0.1: {} - - v8-compile-cache-lib@3.0.1: {} - - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - - vary@1.1.2: {} - - vite@6.4.1(@types/node@22.19.15)(jiti@1.21.7): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.15 - fsevents: 2.3.3 - jiti: 1.21.7 - - walker@1.0.8: - dependencies: - makeerror: 1.0.12 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - wordwrap@1.0.0: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - - xtend@4.0.2: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yn@3.1.1: {} - - yocto-queue@0.1.0: {} - - zod-validation-error@4.0.2(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod@3.25.76: {} diff --git a/ticketflow/pnpm-workspace.yaml b/ticketflow/pnpm-workspace.yaml deleted file mode 100644 index 56b1156..0000000 --- a/ticketflow/pnpm-workspace.yaml +++ /dev/null @@ -1,5 +0,0 @@ -packages: - - 'gateway' - - 'services/*' - - 'shared' - - 'frontend' diff --git a/ticketflow/scripts/demo.sh b/ticketflow/scripts/demo.sh new file mode 100755 index 0000000..56971b8 --- /dev/null +++ b/ticketflow/scripts/demo.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# ============================================================================= +# TicketFlow End-to-End Demo +# Exercises: register → login → create event → list seats → book → poll status +# ============================================================================= +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:3000}" +POLL_RETRIES=20 +POLL_SLEEP=2 + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[demo]${NC} $*"; } +success() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +fail() { echo -e "${RED}[fail]${NC} $*"; exit 1; } + +require_jq() { + command -v jq &>/dev/null || fail "jq is required. Install it with: brew install jq" +} + +require_curl() { + command -v curl &>/dev/null || fail "curl is required." +} + +# --------------------------------------------------------------------------- +info "Checking dependencies..." +require_jq +require_curl + +# --------------------------------------------------------------------------- +info "Waiting for gateway to be ready at ${BASE_URL}/health ..." +for i in $(seq 1 10); do + if curl -sf "${BASE_URL}/health" &>/dev/null; then + success "Gateway is up." + break + fi + if [[ $i -eq 10 ]]; then + fail "Gateway did not respond after 10 attempts. Is the stack running? Try: make dev" + fi + echo " attempt $i/10..." + sleep 3 +done + +# --------------------------------------------------------------------------- +TIMESTAMP=$(date +%s) +EMAIL="demo-user-${TIMESTAMP}@ticketflow.dev" +PASSWORD="Demo1234!" +NAME="Demo User ${TIMESTAMP}" + +info "Registering user: ${EMAIL}" +REGISTER_RESP=$(curl -sf -X POST "${BASE_URL}/api/users/register" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"${NAME}\",\"email\":\"${EMAIL}\",\"password\":\"${PASSWORD}\"}") +echo "$REGISTER_RESP" | jq . +success "User registered." + +# --------------------------------------------------------------------------- +info "Logging in..." +LOGIN_RESP=$(curl -sf -X POST "${BASE_URL}/api/users/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${EMAIL}\",\"password\":\"${PASSWORD}\"}") +TOKEN=$(echo "$LOGIN_RESP" | jq -r '.token') +[[ -z "$TOKEN" || "$TOKEN" == "null" ]] && fail "Login failed — no token returned." +success "Logged in. Token acquired." + +AUTH_HEADER="Authorization: Bearer ${TOKEN}" + +# --------------------------------------------------------------------------- +info "Fetching available events..." +EVENTS_RESP=$(curl -sf "${BASE_URL}/api/events" -H "$AUTH_HEADER") +echo "$EVENTS_RESP" | jq '.events[] | {id, name, date, price}' + +EVENT_ID=$(echo "$EVENTS_RESP" | jq -r '.events[0].id // empty') +if [[ -z "$EVENT_ID" ]]; then + warn "No events found. Running seed first..." + # Attempt to call the seed endpoint if it exists, otherwise skip + curl -sf -X POST "${BASE_URL}/api/events/seed" -H "$AUTH_HEADER" &>/dev/null || true + EVENTS_RESP=$(curl -sf "${BASE_URL}/api/events" -H "$AUTH_HEADER") + EVENT_ID=$(echo "$EVENTS_RESP" | jq -r '.events[0].id // empty') + [[ -z "$EVENT_ID" ]] && fail "Still no events after seed attempt. Run: make seed" +fi + +EVENT_NAME=$(echo "$EVENTS_RESP" | jq -r '.events[0].name') +success "Using event: ${EVENT_NAME} (${EVENT_ID})" + +# --------------------------------------------------------------------------- +info "Fetching available seats for event..." +SEATS_RESP=$(curl -sf "${BASE_URL}/api/inventory/events/${EVENT_ID}/seats" -H "$AUTH_HEADER") +AVAILABLE_SEATS=$(echo "$SEATS_RESP" | jq '[.seats[] | select(.status == "AVAILABLE")] | .[0:2]') +echo "$AVAILABLE_SEATS" | jq . + +SEAT_IDS=$(echo "$AVAILABLE_SEATS" | jq -r '[.[].id] | @json') +SEAT_COUNT=$(echo "$AVAILABLE_SEATS" | jq 'length') + +if [[ "$SEAT_COUNT" -eq 0 ]]; then + fail "No available seats found for this event." +fi +success "Selected ${SEAT_COUNT} seat(s)." + +# --------------------------------------------------------------------------- +info "Creating booking (POST /api/bookings → expecting 202 Accepted)..." +BOOKING_RESP=$(curl -sf -o /tmp/booking_resp.json -w "%{http_code}" \ + -X POST "${BASE_URL}/api/bookings" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "{\"eventId\":\"${EVENT_ID}\",\"seatIds\":${SEAT_IDS}}") + +HTTP_CODE="$BOOKING_RESP" +if [[ "$HTTP_CODE" != "202" ]]; then + echo "Response body:" && cat /tmp/booking_resp.json + fail "Expected HTTP 202, got ${HTTP_CODE}" +fi + +cat /tmp/booking_resp.json | jq . +BOOKING_ID=$(cat /tmp/booking_resp.json | jq -r '.bookingId // .id // empty') +[[ -z "$BOOKING_ID" ]] && fail "Could not extract bookingId from response." +success "Booking accepted. ID: ${BOOKING_ID}" + +# --------------------------------------------------------------------------- +info "Polling booking status (up to $((POLL_RETRIES * POLL_SLEEP))s)..." +FINAL_STATUS="" +for i in $(seq 1 "$POLL_RETRIES"); do + STATUS_RESP=$(curl -sf "${BASE_URL}/api/bookings/${BOOKING_ID}" -H "$AUTH_HEADER") + STATUS=$(echo "$STATUS_RESP" | jq -r '.status // .booking.status // empty') + echo " poll ${i}/${POLL_RETRIES}: status = ${STATUS}" + + if [[ "$STATUS" != "PENDING" && -n "$STATUS" ]]; then + FINAL_STATUS="$STATUS" + echo "$STATUS_RESP" | jq . + break + fi + sleep "$POLL_SLEEP" +done + +if [[ -z "$FINAL_STATUS" ]]; then + fail "Timed out waiting for booking to leave PENDING state." +fi + +if [[ "$FINAL_STATUS" == "CONFIRMED" ]]; then + success "Booking CONFIRMED! Check Mailpit at http://localhost:8025 for the confirmation email." +else + warn "Booking ended with status: ${FINAL_STATUS}" +fi + +# --------------------------------------------------------------------------- +info "Fetching all bookings for user..." +MY_BOOKINGS=$(curl -sf "${BASE_URL}/api/bookings/my" -H "$AUTH_HEADER") +echo "$MY_BOOKINGS" | jq '[.[] | {id, status, totalAmount}]' + +echo "" +echo -e "${GREEN}============================================${NC}" +echo -e "${GREEN} Demo complete!${NC}" +echo -e "${GREEN}============================================${NC}" +echo "" +echo " Kafka UI: http://localhost:8080" +echo " Mailpit: http://localhost:8025" +echo " Grafana: http://localhost:3100" +echo " Jaeger: http://localhost:16686" +echo "" diff --git a/ticketflow/services/booking-service/Dockerfile b/ticketflow/services/booking-service/Dockerfile index bcec2a4..c6f5f20 100644 --- a/ticketflow/services/booking-service/Dockerfile +++ b/ticketflow/services/booking-service/Dockerfile @@ -1,9 +1,14 @@ -FROM node:20-alpine +# Build stage +FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /app -RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm run build +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests -B + +# Run stage +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/target/*.jar app.jar EXPOSE 3003 -CMD ["node", "dist/src/index.js"] +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/ticketflow/services/booking-service/jest.config.js b/ticketflow/services/booking-service/jest.config.js deleted file mode 100644 index 556db62..0000000 --- a/ticketflow/services/booking-service/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts'], - moduleNameMapper: { - '^@ticketflow/shared$': '/../../shared/src/index.ts', - }, -}; diff --git a/ticketflow/services/booking-service/package.json b/ticketflow/services/booking-service/package.json deleted file mode 100644 index 279593b..0000000 --- a/ticketflow/services/booking-service/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "booking-service", - "version": "1.0.0", - "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/src/index.js", - "migrate": "node scripts/migrate.cjs", - "migrate:dev": "node scripts/migrate.cjs", - "generate": "echo \"No code generation required with Drizzle\"", - "test": "jest --forceExit" - }, - "dependencies": { - "@ticketflow/shared": "workspace:*", - "amqplib": "^0.10.3", - "axios": "^1.6.2", - "dotenv": "^16.3.1", - "drizzle-orm": "^0.36.4", - "express": "^4.18.2", - "pg": "^8.13.1", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/amqplib": "^0.10.4", - "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.6", - "@types/pg": "^8.11.10", - "@types/supertest": "^6.0.2", - "jest": "^29.7.0", - "supertest": "^6.3.4", - "ts-jest": "^29.1.1", - "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" - } -} diff --git a/ticketflow/services/booking-service/pom.xml b/ticketflow/services/booking-service/pom.xml new file mode 100644 index 0000000..3d761b2 --- /dev/null +++ b/ticketflow/services/booking-service/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.ticketflow + booking-service + 1.0.0 + booking-service + TicketFlow Booking Service + + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.kafka + spring-kafka + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/ticketflow/services/booking-service/scripts/migrate.cjs b/ticketflow/services/booking-service/scripts/migrate.cjs deleted file mode 100644 index 0d5dfdc..0000000 --- a/ticketflow/services/booking-service/scripts/migrate.cjs +++ /dev/null @@ -1,48 +0,0 @@ -const { Pool } = require('pg') - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is required for migrations') -} - -const pool = new Pool({ connectionString }) - -async function run() { - await pool.query(` -DO $$ BEGIN - CREATE TYPE "BookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FAILED', 'CANCELLED'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; -`) - - await pool.query(` -CREATE TABLE IF NOT EXISTS "Booking" ( - "id" text PRIMARY KEY, - "userId" text NOT NULL, - "eventId" text NOT NULL, - "status" "BookingStatus" NOT NULL DEFAULT 'PENDING', - "totalAmount" numeric(10, 2) NOT NULL, - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now() -); -`) - - await pool.query(` -CREATE TABLE IF NOT EXISTS "BookingItem" ( - "id" text PRIMARY KEY, - "bookingId" text NOT NULL REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE, - "seatId" text NOT NULL -); -`) -} - -run() - .then(() => { - console.log('booking-service migration complete') - return pool.end() - }) - .catch((error) => { - console.error('booking-service migration failed', error) - return pool.end().finally(() => process.exit(1)) - }) diff --git a/ticketflow/services/booking-service/src/app.ts b/ticketflow/services/booking-service/src/app.ts deleted file mode 100644 index fe6c398..0000000 --- a/ticketflow/services/booking-service/src/app.ts +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express'; -import { errorHandler } from '@ticketflow/shared'; -import { bookingRouter } from './routes/booking.routes'; - -export const app = express(); - -app.use(express.json()); -app.use('/api/bookings', bookingRouter); -app.use(errorHandler); diff --git a/ticketflow/services/booking-service/src/controllers/booking.controller.ts b/ticketflow/services/booking-service/src/controllers/booking.controller.ts deleted file mode 100644 index 075b1f2..0000000 --- a/ticketflow/services/booking-service/src/controllers/booking.controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { bookingService } from '../services/booking.service'; -import { CreateBookingInput } from '../schemas/booking.schema'; - -export async function createBooking(req: Request, res: Response, next: NextFunction): Promise { - try { - const userId = req.user!.sub; - const userEmail = req.user!.email; - const { eventId, seatIds } = req.body as CreateBookingInput; - const booking = await bookingService.create({ userId, userEmail, eventId, seatIds }); - res.status(201).json({ booking }); - } catch (err) { - next(err); - } -} - -export async function getBookingById(req: Request, res: Response, next: NextFunction): Promise { - try { - const booking = await bookingService.getById(req.params.id, req.user!.sub); - res.json({ booking }); - } catch (err) { - next(err); - } -} - -export async function getMyBookings(req: Request, res: Response, next: NextFunction): Promise { - try { - const bookings = await bookingService.getByUser(req.user!.sub); - res.json({ bookings }); - } catch (err) { - next(err); - } -} - -export async function confirmBooking(req: Request, res: Response, next: NextFunction): Promise { - try { - const booking = await bookingService.confirm(req.params.id, req.user!.sub); - res.json({ booking }); - } catch (err) { - next(err); - } -} - -export async function cancelBooking(req: Request, res: Response, next: NextFunction): Promise { - try { - const booking = await bookingService.cancel(req.params.id, req.user!.sub); - res.json({ booking }); - } catch (err) { - next(err); - } -} diff --git a/ticketflow/services/booking-service/src/db/client.ts b/ticketflow/services/booking-service/src/db/client.ts deleted file mode 100644 index 3fecfb3..0000000 --- a/ticketflow/services/booking-service/src/db/client.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drizzle } from 'drizzle-orm/node-postgres' -import { Pool } from 'pg' - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is not configured') -} - -const pool = new Pool({ connectionString }) -export const db = drizzle(pool) diff --git a/ticketflow/services/booking-service/src/db/schema.ts b/ticketflow/services/booking-service/src/db/schema.ts deleted file mode 100644 index 97e25e1..0000000 --- a/ticketflow/services/booking-service/src/db/schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { numeric, pgEnum, pgTable, text, timestamp } from 'drizzle-orm/pg-core' - -export const bookingStatusEnum = pgEnum('BookingStatus', ['PENDING', 'CONFIRMED', 'FAILED', 'CANCELLED']) - -export const bookings = pgTable('Booking', { - id: text('id').primaryKey(), - userId: text('userId').notNull(), - eventId: text('eventId').notNull(), - status: bookingStatusEnum('status').notNull().default('PENDING'), - totalAmount: numeric('totalAmount', { precision: 10, scale: 2 }).notNull(), - createdAt: timestamp('createdAt', { withTimezone: false }).notNull().defaultNow(), - updatedAt: timestamp('updatedAt', { withTimezone: false }).notNull().defaultNow(), -}) - -export const bookingItems = pgTable('BookingItem', { - id: text('id').primaryKey(), - bookingId: text('bookingId').notNull(), - seatId: text('seatId').notNull(), -}) diff --git a/ticketflow/services/booking-service/src/index.ts b/ticketflow/services/booking-service/src/index.ts deleted file mode 100644 index 7709912..0000000 --- a/ticketflow/services/booking-service/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'dotenv/config'; -import { app } from './app'; - -const PORT = process.env.PORT ?? 3003; - -app.listen(PORT, () => { - console.log(`Booking service listening on port ${PORT}`); -}); diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/BookingServiceApplication.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/BookingServiceApplication.java new file mode 100644 index 0000000..2709c86 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/BookingServiceApplication.java @@ -0,0 +1,11 @@ +package com.ticketflow.booking; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BookingServiceApplication { + public static void main(String[] args) { + SpringApplication.run(BookingServiceApplication.class, args); + } +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java new file mode 100644 index 0000000..bcef80c --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java @@ -0,0 +1,92 @@ +package com.ticketflow.booking.config; + +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.core.KafkaAdmin; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.util.backoff.FixedBackOff; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Bean + public KafkaAdmin kafkaAdmin() { + return new KafkaAdmin(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers)); + } + + @Bean + public NewTopic bookingInitiatedTopic() { + return TopicBuilder.name("ticketflow.booking.initiated").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic paymentRequestedTopic() { + return TopicBuilder.name("ticketflow.payment.requested").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic bookingConfirmedTopic() { + return TopicBuilder.name("ticketflow.booking.confirmed").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic bookingFailedTopic() { + return TopicBuilder.name("ticketflow.booking.failed").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic bookingCancelledTopic() { + return TopicBuilder.name("ticketflow.booking.cancelled").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic seatsConfirmTopic() { + return TopicBuilder.name("ticketflow.seats.confirm").partitions(3).replicas(1).build(); + } + + @Bean + public NewTopic seatsReleaseTopic() { + return TopicBuilder.name("ticketflow.seats.release").partitions(3).replicas(1).build(); + } + + @Bean + public ConsumerFactory consumerFactory() { + Map props = new HashMap<>(); + props.put(org.apache.kafka.clients.consumer.ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(org.apache.kafka.clients.consumer.ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(org.apache.kafka.clients.consumer.ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(org.apache.kafka.clients.consumer.ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + props.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, "java.util.Map"); + return new DefaultKafkaConsumerFactory<>(props); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setCommonErrorHandler(new DefaultErrorHandler(new FixedBackOff(3000L, 3))); + return factory; + } +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/SecurityConfig.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/SecurityConfig.java new file mode 100644 index 0000000..307a086 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/config/SecurityConfig.java @@ -0,0 +1,21 @@ +package com.ticketflow.booking.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/BookingController.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/BookingController.java new file mode 100644 index 0000000..e99850a --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/BookingController.java @@ -0,0 +1,57 @@ +package com.ticketflow.booking.controller; + +import com.ticketflow.booking.dto.BookingResponse; +import com.ticketflow.booking.dto.CreateBookingRequest; +import com.ticketflow.booking.service.BookingService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/bookings") +@RequiredArgsConstructor +public class BookingController { + + private final BookingService bookingService; + + @PostMapping + public ResponseEntity createBooking( + @RequestHeader("x-user-id") String userId, + @RequestHeader("x-user-email") String userEmail, + @Valid @RequestBody CreateBookingRequest request) { + BookingResponse response = bookingService.createBooking(userId, userEmail, request); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } + + @GetMapping("/my") + public ResponseEntity> getUserBookings( + @RequestHeader("x-user-id") String userId) { + return ResponseEntity.ok(bookingService.getUserBookings(userId)); + } + + @GetMapping("/{id}") + public ResponseEntity getBooking( + @PathVariable String id, + @RequestHeader("x-user-id") String userId) { + return ResponseEntity.ok(bookingService.getBooking(id, userId)); + } + + @PostMapping("/{id}/cancel") + public ResponseEntity cancelBooking( + @PathVariable String id, + @RequestHeader("x-user-id") String userId) { + return ResponseEntity.ok(bookingService.cancelBooking(id, userId)); + } + + @GetMapping("/health") + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of("status", "up", "service", "booking-service")); + } +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/GlobalExceptionHandler.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..844b2cb --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +package com.ticketflow.booking.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + private ResponseEntity> errorResponse(String code, String message, HttpStatus status) { + return ResponseEntity.status(status) + .body(Map.of("error", Map.of("code", code, "message", message))); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + return errorResponse("VALIDATION_ERROR", message, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + String code = ex.getStatusCode().value() == 404 ? "NOT_FOUND" + : ex.getStatusCode().value() == 403 ? "FORBIDDEN" + : ex.getStatusCode().value() == 400 ? "BAD_REQUEST" + : "ERROR"; + return errorResponse(code, ex.getReason(), HttpStatus.valueOf(ex.getStatusCode().value())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return errorResponse("INTERNAL_ERROR", "An internal error occurred", HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/BookingResponse.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/BookingResponse.java new file mode 100644 index 0000000..c10f667 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/BookingResponse.java @@ -0,0 +1,18 @@ +package com.ticketflow.booking.dto; + +import com.ticketflow.booking.entity.BookingStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record BookingResponse( + String id, + String userId, + String eventId, + String eventName, + BookingStatus status, + BigDecimal totalAmount, + List seatIds, + LocalDateTime createdAt +) {} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/CreateBookingRequest.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/CreateBookingRequest.java new file mode 100644 index 0000000..0cb9312 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/dto/CreateBookingRequest.java @@ -0,0 +1,15 @@ +package com.ticketflow.booking.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.util.List; + +public record CreateBookingRequest( + @NotBlank String eventId, + @NotBlank String eventName, + @NotEmpty List seatIds, + @NotNull BigDecimal totalAmount +) {} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/Booking.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/Booking.java new file mode 100644 index 0000000..5a857dd --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/Booking.java @@ -0,0 +1,54 @@ +package com.ticketflow.booking.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "bookings") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Booking { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(nullable = false) + private String userId; + + @Column(nullable = false) + private String userEmail; + + @Column(nullable = false) + private String eventId; + + @Column(nullable = false) + private String eventName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private BookingStatus status = BookingStatus.PENDING; + + private BigDecimal totalAmount; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "booking", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List items = new ArrayList<>(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingItem.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingItem.java new file mode 100644 index 0000000..41cb218 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingItem.java @@ -0,0 +1,24 @@ +package com.ticketflow.booking.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "booking_items") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookingItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "booking_id", nullable = false) + private Booking booking; + + @Column(nullable = false) + private String seatId; +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingStatus.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingStatus.java new file mode 100644 index 0000000..c4af9c7 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/entity/BookingStatus.java @@ -0,0 +1,8 @@ +package com.ticketflow.booking.entity; + +public enum BookingStatus { + PENDING, + CONFIRMED, + FAILED, + CANCELLED +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/consumer/SagaReplyConsumer.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/consumer/SagaReplyConsumer.java new file mode 100644 index 0000000..e498e21 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/consumer/SagaReplyConsumer.java @@ -0,0 +1,85 @@ +package com.ticketflow.booking.kafka.consumer; + +import com.ticketflow.booking.entity.Booking; +import com.ticketflow.booking.entity.BookingStatus; +import com.ticketflow.booking.kafka.events.PaymentProcessedEvent; +import com.ticketflow.booking.kafka.events.SeatsLockFailedEvent; +import com.ticketflow.booking.kafka.events.SeatsLockedEvent; +import com.ticketflow.booking.kafka.producer.BookingEventProducer; +import com.ticketflow.booking.repository.BookingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SagaReplyConsumer { + + private final BookingRepository bookingRepository; + private final BookingEventProducer producer; + + @Transactional + @KafkaListener(topics = "ticketflow.seats.locked", groupId = "booking-service") + public void onSeatsLocked(@Payload SeatsLockedEvent event) { + log.info("Received seats.locked for bookingId={}", event.getBookingId()); + Booking booking = bookingRepository.findByIdWithItems(event.getBookingId()) + .orElseThrow(() -> { + log.error("Booking not found for id={}", event.getBookingId()); + return new RuntimeException("Booking not found: " + event.getBookingId()); + }); + + // Booking stays PENDING, proceed to payment + producer.publishPaymentRequested(booking); + } + + @Transactional + @KafkaListener(topics = "ticketflow.seats.lock-failed", groupId = "booking-service") + public void onSeatsLockFailed(@Payload SeatsLockFailedEvent event) { + log.info("Received seats.lock-failed for bookingId={}", event.getBookingId()); + Booking booking = bookingRepository.findByIdWithItems(event.getBookingId()) + .orElseThrow(() -> { + log.error("Booking not found for id={}", event.getBookingId()); + return new RuntimeException("Booking not found: " + event.getBookingId()); + }); + + booking.setStatus(BookingStatus.FAILED); + bookingRepository.save(booking); + + String reason = event.getReason() != null ? event.getReason() : "Seat lock failed"; + producer.publishBookingFailed(booking, reason); + } + + @Transactional + @KafkaListener(topics = "ticketflow.payment.processed", groupId = "booking-service") + public void onPaymentProcessed(@Payload PaymentProcessedEvent event) { + log.info("Received payment.processed for bookingId={}, status={}", event.getBookingId(), event.getStatus()); + Booking booking = bookingRepository.findByIdWithItems(event.getBookingId()) + .orElseThrow(() -> { + log.error("Booking not found for id={}", event.getBookingId()); + return new RuntimeException("Booking not found: " + event.getBookingId()); + }); + + List seatIds = booking.getItems().stream() + .map(item -> item.getSeatId()) + .toList(); + + if ("SUCCESS".equalsIgnoreCase(event.getStatus())) { + booking.setStatus(BookingStatus.CONFIRMED); + bookingRepository.save(booking); + producer.publishBookingConfirmed(booking); + producer.publishSeatsConfirm(booking.getId(), seatIds); + } else { + booking.setStatus(BookingStatus.FAILED); + bookingRepository.save(booking); + String reason = event.getReason() != null ? event.getReason() : "Payment failed"; + producer.publishBookingFailed(booking, reason); + producer.publishSeatsRelease(booking.getId(), seatIds); + } + } +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingCancelledEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingCancelledEvent.java new file mode 100644 index 0000000..88470ed --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingCancelledEvent.java @@ -0,0 +1,20 @@ +package com.ticketflow.booking.kafka.events; + +import lombok.*; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookingCancelledEvent { + @Builder.Default + private String eventType = "booking.cancelled"; + private String bookingId; + private String userId; + private List seatIds; + @Builder.Default + private String timestamp = Instant.now().toString(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingConfirmedEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingConfirmedEvent.java new file mode 100644 index 0000000..585b184 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingConfirmedEvent.java @@ -0,0 +1,25 @@ +package com.ticketflow.booking.kafka.events; + +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookingConfirmedEvent { + @Builder.Default + private String eventType = "booking.confirmed"; + private String bookingId; + private String userId; + private String userEmail; + private String eventId; + private String eventName; + private List seatIds; + private BigDecimal totalAmount; + @Builder.Default + private String timestamp = Instant.now().toString(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingFailedEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingFailedEvent.java new file mode 100644 index 0000000..939b1d8 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingFailedEvent.java @@ -0,0 +1,20 @@ +package com.ticketflow.booking.kafka.events; + +import lombok.*; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookingFailedEvent { + @Builder.Default + private String eventType = "booking.failed"; + private String bookingId; + private String userId; + private String userEmail; + private String reason; + @Builder.Default + private String timestamp = Instant.now().toString(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingInitiatedEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingInitiatedEvent.java new file mode 100644 index 0000000..b62ed05 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/BookingInitiatedEvent.java @@ -0,0 +1,25 @@ +package com.ticketflow.booking.kafka.events; + +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookingInitiatedEvent { + @Builder.Default + private String eventType = "booking.initiated"; + private String bookingId; + private String userId; + private String userEmail; + private String eventId; + private String eventName; + private List seatIds; + private BigDecimal totalAmount; + @Builder.Default + private String timestamp = Instant.now().toString(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentProcessedEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentProcessedEvent.java new file mode 100644 index 0000000..347b47b --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentProcessedEvent.java @@ -0,0 +1,18 @@ +package com.ticketflow.booking.kafka.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class PaymentProcessedEvent { + private String eventType; + private String bookingId; + private String paymentId; + private String status; // "SUCCESS" or "FAILED" + private String reason; + private String timestamp; +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentRequestedEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentRequestedEvent.java new file mode 100644 index 0000000..6ca1cd5 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/PaymentRequestedEvent.java @@ -0,0 +1,22 @@ +package com.ticketflow.booking.kafka.events; + +import lombok.*; + +import java.math.BigDecimal; +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaymentRequestedEvent { + @Builder.Default + private String eventType = "payment.requested"; + private String bookingId; + private String userId; + private BigDecimal amount; + @Builder.Default + private String currency = "USD"; + @Builder.Default + private String timestamp = Instant.now().toString(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsConfirmEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsConfirmEvent.java new file mode 100644 index 0000000..b7c3b86 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsConfirmEvent.java @@ -0,0 +1,19 @@ +package com.ticketflow.booking.kafka.events; + +import lombok.*; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SeatsConfirmEvent { + @Builder.Default + private String eventType = "seats.confirm"; + private String bookingId; + private List seatIds; + @Builder.Default + private String timestamp = Instant.now().toString(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockFailedEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockFailedEvent.java new file mode 100644 index 0000000..c9f8ed7 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockFailedEvent.java @@ -0,0 +1,19 @@ +package com.ticketflow.booking.kafka.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.*; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class SeatsLockFailedEvent { + private String eventType; + private String bookingId; + private String reason; + private List conflictingSeatIds; + private String timestamp; +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockedEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockedEvent.java new file mode 100644 index 0000000..2f92ce1 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsLockedEvent.java @@ -0,0 +1,18 @@ +package com.ticketflow.booking.kafka.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.*; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class SeatsLockedEvent { + private String eventType; + private String bookingId; + private List seatIds; + private String timestamp; +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsReleaseEvent.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsReleaseEvent.java new file mode 100644 index 0000000..1e627d6 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/events/SeatsReleaseEvent.java @@ -0,0 +1,19 @@ +package com.ticketflow.booking.kafka.events; + +import lombok.*; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SeatsReleaseEvent { + @Builder.Default + private String eventType = "seats.release"; + private String bookingId; + private List seatIds; + @Builder.Default + private String timestamp = Instant.now().toString(); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/producer/BookingEventProducer.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/producer/BookingEventProducer.java new file mode 100644 index 0000000..5c17472 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/kafka/producer/BookingEventProducer.java @@ -0,0 +1,101 @@ +package com.ticketflow.booking.kafka.producer; + +import com.ticketflow.booking.entity.Booking; +import com.ticketflow.booking.kafka.events.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BookingEventProducer { + + private final KafkaTemplate kafkaTemplate; + + public void publishBookingInitiated(Booking booking, List seatIds) { + BookingInitiatedEvent event = BookingInitiatedEvent.builder() + .bookingId(booking.getId()) + .userId(booking.getUserId()) + .userEmail(booking.getUserEmail()) + .eventId(booking.getEventId()) + .eventName(booking.getEventName()) + .seatIds(seatIds) + .totalAmount(booking.getTotalAmount()) + .build(); + kafkaTemplate.send("ticketflow.booking.initiated", booking.getId(), event); + log.info("Published booking.initiated for bookingId={}", booking.getId()); + } + + public void publishPaymentRequested(Booking booking) { + PaymentRequestedEvent event = PaymentRequestedEvent.builder() + .bookingId(booking.getId()) + .userId(booking.getUserId()) + .amount(booking.getTotalAmount()) + .build(); + kafkaTemplate.send("ticketflow.payment.requested", booking.getId(), event); + log.info("Published payment.requested for bookingId={}", booking.getId()); + } + + public void publishBookingConfirmed(Booking booking) { + List seatIds = booking.getItems().stream() + .map(item -> item.getSeatId()) + .toList(); + BookingConfirmedEvent event = BookingConfirmedEvent.builder() + .bookingId(booking.getId()) + .userId(booking.getUserId()) + .userEmail(booking.getUserEmail()) + .eventId(booking.getEventId()) + .eventName(booking.getEventName()) + .seatIds(seatIds) + .totalAmount(booking.getTotalAmount()) + .build(); + kafkaTemplate.send("ticketflow.booking.confirmed", booking.getId(), event); + log.info("Published booking.confirmed for bookingId={}", booking.getId()); + } + + public void publishBookingFailed(Booking booking, String reason) { + BookingFailedEvent event = BookingFailedEvent.builder() + .bookingId(booking.getId()) + .userId(booking.getUserId()) + .userEmail(booking.getUserEmail()) + .reason(reason) + .build(); + kafkaTemplate.send("ticketflow.booking.failed", booking.getId(), event); + log.info("Published booking.failed for bookingId={}, reason={}", booking.getId(), reason); + } + + public void publishBookingCancelled(Booking booking) { + List seatIds = booking.getItems().stream() + .map(item -> item.getSeatId()) + .toList(); + BookingCancelledEvent event = BookingCancelledEvent.builder() + .bookingId(booking.getId()) + .userId(booking.getUserId()) + .seatIds(seatIds) + .build(); + kafkaTemplate.send("ticketflow.booking.cancelled", booking.getId(), event); + log.info("Published booking.cancelled for bookingId={}", booking.getId()); + } + + public void publishSeatsConfirm(String bookingId, List seatIds) { + SeatsConfirmEvent event = SeatsConfirmEvent.builder() + .bookingId(bookingId) + .seatIds(seatIds) + .build(); + kafkaTemplate.send("ticketflow.seats.confirm", bookingId, event); + log.info("Published seats.confirm for bookingId={}", bookingId); + } + + public void publishSeatsRelease(String bookingId, List seatIds) { + SeatsReleaseEvent event = SeatsReleaseEvent.builder() + .bookingId(bookingId) + .seatIds(seatIds) + .build(); + kafkaTemplate.send("ticketflow.seats.release", bookingId, event); + log.info("Published seats.release for bookingId={}", bookingId); + } +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/repository/BookingRepository.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/repository/BookingRepository.java new file mode 100644 index 0000000..dfe0145 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/repository/BookingRepository.java @@ -0,0 +1,19 @@ +package com.ticketflow.booking.repository; + +import com.ticketflow.booking.entity.Booking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BookingRepository extends JpaRepository { + + List findByUserId(String userId); + + @Query("SELECT b FROM Booking b LEFT JOIN FETCH b.items WHERE b.id = :id") + Optional findByIdWithItems(@Param("id") String id); +} diff --git a/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/service/BookingService.java b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/service/BookingService.java new file mode 100644 index 0000000..d5a3b53 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/service/BookingService.java @@ -0,0 +1,114 @@ +package com.ticketflow.booking.service; + +import com.ticketflow.booking.dto.BookingResponse; +import com.ticketflow.booking.dto.CreateBookingRequest; +import com.ticketflow.booking.entity.Booking; +import com.ticketflow.booking.entity.BookingItem; +import com.ticketflow.booking.entity.BookingStatus; +import com.ticketflow.booking.kafka.producer.BookingEventProducer; +import com.ticketflow.booking.repository.BookingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BookingService { + + private final BookingRepository bookingRepository; + private final BookingEventProducer producer; + + @Transactional + public BookingResponse createBooking(String userId, String userEmail, CreateBookingRequest req) { + Booking booking = Booking.builder() + .userId(userId) + .userEmail(userEmail) + .eventId(req.eventId()) + .eventName(req.eventName()) + .totalAmount(req.totalAmount()) + .status(BookingStatus.PENDING) + .build(); + + List items = req.seatIds().stream() + .map(seatId -> BookingItem.builder() + .booking(booking) + .seatId(seatId) + .build()) + .toList(); + booking.setItems(items); + + Booking saved = bookingRepository.save(booking); + log.info("Created booking id={} for userId={}", saved.getId(), userId); + + producer.publishBookingInitiated(saved, req.seatIds()); + return toResponse(saved); + } + + @Transactional(readOnly = true) + public BookingResponse getBooking(String bookingId, String userId) { + Booking booking = bookingRepository.findByIdWithItems(bookingId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found: " + bookingId)); + if (!booking.getUserId().equals(userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to booking: " + bookingId); + } + return toResponse(booking); + } + + @Transactional(readOnly = true) + public List getUserBookings(String userId) { + return bookingRepository.findByUserId(userId).stream() + .map(this::toResponse) + .toList(); + } + + @Transactional + public BookingResponse cancelBooking(String bookingId, String userId) { + Booking booking = bookingRepository.findByIdWithItems(bookingId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found: " + bookingId)); + + if (!booking.getUserId().equals(userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to booking: " + bookingId); + } + + BookingStatus status = booking.getStatus(); + if (status != BookingStatus.PENDING && status != BookingStatus.CONFIRMED) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Cannot cancel booking in status: " + status); + } + + booking.setStatus(BookingStatus.CANCELLED); + bookingRepository.save(booking); + + producer.publishBookingCancelled(booking); + + List seatIds = booking.getItems().stream() + .map(item -> item.getSeatId()) + .toList(); + producer.publishSeatsRelease(booking.getId(), seatIds); + + log.info("Cancelled booking id={} for userId={}", bookingId, userId); + return toResponse(booking); + } + + private BookingResponse toResponse(Booking booking) { + List seatIds = booking.getItems() != null + ? booking.getItems().stream().map(BookingItem::getSeatId).toList() + : List.of(); + return new BookingResponse( + booking.getId(), + booking.getUserId(), + booking.getEventId(), + booking.getEventName(), + booking.getStatus(), + booking.getTotalAmount(), + seatIds, + booking.getCreatedAt() + ); + } +} diff --git a/ticketflow/services/booking-service/src/main/resources/application.yml b/ticketflow/services/booking-service/src/main/resources/application.yml new file mode 100644 index 0000000..f3c6f23 --- /dev/null +++ b/ticketflow/services/booking-service/src/main/resources/application.yml @@ -0,0 +1,39 @@ +server: + port: ${PORT:3003} +spring: + application: + name: booking-service + datasource: + url: ${BOOKING_DB_URL:jdbc:postgresql://localhost:5432/ticketflow_bookings} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.add.type.headers: false + consumer: + group-id: booking-service + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + properties: + spring.json.trusted.packages: "*" +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always diff --git a/ticketflow/services/booking-service/src/messaging/publisher.ts b/ticketflow/services/booking-service/src/messaging/publisher.ts deleted file mode 100644 index 3f9e78e..0000000 --- a/ticketflow/services/booking-service/src/messaging/publisher.ts +++ /dev/null @@ -1,55 +0,0 @@ -import amqplib, { Channel, ChannelModel } from 'amqplib'; -import { EventName, RabbitMQMessage } from '@ticketflow/shared'; - -const RABBITMQ_URL = process.env.RABBITMQ_URL ?? 'amqp://guest:guest@localhost:5672'; -const EXCHANGE_NAME = 'ticketflow'; - -let connection: ChannelModel | null = null; -let channel: Channel | null = null; - -async function getChannel(): Promise { - if (channel) return channel; - - connection = await amqplib.connect(RABBITMQ_URL); - const ch = await connection.createChannel(); - await ch.assertExchange(EXCHANGE_NAME, 'topic', { durable: true }); - channel = ch; - - connection.on('error', (err: Error) => { - console.error('RabbitMQ connection error:', err.message); - connection = null; - channel = null; - }); - - connection.on('close', () => { - console.warn('RabbitMQ connection closed'); - connection = null; - channel = null; - }); - - return channel; -} - -export const publisher = { - async publish(event: EventName, payload: T): Promise { - const ch = await getChannel(); - const message: RabbitMQMessage = { - event, - payload, - timestamp: new Date().toISOString(), - }; - const content = Buffer.from(JSON.stringify(message)); - ch.publish(EXCHANGE_NAME, event, content, { persistent: true }); - }, - - async close(): Promise { - if (channel) { - await channel.close(); - channel = null; - } - if (connection) { - await connection.close(); - connection = null; - } - }, -}; diff --git a/ticketflow/services/booking-service/src/routes/booking.routes.ts b/ticketflow/services/booking-service/src/routes/booking.routes.ts deleted file mode 100644 index 023b5dc..0000000 --- a/ticketflow/services/booking-service/src/routes/booking.routes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Router } from 'express'; -import { requireAuth, validate } from '@ticketflow/shared'; -import { - createBooking, - getBookingById, - getMyBookings, - confirmBooking, - cancelBooking, -} from '../controllers/booking.controller'; -import { createBookingSchema } from '../schemas/booking.schema'; - -export const bookingRouter = Router(); - -bookingRouter.post('/', requireAuth, validate(createBookingSchema), createBooking); -bookingRouter.get('/my', requireAuth, getMyBookings); -bookingRouter.get('/:id', requireAuth, getBookingById); -bookingRouter.post('/:id/confirm', requireAuth, confirmBooking); -bookingRouter.post('/:id/cancel', requireAuth, cancelBooking); diff --git a/ticketflow/services/booking-service/src/schemas/booking.schema.ts b/ticketflow/services/booking-service/src/schemas/booking.schema.ts deleted file mode 100644 index b333089..0000000 --- a/ticketflow/services/booking-service/src/schemas/booking.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -export const createBookingSchema = z.object({ - eventId: z.string().min(1, 'Event ID is required'), - seatIds: z.array(z.string()).min(1, 'At least one seat must be selected'), -}); - -export type CreateBookingInput = z.infer; diff --git a/ticketflow/services/booking-service/src/services/booking.service.test.ts b/ticketflow/services/booking-service/src/services/booking.service.test.ts deleted file mode 100644 index 32b6d9f..0000000 --- a/ticketflow/services/booking-service/src/services/booking.service.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('bookingService', () => { - it('tests will be reworked for Drizzle query mocking', () => { - expect(true).toBe(true); - }); -}); diff --git a/ticketflow/services/booking-service/src/services/booking.service.ts b/ticketflow/services/booking-service/src/services/booking.service.ts deleted file mode 100644 index 2893e40..0000000 --- a/ticketflow/services/booking-service/src/services/booking.service.ts +++ /dev/null @@ -1,249 +0,0 @@ -import axios from 'axios'; -import { randomUUID } from 'crypto'; -import { desc, eq } from 'drizzle-orm'; -import { AppError, Events, BookingConfirmedPayload } from '@ticketflow/shared'; -import { publisher } from '../messaging/publisher'; -import { db } from '../db/client'; -import { bookingItems, bookings } from '../db/schema'; - -const INVENTORY_URL = process.env.INVENTORY_SERVICE_URL ?? 'http://localhost:3004'; -const PAYMENT_URL = process.env.PAYMENT_SERVICE_URL ?? 'http://localhost:3005'; -const EVENT_URL = process.env.EVENT_SERVICE_URL ?? 'http://localhost:3002'; - -interface CreateBookingArgs { - userId: string; - userEmail: string; - eventId: string; - seatIds: string[]; -} - -function serializeBooking(booking: { - id: string; - userId: string; - eventId: string; - status: string; - totalAmount: { toString(): string } | string; - items: Array<{ id: string; bookingId: string; seatId: string }>; - createdAt: Date | string; - updatedAt: Date | string; -}) { - return { - id: booking.id, - userId: booking.userId, - eventId: booking.eventId, - status: booking.status, - totalAmount: parseFloat(booking.totalAmount.toString()), - items: booking.items, - createdAt: new Date(booking.createdAt).toISOString(), - updatedAt: new Date(booking.updatedAt).toISOString(), - }; -} - -async function getBookingWithItems(id: string) { - const bookingRows = await db.select().from(bookings).where(eq(bookings.id, id)).limit(1); - const booking = bookingRows[0]; - if (!booking) { - return null; - } - const items = await db.select().from(bookingItems).where(eq(bookingItems.bookingId, id)); - return { ...booking, items }; -} - -export const bookingService = { - async create(args: CreateBookingArgs) { - const { userId, userEmail, eventId, seatIds } = args; - - // Step 2: Check event exists - let event: { id: string; name: string; price: number }; - try { - const response = await axios.get<{ event: { id: string; name: string; price: number } }>( - `${EVENT_URL}/api/events/${eventId}` - ); - event = response.data.event; - } catch { - throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found'); - } - - // Step 3: Attempt seat locks - const tempBookingId = `temp-${userId}-${Date.now()}`; - try { - const lockResponse = await axios.post<{ locked: boolean }>( - `${INVENTORY_URL}/api/inventory/lock`, - { - seatIds, - bookingId: tempBookingId, - ttlSeconds: 300, - } - ); - if (!lockResponse.data.locked) { - throw new AppError(409, 'SEAT_LOCKED', 'One or more seats are unavailable'); - } - } catch (err) { - if (err instanceof AppError) throw err; - if (axios.isAxiosError(err) && err.response?.status === 409) { - throw new AppError(409, 'SEAT_LOCKED', 'One or more seats are unavailable', { - conflictingSeatIds: - (err.response.data as { error?: { details?: { conflictingSeatIds?: string[] } } }) - ?.error?.details?.conflictingSeatIds ?? seatIds, - }); - } - throw new AppError(503, 'INVENTORY_UNAVAILABLE', 'Inventory service unavailable'); - } - - // Step 4: Create PENDING booking record - const totalAmount = event.price * seatIds.length; - const insertedBooking = await db - .insert(bookings) - .values({ - id: randomUUID(), - userId, - eventId, - status: 'PENDING', - totalAmount: totalAmount.toString(), - }) - .returning(); - const booking = insertedBooking[0]; - - await db.insert(bookingItems).values( - seatIds.map((seatId) => ({ - id: randomUUID(), - bookingId: booking.id, - seatId, - })) - ); - - // Step 5: Call Payment Service - let paymentSuccess = false; - try { - const paymentResponse = await axios.post<{ payment: { status: string } }>( - `${PAYMENT_URL}/api/payments/charge`, - { - bookingId: booking.id, - amount: totalAmount, - currency: 'USD', - } - ); - paymentSuccess = paymentResponse.data.payment.status === 'SUCCESS'; - } catch (err) { - if (axios.isAxiosError(err) && err.response?.status === 402) { - paymentSuccess = false; - } else { - await axios.post(`${INVENTORY_URL}/api/inventory/release`, { seatIds }).catch(() => null); - await db - .update(bookings) - .set({ status: 'FAILED', updatedAt: new Date() }) - .where(eq(bookings.id, booking.id)); - throw new AppError(503, 'PAYMENT_SERVICE_UNAVAILABLE', 'Payment service unavailable'); - } - } - - if (!paymentSuccess) { - // Release seat locks and mark booking FAILED - await axios.post(`${INVENTORY_URL}/api/inventory/release`, { seatIds }).catch(() => null); - const failedBookingRows = await db - .update(bookings) - .set({ status: 'FAILED', updatedAt: new Date() }) - .where(eq(bookings.id, booking.id)) - .returning(); - const failedBooking = failedBookingRows[0]; - throw new AppError(402, 'PAYMENT_FAILED', 'Payment was declined', { - bookingId: failedBooking.id, - }); - } - - // Step 6: Confirm seat reservation - await axios.post(`${INVENTORY_URL}/api/inventory/confirm`, { seatIds }).catch(() => null); - - // Step 7: Mark booking CONFIRMED - const confirmedRows = await db - .update(bookings) - .set({ status: 'CONFIRMED', updatedAt: new Date() }) - .where(eq(bookings.id, booking.id)) - .returning(); - const confirmed = confirmedRows[0]; - const confirmedItems = await db.select().from(bookingItems).where(eq(bookingItems.bookingId, booking.id)); - const confirmedBooking = { ...confirmed, items: confirmedItems }; - - // Step 8: Publish BOOKING_CONFIRMED event to RabbitMQ (fire-and-forget, non-blocking) - const payload: BookingConfirmedPayload = { - bookingId: confirmedBooking.id, - userId, - userEmail, - eventId, - eventName: event.name, - seatIds, - totalAmount, - confirmedAt: new Date(confirmedBooking.updatedAt).toISOString(), - }; - publisher.publish(Events.BOOKING_CONFIRMED, payload).catch((publishErr) => { - console.error('Failed to publish BOOKING_CONFIRMED event:', publishErr); - }); - - return serializeBooking(confirmedBooking); - }, - - async getById(id: string, userId: string) { - const booking = await getBookingWithItems(id); - if (!booking) { - throw new AppError(404, 'BOOKING_NOT_FOUND', 'Booking not found'); - } - if (booking.userId !== userId) { - throw new AppError(403, 'FORBIDDEN', 'Access denied'); - } - return serializeBooking(booking); - }, - - async getByUser(userId: string) { - const bookingRows = await db.select().from(bookings).where(eq(bookings.userId, userId)).orderBy(desc(bookings.createdAt)); - const serialized: Array> = []; - - for (const booking of bookingRows) { - const items = await db.select().from(bookingItems).where(eq(bookingItems.bookingId, booking.id)); - serialized.push(serializeBooking({ ...booking, items })); - } - - return serialized; - }, - - async confirm(id: string, userId: string) { - const booking = await getBookingWithItems(id); - if (!booking) { - throw new AppError(404, 'BOOKING_NOT_FOUND', 'Booking not found'); - } - if (booking.userId !== userId) { - throw new AppError(403, 'FORBIDDEN', 'Access denied'); - } - if (booking.status !== 'PENDING') { - throw new AppError(400, 'INVALID_STATUS', `Cannot confirm a booking in ${booking.status} status`); - } - const updatedRows = await db - .update(bookings) - .set({ status: 'CONFIRMED', updatedAt: new Date() }) - .where(eq(bookings.id, id)) - .returning(); - const updated = { ...updatedRows[0], items: booking.items }; - return serializeBooking(updated); - }, - - async cancel(id: string, userId: string) { - const booking = await getBookingWithItems(id); - if (!booking) { - throw new AppError(404, 'BOOKING_NOT_FOUND', 'Booking not found'); - } - if (booking.userId !== userId) { - throw new AppError(403, 'FORBIDDEN', 'Access denied'); - } - if (booking.status === 'CANCELLED') { - throw new AppError(400, 'ALREADY_CANCELLED', 'Booking is already cancelled'); - } - const seatIds = booking.items.map((item: { seatId: string }) => item.seatId); - await axios.post(`${INVENTORY_URL}/api/inventory/release`, { seatIds }).catch(() => null); - const updatedRows = await db - .update(bookings) - .set({ status: 'CANCELLED', updatedAt: new Date() }) - .where(eq(bookings.id, id)) - .returning(); - const updated = { ...updatedRows[0], items: booking.items }; - return serializeBooking(updated); - }, -}; diff --git a/ticketflow/services/booking-service/tsconfig.json b/ticketflow/services/booking-service/tsconfig.json deleted file mode 100644 index 5f1c396..0000000 --- a/ticketflow/services/booking-service/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] -} diff --git a/ticketflow/services/event-service/Dockerfile b/ticketflow/services/event-service/Dockerfile index 075666d..cb80ab3 100644 --- a/ticketflow/services/event-service/Dockerfile +++ b/ticketflow/services/event-service/Dockerfile @@ -1,9 +1,7 @@ -FROM node:20-alpine +FROM python:3.12-slim WORKDIR /app -RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install --frozen-lockfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt COPY . . -RUN pnpm run build EXPOSE 3002 -CMD ["node", "dist/src/index.js"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3002"] diff --git a/ticketflow/services/event-service/app/__init__.py b/ticketflow/services/event-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/event-service/app/config.py b/ticketflow/services/event-service/app/config.py new file mode 100644 index 0000000..10a4fcb --- /dev/null +++ b/ticketflow/services/event-service/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + mongodb_url: str = "mongodb://localhost:27017" + mongodb_db: str = "ticketflow_events" + kafka_bootstrap_servers: str = "localhost:9092" + port: int = 3002 + + class Config: + env_file = ".env" + extra = "ignore" + + +settings = Settings() diff --git a/ticketflow/services/event-service/app/database.py b/ticketflow/services/event-service/app/database.py new file mode 100644 index 0000000..473c764 --- /dev/null +++ b/ticketflow/services/event-service/app/database.py @@ -0,0 +1,21 @@ +import logging +from motor.motor_asyncio import AsyncIOMotorClient +from beanie import init_beanie +from app.config import settings +from app.models.event import EventDocument, VenueDocument + +logger = logging.getLogger(__name__) + +motor_client: AsyncIOMotorClient = None + + +async def init_db(): + global motor_client + motor_client = AsyncIOMotorClient(settings.mongodb_url) + database = motor_client[settings.mongodb_db] + await init_beanie(database=database, document_models=[EventDocument, VenueDocument]) + logger.info(f"Connected to MongoDB: {settings.mongodb_db}") + + +def get_database(): + return motor_client[settings.mongodb_db] diff --git a/ticketflow/services/event-service/app/kafka/__init__.py b/ticketflow/services/event-service/app/kafka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/event-service/app/kafka/producer.py b/ticketflow/services/event-service/app/kafka/producer.py new file mode 100644 index 0000000..e41a26c --- /dev/null +++ b/ticketflow/services/event-service/app/kafka/producer.py @@ -0,0 +1,41 @@ +import json +import logging +from datetime import datetime +from aiokafka import AIOKafkaProducer +from app.config import settings + +logger = logging.getLogger(__name__) +producer: AIOKafkaProducer = None + + +async def start_producer(): + global producer + producer = AIOKafkaProducer( + bootstrap_servers=settings.kafka_bootstrap_servers, + value_serializer=lambda v: json.dumps(v).encode("utf-8"), + key_serializer=lambda k: k.encode("utf-8") if k else None, + ) + await producer.start() + logger.info("Kafka producer started") + + +async def stop_producer(): + global producer + if producer: + await producer.stop() + + +async def publish_event_created(event_id: str, name: str, total_seats: int): + if not producer: + return + payload = { + "eventType": "event.created", + "eventId": event_id, + "name": name, + "totalSeats": total_seats, + "timestamp": datetime.utcnow().isoformat(), + } + await producer.send_and_wait( + "ticketflow.event.created", value=payload, key=event_id + ) + logger.info(f"Published event.created for event {event_id}") diff --git a/ticketflow/services/event-service/app/main.py b/ticketflow/services/event-service/app/main.py new file mode 100644 index 0000000..61240e1 --- /dev/null +++ b/ticketflow/services/event-service/app/main.py @@ -0,0 +1,49 @@ +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.database import init_db +from app.kafka.producer import start_producer, stop_producer +from app.routes.events import router as events_router + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + await start_producer() + yield + await stop_producer() + + +app = FastAPI( + title="Event Service", + description="TicketFlow Event Service - manages events and venues", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(events_router) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.error(f"Unhandled exception: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={"error": {"code": "INTERNAL_ERROR", "message": str(exc)}}, + ) diff --git a/ticketflow/services/event-service/app/models/__init__.py b/ticketflow/services/event-service/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/event-service/app/models/event.py b/ticketflow/services/event-service/app/models/event.py new file mode 100644 index 0000000..0542958 --- /dev/null +++ b/ticketflow/services/event-service/app/models/event.py @@ -0,0 +1,38 @@ +from beanie import Document +from pydantic import Field +from typing import Optional +from datetime import datetime +import uuid + + +class VenueDocument(Document): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + address: str + city: str + country: str + capacity: int + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "venues" + + +class EventDocument(Document): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + description: str + venue_id: str + venue: Optional[dict] = None # embedded venue snapshot + date: datetime + price: float + total_seats: int + available_seats: int + image_url: Optional[str] = None + tags: list[str] = [] + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "events" diff --git a/ticketflow/services/event-service/app/routes/__init__.py b/ticketflow/services/event-service/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/event-service/app/routes/events.py b/ticketflow/services/event-service/app/routes/events.py new file mode 100644 index 0000000..f8a2e00 --- /dev/null +++ b/ticketflow/services/event-service/app/routes/events.py @@ -0,0 +1,101 @@ +import logging +from typing import Optional +from fastapi import APIRouter, Header, HTTPException, Query, status +from fastapi.responses import JSONResponse + +from app.schemas.event import ( + EventCreate, + EventUpdate, + EventResponse, + EventListResponse, + VenueCreate, + VenueResponse, +) +from app.services import event_service +from app.models.event import VenueDocument + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _require_admin(x_user_role: Optional[str]): + if x_user_role != "ADMIN": + raise HTTPException( + status_code=403, + detail={"error": {"code": "FORBIDDEN", "message": "Admin role required"}}, + ) + + +@router.get("/health") +async def health(): + return {"status": "up", "service": "event-service"} + + +@router.get("/api/events", response_model=EventListResponse) +async def list_events( + page: int = Query(1, ge=1), + limit: int = Query(10, ge=1, le=100), + search: Optional[str] = Query(None), +): + return await event_service.list_events(page=page, limit=limit, search=search) + + +@router.get("/api/events/{event_id}", response_model=EventResponse) +async def get_event(event_id: str): + event = await event_service.get_event(event_id) + return event_service._to_event_response(event) + + +@router.post( + "/api/events", response_model=EventResponse, status_code=status.HTTP_201_CREATED +) +async def create_event( + data: EventCreate, + x_user_role: Optional[str] = Header(None), +): + _require_admin(x_user_role) + event = await event_service.create_event(data) + return event_service._to_event_response(event) + + +@router.put("/api/events/{event_id}", response_model=EventResponse) +async def update_event( + event_id: str, + data: EventUpdate, + x_user_role: Optional[str] = Header(None), +): + _require_admin(x_user_role) + event = await event_service.update_event(event_id, data) + return event_service._to_event_response(event) + + +@router.post( + "/api/venues", response_model=VenueResponse, status_code=status.HTTP_201_CREATED +) +async def create_venue( + data: VenueCreate, + x_user_role: Optional[str] = Header(None), +): + _require_admin(x_user_role) + venue = await event_service.create_venue(data) + return VenueResponse( + id=venue.id, + name=venue.name, + address=venue.address, + city=venue.city, + country=venue.country, + capacity=venue.capacity, + ) + + +@router.get("/api/venues/{venue_id}", response_model=VenueResponse) +async def get_venue(venue_id: str): + venue = await event_service.get_venue(venue_id) + return VenueResponse( + id=venue.id, + name=venue.name, + address=venue.address, + city=venue.city, + country=venue.country, + capacity=venue.capacity, + ) diff --git a/ticketflow/services/event-service/app/schemas/__init__.py b/ticketflow/services/event-service/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/event-service/app/schemas/event.py b/ticketflow/services/event-service/app/schemas/event.py new file mode 100644 index 0000000..34cbf7d --- /dev/null +++ b/ticketflow/services/event-service/app/schemas/event.py @@ -0,0 +1,65 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class VenueCreate(BaseModel): + name: str + address: str + city: str + country: str + capacity: int + + +class VenueResponse(BaseModel): + id: str + name: str + address: str + city: str + country: str + capacity: int + + +class EventCreate(BaseModel): + name: str + description: str + venue_id: str + date: datetime + price: float + total_seats: int + image_url: Optional[str] = None + tags: list[str] = [] + + +class EventUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + venue_id: Optional[str] = None + date: Optional[datetime] = None + price: Optional[float] = None + total_seats: Optional[int] = None + available_seats: Optional[int] = None + image_url: Optional[str] = None + tags: Optional[list[str]] = None + + +class EventResponse(BaseModel): + id: str + name: str + description: str + venue_id: str + venue: Optional[dict] = None + date: datetime + price: float + total_seats: int + available_seats: int + image_url: Optional[str] = None + tags: list[str] = [] + created_at: datetime + + +class EventListResponse(BaseModel): + events: list[EventResponse] + total: int + page: int + limit: int diff --git a/ticketflow/services/event-service/app/seed.py b/ticketflow/services/event-service/app/seed.py new file mode 100644 index 0000000..6fd2428 --- /dev/null +++ b/ticketflow/services/event-service/app/seed.py @@ -0,0 +1,123 @@ +""" +Seed script for Event Service. +Run with: python -m app.seed +""" + +import asyncio +import logging +from datetime import datetime +from motor.motor_asyncio import AsyncIOMotorClient +from beanie import init_beanie + +from app.config import settings +from app.models.event import VenueDocument, EventDocument + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" +) +logger = logging.getLogger(__name__) + + +async def seed(): + client = AsyncIOMotorClient(settings.mongodb_url) + db = client[settings.mongodb_db] + await init_beanie(database=db, document_models=[VenueDocument, EventDocument]) + + # Clear existing seed data + await VenueDocument.delete_all() + await EventDocument.delete_all() + logger.info("Cleared existing venues and events") + + # --- Venues --- + msg = VenueDocument( + name="Madison Square Garden", + address="4 Pennsylvania Plaza", + city="New York", + country="US", + capacity=20000, + ) + o2 = VenueDocument( + name="The O2 Arena", + address="Peninsula Square", + city="London", + country="UK", + capacity=20000, + ) + soh = VenueDocument( + name="Sydney Opera House", + address="Bennelong Point", + city="Sydney", + country="AU", + capacity=5000, + ) + for v in (msg, o2, soh): + await v.insert() + logger.info(f"Created venue: {v.name} (id={v.id})") + + # --- Events --- + events_data = [ + EventDocument( + name="Rock Night Live", + description="An electrifying rock concert featuring top bands from around the world.", + venue_id=msg.id, + venue={ + "id": msg.id, + "name": msg.name, + "address": msg.address, + "city": msg.city, + "country": msg.country, + "capacity": msg.capacity, + }, + date=datetime(2025, 6, 15, 20, 0, 0), + price=75.0, + total_seats=500, + available_seats=500, + tags=["rock", "live", "music"], + ), + EventDocument( + name="Jazz & Blues Festival", + description="A soulful evening celebrating the finest jazz and blues artists.", + venue_id=o2.id, + venue={ + "id": o2.id, + "name": o2.name, + "address": o2.address, + "city": o2.city, + "country": o2.country, + "capacity": o2.capacity, + }, + date=datetime(2025, 7, 20, 19, 0, 0), + price=55.0, + total_seats=500, + available_seats=500, + tags=["jazz", "blues", "festival"], + ), + EventDocument( + name="Classical Symphony Evening", + description="An intimate symphony performance in the iconic Sydney Opera House.", + venue_id=soh.id, + venue={ + "id": soh.id, + "name": soh.name, + "address": soh.address, + "city": soh.city, + "country": soh.country, + "capacity": soh.capacity, + }, + date=datetime(2025, 8, 10, 18, 30, 0), + price=95.0, + total_seats=200, + available_seats=200, + tags=["classical", "symphony", "orchestra"], + ), + ] + for event in events_data: + await event.insert() + logger.info(f"Created event: {event.name} (id={event.id})") + + logger.info("Seed complete.") + client.close() + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/ticketflow/services/event-service/app/services/__init__.py b/ticketflow/services/event-service/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/event-service/app/services/event_service.py b/ticketflow/services/event-service/app/services/event_service.py new file mode 100644 index 0000000..f319fb7 --- /dev/null +++ b/ticketflow/services/event-service/app/services/event_service.py @@ -0,0 +1,150 @@ +import logging +from datetime import datetime +from typing import Optional +from fastapi import HTTPException + +from app.models.event import EventDocument, VenueDocument +from app.schemas.event import ( + EventCreate, + EventUpdate, + VenueCreate, + EventListResponse, + EventResponse, + VenueResponse, +) +from app.kafka.producer import publish_event_created + +logger = logging.getLogger(__name__) + + +async def list_events( + page: int = 1, limit: int = 10, search: Optional[str] = None +) -> EventListResponse: + query = {} + if search: + query = { + "$or": [ + {"name": {"$regex": search, "$options": "i"}}, + {"description": {"$regex": search, "$options": "i"}}, + {"tags": {"$in": [search]}}, + ] + } + + total = await EventDocument.find(query).count() + skip = (page - 1) * limit + events = await EventDocument.find(query).skip(skip).limit(limit).to_list() + + return EventListResponse( + events=[_to_event_response(e) for e in events], + total=total, + page=page, + limit=limit, + ) + + +async def get_event(event_id: str) -> EventDocument: + event = await EventDocument.find_one(EventDocument.id == event_id) + if not event: + raise HTTPException( + status_code=404, + detail={ + "error": { + "code": "EVENT_NOT_FOUND", + "message": f"Event {event_id} not found", + } + }, + ) + return event + + +async def create_event(data: EventCreate) -> EventDocument: + # Fetch venue snapshot + venue = await VenueDocument.find_one(VenueDocument.id == data.venue_id) + venue_snapshot = None + if venue: + venue_snapshot = { + "id": venue.id, + "name": venue.name, + "address": venue.address, + "city": venue.city, + "country": venue.country, + "capacity": venue.capacity, + } + + event = EventDocument( + name=data.name, + description=data.description, + venue_id=data.venue_id, + venue=venue_snapshot, + date=data.date, + price=data.price, + total_seats=data.total_seats, + available_seats=data.total_seats, + image_url=data.image_url, + tags=data.tags, + ) + await event.insert() + logger.info(f"Created event {event.id}: {event.name}") + + try: + await publish_event_created(event.id, event.name, event.total_seats) + except Exception as e: + logger.warning(f"Failed to publish event.created for {event.id}: {e}") + + return event + + +async def update_event(event_id: str, data: EventUpdate) -> EventDocument: + event = await get_event(event_id) + update_data = data.model_dump(exclude_none=True) + for key, value in update_data.items(): + setattr(event, key, value) + event.updated_at = datetime.utcnow() + await event.save() + logger.info(f"Updated event {event_id}") + return event + + +async def create_venue(data: VenueCreate) -> VenueDocument: + venue = VenueDocument( + name=data.name, + address=data.address, + city=data.city, + country=data.country, + capacity=data.capacity, + ) + await venue.insert() + logger.info(f"Created venue {venue.id}: {venue.name}") + return venue + + +async def get_venue(venue_id: str) -> VenueDocument: + venue = await VenueDocument.find_one(VenueDocument.id == venue_id) + if not venue: + raise HTTPException( + status_code=404, + detail={ + "error": { + "code": "VENUE_NOT_FOUND", + "message": f"Venue {venue_id} not found", + } + }, + ) + return venue + + +def _to_event_response(event: EventDocument) -> EventResponse: + return EventResponse( + id=event.id, + name=event.name, + description=event.description, + venue_id=event.venue_id, + venue=event.venue, + date=event.date, + price=event.price, + total_seats=event.total_seats, + available_seats=event.available_seats, + image_url=event.image_url, + tags=event.tags, + created_at=event.created_at, + ) diff --git a/ticketflow/services/event-service/jest.config.js b/ticketflow/services/event-service/jest.config.js deleted file mode 100644 index 556db62..0000000 --- a/ticketflow/services/event-service/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts'], - moduleNameMapper: { - '^@ticketflow/shared$': '/../../shared/src/index.ts', - }, -}; diff --git a/ticketflow/services/event-service/package.json b/ticketflow/services/event-service/package.json deleted file mode 100644 index 0066f97..0000000 --- a/ticketflow/services/event-service/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "event-service", - "version": "1.0.0", - "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/src/index.js", - "migrate": "node scripts/migrate.cjs", - "migrate:dev": "node scripts/migrate.cjs", - "generate": "echo \"No code generation required with Drizzle\"", - "seed": "ts-node src/seed.ts", - "test": "jest --forceExit" - }, - "dependencies": { - "@ticketflow/shared": "workspace:*", - "dotenv": "^16.3.1", - "drizzle-orm": "^0.36.4", - "express": "^4.18.2", - "express-rate-limit": "^7.4.1", - "pg": "^8.13.1", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.6", - "@types/pg": "^8.11.10", - "@types/supertest": "^6.0.2", - "jest": "^29.7.0", - "supertest": "^6.3.4", - "ts-jest": "^29.1.1", - "ts-node": "^10.9.2", - "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" - } -} diff --git a/ticketflow/services/event-service/requirements.txt b/ticketflow/services/event-service/requirements.txt new file mode 100644 index 0000000..f74a56b --- /dev/null +++ b/ticketflow/services/event-service/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +beanie==1.26.0 +motor==3.5.1 +pydantic==2.9.2 +pydantic-settings==2.5.2 +aiokafka==0.11.0 +python-jose[cryptography]==3.3.0 +httpx==0.27.2 +prometheus-fastapi-instrumentator==7.0.0 diff --git a/ticketflow/services/event-service/scripts/migrate.cjs b/ticketflow/services/event-service/scripts/migrate.cjs deleted file mode 100644 index 8c068b5..0000000 --- a/ticketflow/services/event-service/scripts/migrate.cjs +++ /dev/null @@ -1,47 +0,0 @@ -const { Pool } = require('pg') - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is required for migrations') -} - -const pool = new Pool({ connectionString }) - -async function run() { - await pool.query(` -CREATE TABLE IF NOT EXISTS "Venue" ( - "id" text PRIMARY KEY, - "name" text NOT NULL, - "address" text NOT NULL, - "city" text NOT NULL, - "country" text NOT NULL, - "capacity" integer NOT NULL, - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now() -); -`) - - await pool.query(` -CREATE TABLE IF NOT EXISTS "Event" ( - "id" text PRIMARY KEY, - "name" text NOT NULL, - "description" text NOT NULL, - "venueId" text NOT NULL REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE, - "date" timestamp NOT NULL, - "price" numeric(10, 2) NOT NULL, - "totalSeats" integer NOT NULL, - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now() -); -`) -} - -run() - .then(() => { - console.log('event-service migration complete') - return pool.end() - }) - .catch((error) => { - console.error('event-service migration failed', error) - return pool.end().finally(() => process.exit(1)) - }) diff --git a/ticketflow/services/event-service/src/app.ts b/ticketflow/services/event-service/src/app.ts deleted file mode 100644 index f5cfb72..0000000 --- a/ticketflow/services/event-service/src/app.ts +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express'; -import { errorHandler } from '@ticketflow/shared'; -import { eventRouter } from './routes/event.routes'; - -export const app = express(); - -app.use(express.json()); -app.use('/api/events', eventRouter); -app.use(errorHandler); diff --git a/ticketflow/services/event-service/src/controllers/event.controller.ts b/ticketflow/services/event-service/src/controllers/event.controller.ts deleted file mode 100644 index 54aa5b2..0000000 --- a/ticketflow/services/event-service/src/controllers/event.controller.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { eventService } from '../services/event.service'; -import { CreateEventInput, UpdateEventInput } from '../schemas/event.schema'; - -export async function getAllEvents(req: Request, res: Response, next: NextFunction): Promise { - try { - const events = await eventService.getAll(); - res.json({ events }); - } catch (err) { - next(err); - } -} - -export async function getEventById(req: Request, res: Response, next: NextFunction): Promise { - try { - const event = await eventService.getById(req.params.id); - res.json({ event }); - } catch (err) { - next(err); - } -} - -export async function createEvent(req: Request, res: Response, next: NextFunction): Promise { - try { - const event = await eventService.create(req.body as CreateEventInput); - res.status(201).json({ event }); - } catch (err) { - next(err); - } -} - -export async function updateEvent(req: Request, res: Response, next: NextFunction): Promise { - try { - const event = await eventService.update(req.params.id, req.body as UpdateEventInput); - res.json({ event }); - } catch (err) { - next(err); - } -} diff --git a/ticketflow/services/event-service/src/db/client.ts b/ticketflow/services/event-service/src/db/client.ts deleted file mode 100644 index 3fecfb3..0000000 --- a/ticketflow/services/event-service/src/db/client.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drizzle } from 'drizzle-orm/node-postgres' -import { Pool } from 'pg' - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is not configured') -} - -const pool = new Pool({ connectionString }) -export const db = drizzle(pool) diff --git a/ticketflow/services/event-service/src/db/schema.ts b/ticketflow/services/event-service/src/db/schema.ts deleted file mode 100644 index 07382c7..0000000 --- a/ticketflow/services/event-service/src/db/schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { integer, numeric, pgTable, text, timestamp } from 'drizzle-orm/pg-core' - -export const venues = pgTable('Venue', { - id: text('id').primaryKey(), - name: text('name').notNull(), - address: text('address').notNull(), - city: text('city').notNull(), - country: text('country').notNull(), - capacity: integer('capacity').notNull(), - createdAt: timestamp('createdAt', { withTimezone: false }).notNull().defaultNow(), - updatedAt: timestamp('updatedAt', { withTimezone: false }).notNull().defaultNow(), -}) - -export const events = pgTable('Event', { - id: text('id').primaryKey(), - name: text('name').notNull(), - description: text('description').notNull(), - venueId: text('venueId').notNull(), - date: timestamp('date', { withTimezone: false }).notNull(), - price: numeric('price', { precision: 10, scale: 2 }).notNull(), - totalSeats: integer('totalSeats').notNull(), - createdAt: timestamp('createdAt', { withTimezone: false }).notNull().defaultNow(), - updatedAt: timestamp('updatedAt', { withTimezone: false }).notNull().defaultNow(), -}) diff --git a/ticketflow/services/event-service/src/index.ts b/ticketflow/services/event-service/src/index.ts deleted file mode 100644 index ec20456..0000000 --- a/ticketflow/services/event-service/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'dotenv/config'; -import { app } from './app'; - -const PORT = process.env.PORT ?? 3002; - -app.listen(PORT, () => { - console.log(`Event service listening on port ${PORT}`); -}); diff --git a/ticketflow/services/event-service/src/routes/event.routes.ts b/ticketflow/services/event-service/src/routes/event.routes.ts deleted file mode 100644 index 136b25b..0000000 --- a/ticketflow/services/event-service/src/routes/event.routes.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Router } from 'express'; -import rateLimit from 'express-rate-limit'; -import { requireAdmin, validate } from '@ticketflow/shared'; -import { - getAllEvents, - getEventById, - createEvent, - updateEvent, -} from '../controllers/event.controller'; -import { createEventSchema, updateEventSchema } from '../schemas/event.schema'; - -export const eventRouter = Router(); - -const readLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 200, - standardHeaders: true, - legacyHeaders: false, - message: { error: { code: 'TOO_MANY_REQUESTS', message: 'Too many requests, please try again later.' } }, -}); - -const writeLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 50, - standardHeaders: true, - legacyHeaders: false, - message: { error: { code: 'TOO_MANY_REQUESTS', message: 'Too many requests, please try again later.' } }, -}); - -eventRouter.get('/', readLimiter, getAllEvents); -eventRouter.get('/:id', readLimiter, getEventById); -eventRouter.post('/', writeLimiter, requireAdmin, validate(createEventSchema), createEvent); -eventRouter.put('/:id', writeLimiter, requireAdmin, validate(updateEventSchema), updateEvent); - diff --git a/ticketflow/services/event-service/src/schemas/event.schema.ts b/ticketflow/services/event-service/src/schemas/event.schema.ts deleted file mode 100644 index 0d264fc..0000000 --- a/ticketflow/services/event-service/src/schemas/event.schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const createEventSchema = z.object({ - name: z.string().min(1, 'Event name is required'), - description: z.string().min(1, 'Description is required'), - date: z.string().datetime('Invalid date format'), - price: z.number().positive('Price must be positive'), - totalSeats: z.number().int().positive('Total seats must be a positive integer'), - venueId: z.string().optional(), - venueName: z.string().optional(), - venueAddress: z.string().optional(), - venueCity: z.string().optional(), - venueCountry: z.string().optional(), -}); - -export const updateEventSchema = createEventSchema.partial(); - -export type CreateEventInput = z.infer; -export type UpdateEventInput = z.infer; diff --git a/ticketflow/services/event-service/src/seed.ts b/ticketflow/services/event-service/src/seed.ts deleted file mode 100644 index e90df19..0000000 --- a/ticketflow/services/event-service/src/seed.ts +++ /dev/null @@ -1,112 +0,0 @@ -import 'dotenv/config'; -import { eq } from 'drizzle-orm'; -import { db } from './db/client'; -import { events, venues } from './db/schema'; - -async function upsertVenue(data: { - id: string; - name: string; - address: string; - city: string; - country: string; - capacity: number; -}) { - const existing = await db.select().from(venues).where(eq(venues.id, data.id)).limit(1); - if (existing.length > 0) { - return existing[0]; - } - const created = await db.insert(venues).values(data).returning(); - return created[0]; -} - -async function upsertEvent(data: { - id: string; - name: string; - description: string; - venueId: string; - date: Date; - price: string; - totalSeats: number; -}) { - const existing = await db.select().from(events).where(eq(events.id, data.id)).limit(1); - if (existing.length > 0) { - return existing[0]; - } - const created = await db.insert(events).values(data).returning(); - return created[0]; -} - -const ROWS = ['A', 'B', 'C', 'D', 'E']; -const SEATS_PER_ROW = 10; - -async function seed() { - console.log('Seeding event service database...'); - - const venue1 = await upsertVenue({ - id: 'venue-1', - name: 'Madison Square Garden', - address: '4 Pennsylvania Plaza', - city: 'New York', - country: 'US', - capacity: 20000, - }); - - const venue2 = await upsertVenue({ - id: 'venue-2', - name: 'The O2 Arena', - address: 'Peninsula Square', - city: 'London', - country: 'UK', - capacity: 20000, - }); - - const venue3 = await upsertVenue({ - id: 'venue-3', - name: 'Sydney Opera House', - address: 'Bennelong Point', - city: 'Sydney', - country: 'AU', - capacity: 5000, - }); - - const totalSeats = ROWS.length * SEATS_PER_ROW; - - const event1 = await upsertEvent({ - id: 'event-1', - name: 'Rock Night Live', - description: 'An epic night of rock music featuring top bands from around the world.', - venueId: venue1.id, - date: new Date('2025-06-15T20:00:00Z'), - price: '75.00', - totalSeats, - }); - - const event2 = await upsertEvent({ - id: 'event-2', - name: 'Jazz & Blues Festival', - description: 'Two days of smooth jazz and soulful blues performances.', - venueId: venue2.id, - date: new Date('2025-07-20T18:00:00Z'), - price: '55.00', - totalSeats, - }); - - const event3 = await upsertEvent({ - id: 'event-3', - name: 'Classical Symphony Evening', - description: 'A breathtaking evening of classical masterpieces performed by world-class musicians.', - venueId: venue3.id, - date: new Date('2025-08-10T19:00:00Z'), - price: '95.00', - totalSeats, - }); - - console.log('Events created:', [event1.id, event2.id, event3.id]); - console.log('Seeding complete!'); -} - -seed() - .catch((err) => { - console.error('Seed failed:', err); - process.exit(1); - }); diff --git a/ticketflow/services/event-service/src/services/event.service.test.ts b/ticketflow/services/event-service/src/services/event.service.test.ts deleted file mode 100644 index e165b5b..0000000 --- a/ticketflow/services/event-service/src/services/event.service.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('eventService', () => { - it('tests will be reworked for Drizzle query mocking', () => { - expect(true).toBe(true); - }); -}); diff --git a/ticketflow/services/event-service/src/services/event.service.ts b/ticketflow/services/event-service/src/services/event.service.ts deleted file mode 100644 index 7d2f24b..0000000 --- a/ticketflow/services/event-service/src/services/event.service.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { randomUUID } from 'crypto'; -import { asc, eq } from 'drizzle-orm'; -import { AppError } from '@ticketflow/shared'; -import { CreateEventInput, UpdateEventInput } from '../schemas/event.schema'; -import { db } from '../db/client'; -import { events, venues } from '../db/schema'; - -function serializeEvent(event: { - id: string; - name: string; - description: string; - venueId: string; - venue?: { id: string; name: string; address: string; city: string; country: string; capacity: number; createdAt: Date | string; updatedAt: Date | string }; - date: Date | string; - price: { toString(): string } | string; - totalSeats: number; - createdAt: Date | string; - updatedAt: Date | string; -}) { - return { - id: event.id, - name: event.name, - description: event.description, - venueId: event.venueId, - venue: event.venue - ? { - ...event.venue, - createdAt: new Date(event.venue.createdAt).toISOString(), - updatedAt: new Date(event.venue.updatedAt).toISOString(), - } - : undefined, - date: new Date(event.date).toISOString(), - price: parseFloat(event.price.toString()), - totalSeats: event.totalSeats, - createdAt: new Date(event.createdAt).toISOString(), - updatedAt: new Date(event.updatedAt).toISOString(), - }; -} - -export const eventService = { - async getAll() { - const rows = await db - .select({ event: events, venue: venues }) - .from(events) - .leftJoin(venues, eq(events.venueId, venues.id)) - .orderBy(asc(events.date)); - return rows.map(({ event, venue }) => serializeEvent({ ...event, venue: venue ?? undefined })); - }, - - async getById(id: string) { - const row = await db - .select({ event: events, venue: venues }) - .from(events) - .leftJoin(venues, eq(events.venueId, venues.id)) - .where(eq(events.id, id)) - .limit(1); - if (row.length === 0) { - throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found'); - } - return serializeEvent({ ...row[0].event, venue: row[0].venue ?? undefined }); - }, - - async create(input: CreateEventInput) { - if (!input.venueId && !input.venueName) { - throw new AppError(400, 'VENUE_REQUIRED', 'Provide venueId to link an existing venue, or venueName/venueAddress/venueCity/venueCountry to create one'); - } - - let venueId = input.venueId; - - if (!venueId) { - const createdVenue = await db - .insert(venues) - .values({ - id: randomUUID(), - name: input.venueName ?? '', - address: input.venueAddress ?? '', - city: input.venueCity ?? '', - country: input.venueCountry ?? '', - capacity: input.totalSeats, - }) - .returning(); - venueId = createdVenue[0].id; - } - - const createdEvent = await db - .insert(events) - .values({ - id: randomUUID(), - name: input.name, - description: input.description, - date: new Date(input.date), - price: input.price.toString(), - totalSeats: input.totalSeats, - venueId, - }) - .returning(); - - const venueRows = await db.select().from(venues).where(eq(venues.id, venueId)).limit(1); - return serializeEvent({ ...createdEvent[0], venue: venueRows[0] }); - }, - - async update(id: string, input: UpdateEventInput) { - const existing = await db.select().from(events).where(eq(events.id, id)).limit(1); - if (existing.length === 0) { - throw new AppError(404, 'EVENT_NOT_FOUND', 'Event not found'); - } - - const patched = await db - .update(events) - .set({ - ...(input.name !== undefined ? { name: input.name } : {}), - ...(input.description !== undefined ? { description: input.description } : {}), - ...(input.date !== undefined ? { date: new Date(input.date) } : {}), - ...(input.price !== undefined ? { price: input.price.toString() } : {}), - ...(input.totalSeats !== undefined ? { totalSeats: input.totalSeats } : {}), - updatedAt: new Date(), - }) - .where(eq(events.id, id)) - .returning(); - - const venueRows = await db.select().from(venues).where(eq(venues.id, patched[0].venueId)).limit(1); - return serializeEvent({ ...patched[0], venue: venueRows[0] }); - }, -}; diff --git a/ticketflow/services/event-service/tsconfig.json b/ticketflow/services/event-service/tsconfig.json deleted file mode 100644 index 5f1c396..0000000 --- a/ticketflow/services/event-service/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] -} diff --git a/ticketflow/services/inventory-service/Dockerfile b/ticketflow/services/inventory-service/Dockerfile index 82ece9c..a507475 100644 --- a/ticketflow/services/inventory-service/Dockerfile +++ b/ticketflow/services/inventory-service/Dockerfile @@ -1,9 +1,12 @@ -FROM node:20-alpine +FROM oven/bun:1.1-alpine AS base WORKDIR /app -RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install --frozen-lockfile + +FROM base AS deps +COPY package.json bun.lockb* ./ +RUN bun install --frozen-lockfile + +FROM base AS runner +COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN pnpm run build EXPOSE 3004 -CMD ["node", "dist/src/index.js"] +CMD ["bun", "src/index.ts"] diff --git a/ticketflow/services/inventory-service/package.json b/ticketflow/services/inventory-service/package.json index ee4c14a..cacd304 100644 --- a/ticketflow/services/inventory-service/package.json +++ b/ticketflow/services/inventory-service/package.json @@ -1,35 +1,21 @@ { - "name": "inventory-service", + "name": "@ticketflow/inventory-service", "version": "1.0.0", "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/src/index.js", - "migrate": "node scripts/migrate.cjs", - "migrate:dev": "node scripts/migrate.cjs", - "seed": "node scripts/seed.cjs", - "generate": "echo \"No code generation required with Drizzle\"", - "test": "jest --forceExit" + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "db:migrate": "bun src/db/migrate.ts" }, "dependencies": { - "@ticketflow/shared": "workspace:*", - "dotenv": "^16.3.1", - "drizzle-orm": "^0.36.4", - "express": "^4.18.2", - "ioredis": "^5.3.2", - "pg": "^8.13.1", - "zod": "^3.22.4" + "elysia": "^1.1.25", + "@elysiajs/cors": "^1.1.1", + "drizzle-orm": "^0.33.0", + "postgres": "^3.4.4", + "ioredis": "^5.4.1", + "kafkajs": "^2.2.4", + "drizzle-kit": "^0.24.0" }, "devDependencies": { - "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.6", - "@types/pg": "^8.11.10", - "@types/supertest": "^6.0.2", - "jest": "^29.7.0", - "supertest": "^6.3.4", - "ts-jest": "^29.1.1", - "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" + "bun-types": "^1.1.29" } } diff --git a/ticketflow/services/inventory-service/scripts/migrate.cjs b/ticketflow/services/inventory-service/scripts/migrate.cjs deleted file mode 100644 index 8191c59..0000000 --- a/ticketflow/services/inventory-service/scripts/migrate.cjs +++ /dev/null @@ -1,41 +0,0 @@ -const { Pool } = require('pg') - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is required for migrations') -} - -const pool = new Pool({ connectionString }) - -async function run() { - await pool.query(` -DO $$ BEGIN - CREATE TYPE "SeatStatus" AS ENUM ('AVAILABLE', 'LOCKED', 'RESERVED'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; -`) - - await pool.query(` -CREATE TABLE IF NOT EXISTS "Seat" ( - "id" text PRIMARY KEY, - "eventId" text NOT NULL, - "seatNumber" text NOT NULL, - "row" text NOT NULL, - "status" "SeatStatus" NOT NULL DEFAULT 'AVAILABLE', - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now(), - CONSTRAINT "Seat_eventId_row_seatNumber_key" UNIQUE("eventId", "row", "seatNumber") -); -`) -} - -run() - .then(() => { - console.log('inventory-service migration complete') - return pool.end() - }) - .catch((error) => { - console.error('inventory-service migration failed', error) - return pool.end().finally(() => process.exit(1)) - }) diff --git a/ticketflow/services/inventory-service/scripts/seed.cjs b/ticketflow/services/inventory-service/scripts/seed.cjs deleted file mode 100644 index cdc654e..0000000 --- a/ticketflow/services/inventory-service/scripts/seed.cjs +++ /dev/null @@ -1,49 +0,0 @@ -const { randomUUID } = require('crypto') -const { Pool } = require('pg') - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is required for seeding') -} - -const pool = new Pool({ connectionString }) - -const EVENT_IDS = ['event-1', 'event-2', 'event-3'] -const ROWS = ['A', 'B', 'C', 'D', 'E'] -const SEATS_PER_ROW = 10 - -async function seedEventSeats(eventId) { - const values = [] - const params = [] - let i = 1 - - for (const row of ROWS) { - for (let seat = 1; seat <= SEATS_PER_ROW; seat += 1) { - values.push(`($${i++}, $${i++}, $${i++}, $${i++}, $${i++})`) - params.push(randomUUID(), eventId, String(seat), row, 'AVAILABLE') - } - } - - const sql = ` - INSERT INTO "Seat" ("id", "eventId", "seatNumber", "row", "status") - VALUES ${values.join(', ')} - ON CONFLICT ("eventId", "row", "seatNumber") DO NOTHING; - ` - - await pool.query(sql, params) -} - -async function run() { - for (const eventId of EVENT_IDS) { - await seedEventSeats(eventId) - } - - console.log('inventory-service seed complete') -} - -run() - .then(() => pool.end()) - .catch((error) => { - console.error('inventory-service seed failed', error) - pool.end().finally(() => process.exit(1)) - }) diff --git a/ticketflow/services/inventory-service/src/app.ts b/ticketflow/services/inventory-service/src/app.ts deleted file mode 100644 index 6919a5e..0000000 --- a/ticketflow/services/inventory-service/src/app.ts +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express'; -import { errorHandler } from '@ticketflow/shared'; -import { inventoryRouter } from './routes/inventory.routes'; - -export const app = express(); - -app.use(express.json()); -app.use('/api/inventory', inventoryRouter); -app.use(errorHandler); diff --git a/ticketflow/services/inventory-service/src/config.ts b/ticketflow/services/inventory-service/src/config.ts new file mode 100644 index 0000000..6c6921e --- /dev/null +++ b/ticketflow/services/inventory-service/src/config.ts @@ -0,0 +1,16 @@ +export const config = { + port: parseInt(process.env.PORT || "3004"), + db: { + url: + process.env.INVENTORY_DB_URL || + "postgresql://postgres:postgres@localhost:5432/ticketflow_inventory", + }, + redis: { + url: process.env.REDIS_URL || "redis://localhost:6379", + lockTtl: 300, // 5 minutes in seconds + }, + kafka: { + brokers: (process.env.KAFKA_BOOTSTRAP_SERVERS || "localhost:9092").split(","), + groupId: "inventory-service", + }, +}; diff --git a/ticketflow/services/inventory-service/src/controllers/inventory.controller.ts b/ticketflow/services/inventory-service/src/controllers/inventory.controller.ts deleted file mode 100644 index 99200c4..0000000 --- a/ticketflow/services/inventory-service/src/controllers/inventory.controller.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { inventoryService } from '../services/inventory.service'; -import { LockSeatsInput, ReleaseSeatsInput, ConfirmSeatsInput } from '../schemas/inventory.schema'; - -export async function getSeatsByEvent(req: Request, res: Response, next: NextFunction): Promise { - try { - const seats = await inventoryService.getSeatsByEvent(req.params.eventId); - res.json({ seats }); - } catch (err) { - next(err); - } -} - -export async function lockSeats(req: Request, res: Response, next: NextFunction): Promise { - try { - const { seatIds, bookingId, ttlSeconds } = req.body as LockSeatsInput; - const result = await inventoryService.lockSeats(seatIds, bookingId, ttlSeconds ?? 300); - if (!result.locked) { - res.status(409).json({ - error: { - code: 'SEAT_LOCKED', - message: 'One or more seats are unavailable', - details: { conflictingSeatIds: result.conflictingSeatIds }, - }, - }); - return; - } - res.json({ locked: true }); - } catch (err) { - next(err); - } -} - -export async function releaseSeats(req: Request, res: Response, next: NextFunction): Promise { - try { - const { seatIds } = req.body as ReleaseSeatsInput; - await inventoryService.releaseSeats(seatIds); - res.json({ ok: true }); - } catch (err) { - next(err); - } -} - -export async function confirmSeats(req: Request, res: Response, next: NextFunction): Promise { - try { - const { seatIds } = req.body as ConfirmSeatsInput; - await inventoryService.confirmSeats(seatIds); - res.json({ ok: true }); - } catch (err) { - next(err); - } -} diff --git a/ticketflow/services/inventory-service/src/db/client.ts b/ticketflow/services/inventory-service/src/db/client.ts index 3fecfb3..fa09777 100644 --- a/ticketflow/services/inventory-service/src/db/client.ts +++ b/ticketflow/services/inventory-service/src/db/client.ts @@ -1,10 +1,33 @@ -import { drizzle } from 'drizzle-orm/node-postgres' -import { Pool } from 'pg' +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { config } from "../config"; +import * as schema from "./schema"; -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is not configured') -} +const queryClient = postgres(config.db.url); +export const db = drizzle(queryClient, { schema }); + +export async function createTableIfNotExists() { + // Create enum type first — ignore error if it already exists + await queryClient` + DO $$ BEGIN + CREATE TYPE seat_status AS ENUM ('AVAILABLE', 'LOCKED', 'RESERVED'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$ + `; -const pool = new Pool({ connectionString }) -export const db = drizzle(pool) + await queryClient` + CREATE TABLE IF NOT EXISTS seats ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + seat_number TEXT NOT NULL, + row TEXT NOT NULL, + section TEXT DEFAULT 'GENERAL', + status seat_status DEFAULT 'AVAILABLE' NOT NULL, + booking_id TEXT, + created_at TIMESTAMP DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP DEFAULT NOW() NOT NULL, + UNIQUE (event_id, row, seat_number) + ) + `; +} diff --git a/ticketflow/services/inventory-service/src/db/schema.ts b/ticketflow/services/inventory-service/src/db/schema.ts index 0d0da3b..ba88c56 100644 --- a/ticketflow/services/inventory-service/src/db/schema.ts +++ b/ticketflow/services/inventory-service/src/db/schema.ts @@ -1,19 +1,24 @@ -import { pgEnum, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core' +import { pgTable, text, timestamp, pgEnum, unique } from "drizzle-orm/pg-core"; -export const seatStatusEnum = pgEnum('SeatStatus', ['AVAILABLE', 'LOCKED', 'RESERVED']) +export const seatStatusEnum = pgEnum("seat_status", ["AVAILABLE", "LOCKED", "RESERVED"]); export const seats = pgTable( - 'Seat', + "seats", { - id: text('id').primaryKey(), - eventId: text('eventId').notNull(), - seatNumber: text('seatNumber').notNull(), - row: text('row').notNull(), - status: seatStatusEnum('status').notNull().default('AVAILABLE'), - createdAt: timestamp('createdAt', { withTimezone: false }).notNull().defaultNow(), - updatedAt: timestamp('updatedAt', { withTimezone: false }).notNull().defaultNow(), + id: text("id").primaryKey(), + eventId: text("event_id").notNull(), + seatNumber: text("seat_number").notNull(), + row: text("row").notNull(), + section: text("section").default("GENERAL"), + status: seatStatusEnum("status").default("AVAILABLE").notNull(), + bookingId: text("booking_id"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => ({ - eventRowSeatUnique: unique('Seat_eventId_row_seatNumber_key').on(table.eventId, table.row, table.seatNumber), + uniqueSeat: unique().on(table.eventId, table.row, table.seatNumber), }) -) +); + +export type Seat = typeof seats.$inferSelect; +export type NewSeat = typeof seats.$inferInsert; diff --git a/ticketflow/services/inventory-service/src/index.ts b/ticketflow/services/inventory-service/src/index.ts index 6afea56..3996a54 100644 --- a/ticketflow/services/inventory-service/src/index.ts +++ b/ticketflow/services/inventory-service/src/index.ts @@ -1,8 +1,45 @@ -import 'dotenv/config'; -import { app } from './app'; +import Elysia from "elysia"; +import { cors } from "@elysiajs/cors"; +import { config } from "./config"; +import { createTableIfNotExists } from "./db/client"; +import { startProducer, stopProducer } from "./kafka/producer"; +import { startConsumer, stopConsumer } from "./kafka/consumer"; +import { inventoryRoutes } from "./routes/inventory.routes"; -const PORT = process.env.PORT ?? 3004; +const app = new Elysia().use(cors()).use(inventoryRoutes); -app.listen(PORT, () => { - console.log(`Inventory service listening on port ${PORT}`); +async function main() { + console.log("[Inventory Service] Starting..."); + + // Initialize DB schema + await createTableIfNotExists(); + console.log("[DB] Tables ready"); + + // Start Kafka producer first — consumer needs the producer handle to publish results + const kafkaProducer = await startProducer(); + await startConsumer(kafkaProducer); + + // Start HTTP server + app.listen(config.port, () => { + console.log(`[Inventory Service] HTTP server running on port ${config.port}`); + }); +} + +main().catch((err) => { + console.error("[Inventory Service] Fatal startup error:", err); + process.exit(1); +}); + +process.on("SIGTERM", async () => { + console.log("[Inventory Service] Shutting down gracefully..."); + await stopConsumer(); + await stopProducer(); + process.exit(0); +}); + +process.on("SIGINT", async () => { + console.log("[Inventory Service] Interrupted, shutting down..."); + await stopConsumer(); + await stopProducer(); + process.exit(0); }); diff --git a/ticketflow/services/inventory-service/src/kafka/consumer.ts b/ticketflow/services/inventory-service/src/kafka/consumer.ts new file mode 100644 index 0000000..d5600bb --- /dev/null +++ b/ticketflow/services/inventory-service/src/kafka/consumer.ts @@ -0,0 +1,135 @@ +import { Kafka, type Consumer } from "kafkajs"; +import { config } from "../config"; +import { + lockSeats, + releaseSeats, + confirmSeats, + createSeatsForEvent, +} from "../services/inventory.service"; +import type { KafkaProducerHandle } from "./producer"; + +let consumer: Consumer; + +const kafka = new Kafka({ + clientId: "inventory-service", + brokers: config.kafka.brokers, + retry: { initialRetryTime: 300, retries: 10 }, +}); + +export async function startConsumer(producer: KafkaProducerHandle) { + consumer = kafka.consumer({ groupId: config.kafka.groupId }); + + await consumer.connect(); + await consumer.subscribe({ + topics: [ + "ticketflow.booking.initiated", + "ticketflow.seats.confirm", + "ticketflow.seats.release", + "ticketflow.event.created", + ], + fromBeginning: false, + }); + + await consumer.run({ + eachMessage: async ({ topic, message }) => { + if (!message.value) return; + + let payload: Record; + try { + payload = JSON.parse(message.value.toString()); + } catch (err) { + console.error(`[Kafka] Failed to parse message from ${topic}:`, err); + return; + } + + console.log(`[Kafka] Received ${topic}:`, payload.eventType ?? "(no eventType)"); + + try { + switch (topic) { + case "ticketflow.booking.initiated": { + const { bookingId, seatIds } = payload as { + bookingId: string; + seatIds: string[]; + }; + + if (!bookingId || !Array.isArray(seatIds) || seatIds.length === 0) { + console.error("[Kafka] Invalid booking.initiated payload:", payload); + return; + } + + const result = await lockSeats(bookingId, seatIds); + + if (result.success) { + await producer.publishSeatsLocked(bookingId, seatIds); + console.log( + `[Inventory] Locked ${seatIds.length} seats for booking ${bookingId}` + ); + } else { + await producer.publishSeatsLockFailed( + bookingId, + "SEATS_UNAVAILABLE", + result.conflictingSeatIds ?? [] + ); + console.warn( + `[Inventory] Failed to lock seats for booking ${bookingId}. Conflicting: ${result.conflictingSeatIds?.join(", ")}` + ); + } + break; + } + + case "ticketflow.seats.confirm": { + const { seatIds } = payload as { seatIds: string[] }; + if (!Array.isArray(seatIds)) { + console.error("[Kafka] Invalid seats.confirm payload:", payload); + return; + } + await confirmSeats(seatIds); + console.log(`[Inventory] Confirmed ${seatIds.length} seats`); + break; + } + + case "ticketflow.seats.release": { + const { seatIds } = payload as { seatIds: string[] }; + if (!Array.isArray(seatIds)) { + console.error("[Kafka] Invalid seats.release payload:", payload); + return; + } + await releaseSeats(seatIds); + console.log(`[Inventory] Released ${seatIds.length} seats`); + break; + } + + case "ticketflow.event.created": { + const { eventId, totalSeats } = payload as { + eventId: string; + totalSeats: number; + }; + if (!eventId || typeof totalSeats !== "number") { + console.error("[Kafka] Invalid event.created payload:", payload); + return; + } + await createSeatsForEvent(eventId, totalSeats); + console.log( + `[Inventory] Created ${totalSeats} seats for event ${eventId}` + ); + break; + } + + default: + console.warn(`[Kafka] Unhandled topic: ${topic}`); + } + } catch (err) { + console.error(`[Kafka] Error processing message from ${topic}:`, err); + } + }, + }); + + console.log("[Kafka Consumer] Inventory service consumer started"); +} + +export async function stopConsumer() { + if (consumer) { + await consumer.disconnect(); + console.log("[Kafka Consumer] Disconnected"); + } +} diff --git a/ticketflow/services/inventory-service/src/kafka/producer.ts b/ticketflow/services/inventory-service/src/kafka/producer.ts new file mode 100644 index 0000000..e8a1c4e --- /dev/null +++ b/ticketflow/services/inventory-service/src/kafka/producer.ts @@ -0,0 +1,73 @@ +import { Kafka, type Producer } from "kafkajs"; +import { config } from "../config"; + +export interface KafkaProducerHandle { + publishSeatsLocked(bookingId: string, seatIds: string[]): Promise; + publishSeatsLockFailed( + bookingId: string, + reason: string, + conflictingSeatIds: string[] + ): Promise; +} + +let producer: Producer; + +const kafka = new Kafka({ + clientId: "inventory-service-producer", + brokers: config.kafka.brokers, + retry: { initialRetryTime: 300, retries: 10 }, +}); + +export async function startProducer(): Promise { + producer = kafka.producer(); + await producer.connect(); + console.log("[Kafka Producer] Inventory service producer started"); + + return { + async publishSeatsLocked(bookingId: string, seatIds: string[]) { + await producer.send({ + topic: "ticketflow.seats.locked", + messages: [ + { + key: bookingId, + value: JSON.stringify({ + eventType: "seats.locked", + bookingId, + seatIds, + timestamp: new Date().toISOString(), + }), + }, + ], + }); + }, + + async publishSeatsLockFailed( + bookingId: string, + reason: string, + conflictingSeatIds: string[] + ) { + await producer.send({ + topic: "ticketflow.seats.lock-failed", + messages: [ + { + key: bookingId, + value: JSON.stringify({ + eventType: "seats.lock-failed", + bookingId, + reason, + conflictingSeatIds, + timestamp: new Date().toISOString(), + }), + }, + ], + }); + }, + }; +} + +export async function stopProducer() { + if (producer) { + await producer.disconnect(); + console.log("[Kafka Producer] Disconnected"); + } +} diff --git a/ticketflow/services/inventory-service/src/redis/client.ts b/ticketflow/services/inventory-service/src/redis/client.ts index 4601755..32dbef9 100644 --- a/ticketflow/services/inventory-service/src/redis/client.ts +++ b/ticketflow/services/inventory-service/src/redis/client.ts @@ -1,12 +1,42 @@ -import Redis from 'ioredis'; +import Redis from "ioredis"; +import { config } from "../config"; -const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'; - -export const redisClient = new Redis(REDIS_URL, { +export const redis = new Redis(config.redis.url, { + retryStrategy: (times) => Math.min(times * 50, 2000), maxRetriesPerRequest: 3, - retryStrategy: (times: number) => Math.min(times * 100, 3000), - lazyConnect: false, }); -redisClient.on('connect', () => console.log('Redis connected')); -redisClient.on('error', (err: Error) => console.error('Redis error:', err.message)); +redis.on("connect", () => console.log("[Redis] Connected")); +redis.on("error", (err) => console.error("[Redis] Error:", err)); + +export const LOCK_PREFIX = "seat:lock:"; + +/** + * Attempts to acquire an NX lock on a single seat. + * Returns true if the lock was successfully acquired. + */ +export async function acquireSeatLock( + seatId: string, + bookingId: string, + ttlSeconds: number +): Promise { + const key = `${LOCK_PREFIX}${seatId}`; + const result = await redis.set(key, bookingId, "EX", ttlSeconds, "NX"); + return result === "OK"; +} + +/** + * Releases the Redis lock for a single seat. + */ +export async function releaseSeatLock(seatId: string): Promise { + await redis.del(`${LOCK_PREFIX}${seatId}`); +} + +/** + * Releases Redis locks for multiple seats in a single DEL call. + */ +export async function releaseSeatLocks(seatIds: string[]): Promise { + if (seatIds.length === 0) return; + const keys = seatIds.map((id) => `${LOCK_PREFIX}${id}`); + await redis.del(...keys); +} diff --git a/ticketflow/services/inventory-service/src/routes/inventory.routes.ts b/ticketflow/services/inventory-service/src/routes/inventory.routes.ts index 13185d6..6ec7795 100644 --- a/ticketflow/services/inventory-service/src/routes/inventory.routes.ts +++ b/ticketflow/services/inventory-service/src/routes/inventory.routes.ts @@ -1,16 +1,27 @@ -import { Router } from 'express'; -import { validate } from '@ticketflow/shared'; -import { - getSeatsByEvent, - lockSeats, - releaseSeats, - confirmSeats, -} from '../controllers/inventory.controller'; -import { lockSeatsSchema, releaseSeatsSchema, confirmSeatsSchema } from '../schemas/inventory.schema'; +import Elysia from "elysia"; +import { getSeatsByEvent } from "../services/inventory.service"; -export const inventoryRouter = Router(); +export const inventoryRoutes = new Elysia() + // List all seats for an event + .get("/api/inventory/events/:eventId/seats", async ({ params, set }) => { + try { + const seatList = await getSeatsByEvent(params.eventId); + return { seats: seatList, total: seatList.length }; + } catch (err) { + console.error("[Route] GET seats error:", err); + set.status = 500; + return { + error: { + code: "INTERNAL_ERROR", + message: "Failed to fetch seats", + }, + }; + } + }) -inventoryRouter.get('/events/:eventId/seats', getSeatsByEvent); -inventoryRouter.post('/lock', validate(lockSeatsSchema), lockSeats); -inventoryRouter.post('/release', validate(releaseSeatsSchema), releaseSeats); -inventoryRouter.post('/confirm', validate(confirmSeatsSchema), confirmSeats); + // Health check + .get("/health", () => ({ + status: "up", + service: "inventory-service", + timestamp: new Date().toISOString(), + })); diff --git a/ticketflow/services/inventory-service/src/schemas/inventory.schema.ts b/ticketflow/services/inventory-service/src/schemas/inventory.schema.ts deleted file mode 100644 index 19aa5bf..0000000 --- a/ticketflow/services/inventory-service/src/schemas/inventory.schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const lockSeatsSchema = z.object({ - seatIds: z.array(z.string()).min(1, 'At least one seat ID is required'), - bookingId: z.string().min(1, 'Booking ID is required'), - ttlSeconds: z.number().int().positive().optional(), -}); - -export const releaseSeatsSchema = z.object({ - seatIds: z.array(z.string()).min(1, 'At least one seat ID is required'), -}); - -export const confirmSeatsSchema = z.object({ - seatIds: z.array(z.string()).min(1, 'At least one seat ID is required'), -}); - -export type LockSeatsInput = z.infer; -export type ReleaseSeatsInput = z.infer; -export type ConfirmSeatsInput = z.infer; diff --git a/ticketflow/services/inventory-service/src/services/inventory.service.test.ts b/ticketflow/services/inventory-service/src/services/inventory.service.test.ts deleted file mode 100644 index 38b90be..0000000 --- a/ticketflow/services/inventory-service/src/services/inventory.service.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('inventoryService', () => { - it('tests will be reworked for Drizzle query mocking', () => { - expect(true).toBe(true); - }); -}); diff --git a/ticketflow/services/inventory-service/src/services/inventory.service.ts b/ticketflow/services/inventory-service/src/services/inventory.service.ts index f81f219..aa1b604 100644 --- a/ticketflow/services/inventory-service/src/services/inventory.service.ts +++ b/ticketflow/services/inventory-service/src/services/inventory.service.ts @@ -1,130 +1,111 @@ -import { randomUUID } from 'crypto'; -import { and, asc, eq, inArray } from 'drizzle-orm'; -import { redisClient } from '../redis/client'; -import { db } from '../db/client'; -import { seats } from '../db/schema'; - -// Local status constants used by both Redis lock logic and database updates. -const SeatStatus = { - AVAILABLE: 'AVAILABLE', - LOCKED: 'LOCKED', - RESERVED: 'RESERVED', -} as const; -type SeatStatus = (typeof SeatStatus)[keyof typeof SeatStatus]; - -interface SeatRow { - id: string; - eventId: string; - seatNumber: string; - row: string; - status: string; - createdAt: Date | string; - updatedAt: Date | string; -} - -const LOCK_PREFIX = 'seat:lock:'; - -function lockKey(seatId: string): string { - return `${LOCK_PREFIX}${seatId}`; +import { db } from "../db/client"; +import { seats } from "../db/schema"; +import { eq, inArray } from "drizzle-orm"; +import { acquireSeatLock, releaseSeatLocks } from "../redis/client"; +import { config } from "../config"; +import crypto from "crypto"; + +export async function getSeatsByEvent(eventId: string) { + return db.select().from(seats).where(eq(seats.eventId, eventId)); } -async function lockSeat(seatId: string, bookingId: string, ttl: number): Promise { - // SET key value EX ttl NX — atomically set only if key does not exist - const result = await redisClient.set(lockKey(seatId), bookingId, 'EX', ttl, 'NX'); - return result === 'OK'; -} - -export const inventoryService = { - async getSeatsByEvent(eventId: string) { - const seatRows = await db.select().from(seats).where(eq(seats.eventId, eventId)).orderBy(asc(seats.row), asc(seats.seatNumber)); - return seatRows.map((s: SeatRow) => ({ - ...s, - createdAt: new Date(s.createdAt).toISOString(), - updatedAt: new Date(s.updatedAt).toISOString(), - })); - }, - - async lockSeats( - seatIds: string[], - bookingId: string, - ttl: number - ): Promise<{ locked: boolean; conflictingSeatIds?: string[] }> { - const lockedSoFar: string[] = []; - const conflicting: string[] = []; - - for (const seatId of seatIds) { - const seat = await db.select().from(seats).where(eq(seats.id, seatId)).limit(1); - const currentSeat = seat[0]; - if (!currentSeat) { - conflicting.push(seatId); - break; - } - if (currentSeat.status === SeatStatus.RESERVED) { - conflicting.push(seatId); - break; - } - - const acquired = await lockSeat(seatId, bookingId, ttl); - if (!acquired) { - conflicting.push(seatId); - break; - } - lockedSoFar.push(seatId); - } - - if (conflicting.length > 0) { - if (lockedSoFar.length > 0) { - const pipeline = redisClient.pipeline(); - for (const seatId of lockedSoFar) { - pipeline.del(lockKey(seatId)); - } - await pipeline.exec(); - } - return { locked: false, conflictingSeatIds: conflicting }; +/** + * Bulk-creates seats for a newly created event. + * Seats are distributed evenly across `rows` rows (A, B, C…). + */ +export async function createSeatsForEvent( + eventId: string, + totalSeats: number, + rows = 5 +) { + const seatsPerRow = Math.ceil(totalSeats / rows); + const rowLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); + const newSeats: { + id: string; + eventId: string; + row: string; + seatNumber: string; + section: string; + status: "AVAILABLE"; + }[] = []; + + for (let r = 0; r < rows && newSeats.length < totalSeats; r++) { + for (let s = 1; s <= seatsPerRow && newSeats.length < totalSeats; s++) { + newSeats.push({ + id: crypto.randomUUID(), + eventId, + row: rowLetters[r]!, + seatNumber: s.toString(), + section: "GENERAL", + status: "AVAILABLE", + }); } + } - return { locked: true }; - }, + await db.insert(seats).values(newSeats).onConflictDoNothing(); + return newSeats; +} - async releaseSeats(seatIds: string[]): Promise { - const pipeline = redisClient.pipeline(); - for (const seatId of seatIds) { - pipeline.del(lockKey(seatId)); +/** + * Attempts to lock the requested seats for a booking. + * Uses a Redis NX lock per seat plus a DB status update. + */ +export async function lockSeats( + bookingId: string, + seatIds: string[] +): Promise<{ success: boolean; conflictingSeatIds?: string[] }> { + // Verify seats exist in DB and are AVAILABLE + const dbSeats = await db.select().from(seats).where(inArray(seats.id, seatIds)); + + if (dbSeats.length !== seatIds.length) { + const foundIds = new Set(dbSeats.map((s) => s.id)); + const missing = seatIds.filter((id) => !foundIds.has(id)); + return { success: false, conflictingSeatIds: missing }; + } + + const unavailable = dbSeats.filter((s) => s.status !== "AVAILABLE"); + if (unavailable.length > 0) { + return { success: false, conflictingSeatIds: unavailable.map((s) => s.id) }; + } + + // Acquire Redis locks one-by-one; roll back on first failure + const lockedSoFar: string[] = []; + for (const seatId of seatIds) { + const acquired = await acquireSeatLock(seatId, bookingId, config.redis.lockTtl); + if (!acquired) { + await releaseSeatLocks(lockedSoFar); + return { success: false, conflictingSeatIds: [seatId] }; } - await pipeline.exec(); - await db - .update(seats) - .set({ status: SeatStatus.AVAILABLE, updatedAt: new Date() }) - .where(and(inArray(seats.id, seatIds), eq(seats.status, SeatStatus.LOCKED))); - }, + lockedSoFar.push(seatId); + } - async confirmSeats(seatIds: string[]): Promise { - await db.update(seats).set({ status: SeatStatus.RESERVED, updatedAt: new Date() }).where(inArray(seats.id, seatIds)); - const pipeline = redisClient.pipeline(); - for (const seatId of seatIds) { - pipeline.del(lockKey(seatId)); - } - await pipeline.exec(); - }, + // Persist LOCKED status to DB + await db + .update(seats) + .set({ status: "LOCKED", bookingId, updatedAt: new Date() }) + .where(inArray(seats.id, seatIds)); - async initSeatsForEvent(eventId: string, rows: string[], seatsPerRow: number): Promise { - const existing = await db.select().from(seats).where(eq(seats.eventId, eventId)).limit(1); - if (existing.length > 0) return; + return { success: true }; +} - const seatRows = rows.flatMap((row) => - Array.from({ length: seatsPerRow }, (_, i) => ({ - eventId, - row, - seatNumber: String(i + 1), - status: SeatStatus.AVAILABLE, - })) - ); +/** + * Releases previously locked seats back to AVAILABLE. + */ +export async function releaseSeats(seatIds: string[]): Promise { + await releaseSeatLocks(seatIds); + await db + .update(seats) + .set({ status: "AVAILABLE", bookingId: null, updatedAt: new Date() }) + .where(inArray(seats.id, seatIds)); +} - await db.insert(seats).values( - seatRows.map((seat) => ({ - id: randomUUID(), - ...seat, - })) - ); - }, -}; +/** + * Confirms locked seats as RESERVED (payment completed). + */ +export async function confirmSeats(seatIds: string[]): Promise { + await releaseSeatLocks(seatIds); // Redis lock no longer needed + await db + .update(seats) + .set({ status: "RESERVED", updatedAt: new Date() }) + .where(inArray(seats.id, seatIds)); +} diff --git a/ticketflow/services/inventory-service/tsconfig.json b/ticketflow/services/inventory-service/tsconfig.json index 5f1c396..cc85826 100644 --- a/ticketflow/services/inventory-service/tsconfig.json +++ b/ticketflow/services/inventory-service/tsconfig.json @@ -1,15 +1,9 @@ { "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "types": ["bun-types"] + } } diff --git a/ticketflow/services/notification-service/Dockerfile b/ticketflow/services/notification-service/Dockerfile index dd60fbd..03588da 100644 --- a/ticketflow/services/notification-service/Dockerfile +++ b/ticketflow/services/notification-service/Dockerfile @@ -1,9 +1,7 @@ -FROM node:20-alpine +FROM python:3.12-slim WORKDIR /app -RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install --frozen-lockfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt COPY . . -RUN pnpm run build EXPOSE 3006 -CMD ["node", "dist/src/index.js"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3006"] diff --git a/ticketflow/services/notification-service/app/__init__.py b/ticketflow/services/notification-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/notification-service/app/config.py b/ticketflow/services/notification-service/app/config.py new file mode 100644 index 0000000..877821a --- /dev/null +++ b/ticketflow/services/notification-service/app/config.py @@ -0,0 +1,20 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + mongodb_url: str = "mongodb://localhost:27017" + mongodb_db: str = "ticketflow_notifications" + kafka_bootstrap_servers: str = "localhost:9092" + smtp_host: str = "localhost" + smtp_port: int = 1025 + smtp_from: str = "noreply@ticketflow.com" + smtp_username: str = "" + smtp_password: str = "" + port: int = 3006 + + class Config: + env_file = ".env" + extra = "ignore" + + +settings = Settings() diff --git a/ticketflow/services/notification-service/app/database.py b/ticketflow/services/notification-service/app/database.py new file mode 100644 index 0000000..e227a96 --- /dev/null +++ b/ticketflow/services/notification-service/app/database.py @@ -0,0 +1,21 @@ +import logging +from motor.motor_asyncio import AsyncIOMotorClient +from beanie import init_beanie +from app.config import settings +from app.models.notification import NotificationDocument + +logger = logging.getLogger(__name__) + +motor_client: AsyncIOMotorClient = None + + +async def init_db(): + global motor_client + motor_client = AsyncIOMotorClient(settings.mongodb_url) + database = motor_client[settings.mongodb_db] + await init_beanie(database=database, document_models=[NotificationDocument]) + logger.info(f"Connected to MongoDB: {settings.mongodb_db}") + + +def get_database(): + return motor_client[settings.mongodb_db] diff --git a/ticketflow/services/notification-service/app/handlers/__init__.py b/ticketflow/services/notification-service/app/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/notification-service/app/handlers/booking_confirmed.py b/ticketflow/services/notification-service/app/handlers/booking_confirmed.py new file mode 100644 index 0000000..ebcfe83 --- /dev/null +++ b/ticketflow/services/notification-service/app/handlers/booking_confirmed.py @@ -0,0 +1,54 @@ +import logging +from jinja2 import Environment, FileSystemLoader, select_autoescape +from pathlib import Path + +from app.mailer.client import send_email +from app.models.notification import NotificationDocument + +logger = logging.getLogger(__name__) + +_template_dir = Path(__file__).parent.parent / "mailer" / "templates" +env = Environment( + loader=FileSystemLoader(str(_template_dir)), + autoescape=select_autoescape(["html"]), +) + + +async def handle_booking_confirmed(message: dict): + booking_id = message.get("bookingId", "N/A") + event_name = message.get("eventName", "Your Event") + user_email = message.get("userEmail", "") + + try: + template = env.get_template("booking_confirmed.html") + html = template.render( + booking_id=booking_id, + event_name=event_name, + event_date=message.get("eventDate", "TBD"), + seats=message.get("seatIds", []), + total_amount=message.get("totalAmount", 0), + user_name=user_email.split("@")[0] if user_email else "there", + ) + subject = f"Booking Confirmed – {event_name}" + success = await send_email(user_email, subject, html) + + notification = NotificationDocument( + type="BOOKING_CONFIRMED", + recipient_email=user_email, + subject=subject, + body=html, + status="SENT" if success else "FAILED", + error=None if success else "SMTP delivery failed", + metadata={ + "bookingId": booking_id, + "eventName": event_name, + }, + ) + await notification.insert() + logger.info( + f"Handled booking_confirmed for booking {booking_id}, email={'sent' if success else 'failed'}" + ) + except Exception as e: + logger.error( + f"Failed to handle booking_confirmed for {booking_id}: {e}", exc_info=True + ) diff --git a/ticketflow/services/notification-service/app/handlers/booking_failed.py b/ticketflow/services/notification-service/app/handlers/booking_failed.py new file mode 100644 index 0000000..e591e94 --- /dev/null +++ b/ticketflow/services/notification-service/app/handlers/booking_failed.py @@ -0,0 +1,51 @@ +import logging +from jinja2 import Environment, FileSystemLoader, select_autoescape +from pathlib import Path + +from app.mailer.client import send_email +from app.models.notification import NotificationDocument + +logger = logging.getLogger(__name__) + +_template_dir = Path(__file__).parent.parent / "mailer" / "templates" +env = Environment( + loader=FileSystemLoader(str(_template_dir)), + autoescape=select_autoescape(["html"]), +) + + +async def handle_booking_failed(message: dict): + booking_id = message.get("bookingId", "N/A") + user_email = message.get("userEmail", "") + reason = message.get("reason", "An unexpected error occurred") + + try: + template = env.get_template("booking_failed.html") + html = template.render( + booking_id=booking_id, + reason=reason, + user_email=user_email, + ) + subject = f"Booking Failed – Reference {booking_id}" + success = await send_email(user_email, subject, html) + + notification = NotificationDocument( + type="BOOKING_FAILED", + recipient_email=user_email, + subject=subject, + body=html, + status="SENT" if success else "FAILED", + error=None if success else "SMTP delivery failed", + metadata={ + "bookingId": booking_id, + "reason": reason, + }, + ) + await notification.insert() + logger.info( + f"Handled booking_failed for booking {booking_id}, email={'sent' if success else 'failed'}" + ) + except Exception as e: + logger.error( + f"Failed to handle booking_failed for {booking_id}: {e}", exc_info=True + ) diff --git a/ticketflow/services/notification-service/app/handlers/welcome.py b/ticketflow/services/notification-service/app/handlers/welcome.py new file mode 100644 index 0000000..d5dbea1 --- /dev/null +++ b/ticketflow/services/notification-service/app/handlers/welcome.py @@ -0,0 +1,49 @@ +import logging +from jinja2 import Environment, FileSystemLoader, select_autoescape +from pathlib import Path + +from app.mailer.client import send_email +from app.models.notification import NotificationDocument + +logger = logging.getLogger(__name__) + +_template_dir = Path(__file__).parent.parent / "mailer" / "templates" +env = Environment( + loader=FileSystemLoader(str(_template_dir)), + autoescape=select_autoescape(["html"]), +) + + +async def handle_welcome(message: dict): + user_email = message.get("email", "") + user_name = message.get("name") or ( + user_email.split("@")[0] if user_email else "there" + ) + + try: + template = env.get_template("welcome.html") + html = template.render( + name=user_name, + email=user_email, + ) + subject = "Welcome to TicketFlow!" + success = await send_email(user_email, subject, html) + + notification = NotificationDocument( + type="WELCOME", + recipient_email=user_email, + subject=subject, + body=html, + status="SENT" if success else "FAILED", + error=None if success else "SMTP delivery failed", + metadata={ + "userId": message.get("userId"), + "name": user_name, + }, + ) + await notification.insert() + logger.info( + f"Handled welcome for {user_email}, email={'sent' if success else 'failed'}" + ) + except Exception as e: + logger.error(f"Failed to handle welcome for {user_email}: {e}", exc_info=True) diff --git a/ticketflow/services/notification-service/app/kafka/__init__.py b/ticketflow/services/notification-service/app/kafka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/notification-service/app/kafka/consumer.py b/ticketflow/services/notification-service/app/kafka/consumer.py new file mode 100644 index 0000000..1158bd2 --- /dev/null +++ b/ticketflow/services/notification-service/app/kafka/consumer.py @@ -0,0 +1,92 @@ +import asyncio +import json +import logging +from aiokafka import AIOKafkaConsumer +from app.config import settings +from app.handlers.booking_confirmed import handle_booking_confirmed +from app.handlers.booking_failed import handle_booking_failed +from app.handlers.welcome import handle_welcome + +logger = logging.getLogger(__name__) + +TOPICS = [ + "ticketflow.booking.confirmed", + "ticketflow.booking.failed", + "ticketflow.user.registered", +] +GROUP_ID = "notification-service" +MAX_RETRIES = 3 + + +async def _dispatch(event_type: str, data: dict): + if event_type in ("booking.confirmed", "ticketflow.booking.confirmed"): + await handle_booking_confirmed(data) + elif event_type in ("booking.failed", "ticketflow.booking.failed"): + await handle_booking_failed(data) + elif event_type in ("user.registered", "ticketflow.user.registered"): + await handle_welcome(data) + else: + logger.warning(f"Unknown eventType: {event_type}") + + +async def _handle_with_retry(data: dict): + event_type = data.get("eventType", "") + for attempt in range(1, MAX_RETRIES + 1): + try: + await _dispatch(event_type, data) + return + except Exception as e: + if attempt == MAX_RETRIES: + logger.error( + f"[DLQ] Failed to process event after {MAX_RETRIES} attempts. " + f"eventType={event_type} error={e} payload={data}", + exc_info=True, + ) + else: + logger.warning( + f"Attempt {attempt}/{MAX_RETRIES} failed for eventType={event_type}: {e}. Retrying…" + ) + await asyncio.sleep(attempt * 1.0) + + +async def consume_loop(): + backoff = 1 + while True: + consumer = None + try: + consumer = AIOKafkaConsumer( + *TOPICS, + bootstrap_servers=settings.kafka_bootstrap_servers, + group_id=GROUP_ID, + value_deserializer=lambda m: json.loads(m.decode("utf-8")), + auto_offset_reset="earliest", + enable_auto_commit=True, + ) + await consumer.start() + logger.info(f"Notification consumer started, topics={TOPICS}") + backoff = 1 + + async for msg in consumer: + logger.debug(f"Received message from {msg.topic}: {msg.value}") + await _handle_with_retry(msg.value) + + except asyncio.CancelledError: + logger.info("Notification consumer task cancelled") + break + except Exception as e: + logger.error( + f"Consumer error: {e}. Reconnecting in {backoff}s…", exc_info=True + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60) + finally: + if consumer: + try: + await consumer.stop() + except Exception: + pass + + +async def start_consumer(): + task = asyncio.create_task(consume_loop()) + return task diff --git a/ticketflow/services/notification-service/app/mailer/__init__.py b/ticketflow/services/notification-service/app/mailer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/notification-service/app/mailer/client.py b/ticketflow/services/notification-service/app/mailer/client.py new file mode 100644 index 0000000..59986e9 --- /dev/null +++ b/ticketflow/services/notification-service/app/mailer/client.py @@ -0,0 +1,29 @@ +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import logging +from app.config import settings + +logger = logging.getLogger(__name__) + + +async def send_email(to: str, subject: str, html_body: str) -> bool: + try: + message = MIMEMultipart("alternative") + message["From"] = settings.smtp_from + message["To"] = to + message["Subject"] = subject + message.attach(MIMEText(html_body, "html")) + + await aiosmtplib.send( + message, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username or None, + password=settings.smtp_password or None, + ) + logger.info(f"Email sent to {to}: {subject}") + return True + except Exception as e: + logger.error(f"Failed to send email to {to}: {e}") + return False diff --git a/ticketflow/services/notification-service/app/mailer/templates/booking_confirmed.html b/ticketflow/services/notification-service/app/mailer/templates/booking_confirmed.html new file mode 100644 index 0000000..2009d12 --- /dev/null +++ b/ticketflow/services/notification-service/app/mailer/templates/booking_confirmed.html @@ -0,0 +1,93 @@ + + + + + + Booking Confirmed – TicketFlow + + + +
+
+ +
+

Booking Confirmed!

+
+
+

Hi {{ user_name }}, your booking is confirmed. Get ready for an unforgettable experience!

+ +
+

Booking Details

+ + + + + + + + + + + + + + + + + +
Booking ID{{ booking_id }}
Event{{ event_name }}
Date{{ event_date }}
Seats{{ seats | length }} seat(s)
+
+ + {% if seats %} +
+

Your Tickets

+ {% for seat in seats %} + {{ seat }} + {% endfor %} +
+ {% endif %} + +
+ Total Charged + ${{ "%.2f" | format(total_amount | float) }} +
+ + +
+ +
+ + diff --git a/ticketflow/services/notification-service/app/mailer/templates/booking_failed.html b/ticketflow/services/notification-service/app/mailer/templates/booking_failed.html new file mode 100644 index 0000000..d7cf189 --- /dev/null +++ b/ticketflow/services/notification-service/app/mailer/templates/booking_failed.html @@ -0,0 +1,77 @@ + + + + + + Booking Failed – TicketFlow + + + +
+
+ +
+

Booking Failed

+
+
+

+ We're sorry, but we were unable to complete your booking. No charges have been applied to your account. + Please review the details below and try again. +

+ +
+

Booking Reference

+ + + + + + + + + +
Booking ID{{ booking_id }}
Account{{ user_email }}
+
+ + {% if reason %} +
+ Reason for failure: + {{ reason }} +
+ {% endif %} + +
+ Try Again +
+
+ +
+ + diff --git a/ticketflow/services/notification-service/app/mailer/templates/welcome.html b/ticketflow/services/notification-service/app/mailer/templates/welcome.html new file mode 100644 index 0000000..6367552 --- /dev/null +++ b/ticketflow/services/notification-service/app/mailer/templates/welcome.html @@ -0,0 +1,91 @@ + + + + + + Welcome to TicketFlow! + + + +
+
+ + 👋 +

Welcome to TicketFlow!

+

Your front-row seat to live experiences starts here.

+
+
+

+ Hi {{ name }}, thanks for joining TicketFlow! We're thrilled to have you. + Discover thousands of live events, book instantly, and experience moments that matter. +

+ +
+
+
🎵
+
+ Browse Live Events + Concerts, sports, theatre, festivals and more — all in one place. +
+
+
+
+
+
+ Instant Booking + Secure your seats in seconds with our fast, reliable checkout. +
+
+
+
+
🎟️
+
+ Digital Tickets + All your tickets in one place, accessible on any device. +
+
+
+ + + + +
+ +
+ + diff --git a/ticketflow/services/notification-service/app/main.py b/ticketflow/services/notification-service/app/main.py new file mode 100644 index 0000000..576470d --- /dev/null +++ b/ticketflow/services/notification-service/app/main.py @@ -0,0 +1,84 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.database import init_db +from app.kafka.consumer import start_consumer +from app.models.notification import NotificationDocument + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +_consumer_task = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global _consumer_task + await init_db() + _consumer_task = await start_consumer() + yield + if _consumer_task: + _consumer_task.cancel() + try: + await _consumer_task + except asyncio.CancelledError: + pass + + +app = FastAPI( + title="Notification Service", + description="TicketFlow Notification Service – sends transactional emails via Kafka events", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health(): + return {"status": "up", "service": "notification-service"} + + +@app.get("/api/notifications/recent") +async def recent_notifications(): + notifications = ( + await NotificationDocument.find() + .sort(-NotificationDocument.created_at) + .limit(20) + .to_list() + ) + return [ + { + "id": n.id, + "type": n.type, + "recipientEmail": n.recipient_email, + "subject": n.subject, + "status": n.status, + "error": n.error, + "metadata": n.metadata, + "createdAt": n.created_at.isoformat(), + } + for n in notifications + ] + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.error(f"Unhandled exception: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={"error": {"code": "INTERNAL_ERROR", "message": str(exc)}}, + ) diff --git a/ticketflow/services/notification-service/app/models/__init__.py b/ticketflow/services/notification-service/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/notification-service/app/models/notification.py b/ticketflow/services/notification-service/app/models/notification.py new file mode 100644 index 0000000..aee7cf8 --- /dev/null +++ b/ticketflow/services/notification-service/app/models/notification.py @@ -0,0 +1,20 @@ +from beanie import Document +from pydantic import Field +from typing import Optional +from datetime import datetime +import uuid + + +class NotificationDocument(Document): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + type: str # BOOKING_CONFIRMED | BOOKING_FAILED | WELCOME + recipient_email: str + subject: str + body: str # HTML + status: str # SENT | FAILED + error: Optional[str] = None + metadata: dict = {} + created_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "notifications" diff --git a/ticketflow/services/notification-service/jest.config.js b/ticketflow/services/notification-service/jest.config.js deleted file mode 100644 index 556db62..0000000 --- a/ticketflow/services/notification-service/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts'], - moduleNameMapper: { - '^@ticketflow/shared$': '/../../shared/src/index.ts', - }, -}; diff --git a/ticketflow/services/notification-service/package.json b/ticketflow/services/notification-service/package.json deleted file mode 100644 index 35e97bd..0000000 --- a/ticketflow/services/notification-service/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "notification-service", - "version": "1.0.0", - "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/src/index.js", - "test": "jest --forceExit" - }, - "dependencies": { - "@ticketflow/shared": "workspace:*", - "amqplib": "^0.10.3", - "dotenv": "^16.3.1", - "nodemailer": "^7.0.11" - }, - "devDependencies": { - "@types/amqplib": "^0.10.4", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.6", - "@types/nodemailer": "^6.4.14", - "jest": "^29.7.0", - "ts-jest": "^29.1.1", - "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" - } -} diff --git a/ticketflow/services/notification-service/requirements.txt b/ticketflow/services/notification-service/requirements.txt new file mode 100644 index 0000000..6d22f38 --- /dev/null +++ b/ticketflow/services/notification-service/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +beanie==1.26.0 +motor==3.5.1 +pydantic==2.9.2 +pydantic-settings==2.5.2 +aiokafka==0.11.0 +aiosmtplib==3.0.1 +jinja2==3.1.4 diff --git a/ticketflow/services/notification-service/src/consumer.ts b/ticketflow/services/notification-service/src/consumer.ts deleted file mode 100644 index 38bed6a..0000000 --- a/ticketflow/services/notification-service/src/consumer.ts +++ /dev/null @@ -1,78 +0,0 @@ -import amqplib, { ChannelModel, Channel, ConsumeMessage } from 'amqplib'; -import { Events, RabbitMQMessage, BookingConfirmedPayload, PaymentFailedPayload } from '@ticketflow/shared'; -import { bookingConfirmedHandler } from './handlers/booking.handler'; - -const RABBITMQ_URL = process.env.RABBITMQ_URL ?? 'amqp://guest:guest@localhost:5672'; -const EXCHANGE_NAME = 'ticketflow'; -const QUEUE_NAME = 'notifications'; - -const RECONNECT_DELAY_MS = 5000; - -let connection: ChannelModel | null = null; -let channel: Channel | null = null; - -async function setup(): Promise { - const conn = await amqplib.connect(RABBITMQ_URL); - connection = conn; - - const ch = await conn.createChannel(); - channel = ch; - - await ch.assertExchange(EXCHANGE_NAME, 'topic', { durable: true }); - await ch.assertQueue(QUEUE_NAME, { durable: true }); - - await ch.bindQueue(QUEUE_NAME, EXCHANGE_NAME, Events.BOOKING_CONFIRMED); - await ch.bindQueue(QUEUE_NAME, EXCHANGE_NAME, Events.PAYMENT_FAILED); - - ch.prefetch(1); - - await ch.consume(QUEUE_NAME, async (msg: ConsumeMessage | null) => { - if (!msg) return; - - try { - const raw = JSON.parse(msg.content.toString()) as RabbitMQMessage; - - if (raw.event === Events.BOOKING_CONFIRMED) { - await bookingConfirmedHandler.handleConfirmed(raw.payload as BookingConfirmedPayload); - } else if (raw.event === Events.PAYMENT_FAILED) { - await bookingConfirmedHandler.handlePaymentFailed(raw.payload as PaymentFailedPayload); - } else { - console.warn('Unknown event type:', raw.event); - } - - ch.ack(msg); - } catch (err) { - console.error('Error processing message:', err); - ch.nack(msg, false, false); // dead-letter, don't requeue - } - }); - - console.log(`Notification consumer listening on queue: ${QUEUE_NAME}`); - - conn.on('error', (err: Error) => { - console.error('RabbitMQ connection error:', err.message); - scheduleReconnect(); - }); - - conn.on('close', () => { - console.warn('RabbitMQ connection closed, reconnecting...'); - scheduleReconnect(); - }); -} - -function scheduleReconnect(): void { - connection = null; - channel = null; - setTimeout(() => { - setup().catch((err) => { - console.error('Reconnect failed:', err.message); - scheduleReconnect(); - }); - }, RECONNECT_DELAY_MS); -} - -export const consumer = { - async start(): Promise { - await setup(); - }, -}; diff --git a/ticketflow/services/notification-service/src/handlers/booking.handler.test.ts b/ticketflow/services/notification-service/src/handlers/booking.handler.test.ts deleted file mode 100644 index 18cbd59..0000000 --- a/ticketflow/services/notification-service/src/handlers/booking.handler.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { bookingConfirmedHandler } from './booking.handler'; -import { mailer } from '../mailer'; - -jest.mock('../mailer', () => ({ - mailer: { send: jest.fn().mockResolvedValue(undefined) }, -})); - -const mockMailer = mailer as { send: jest.Mock }; - -describe('bookingConfirmedHandler', () => { - beforeEach(() => jest.clearAllMocks()); - - it('should send confirmation email', async () => { - await bookingConfirmedHandler.handleConfirmed({ - bookingId: 'booking-1', - userId: 'user-1', - userEmail: 'test@example.com', - eventId: 'event-1', - eventName: 'Rock Night', - seatIds: ['A1', 'A2'], - totalAmount: 150, - confirmedAt: new Date().toISOString(), - }); - - expect(mockMailer.send).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'test@example.com', - subject: expect.stringContaining('Rock Night'), - }) - ); - }); - - it('should send payment failed email', async () => { - await bookingConfirmedHandler.handlePaymentFailed({ - bookingId: 'booking-1', - userId: 'user-1', - userEmail: 'test@example.com', - eventId: 'event-1', - eventName: 'Rock Night', - seatIds: ['A1'], - amount: 75, - failedAt: new Date().toISOString(), - reason: 'Insufficient funds', - }); - - expect(mockMailer.send).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'test@example.com', - subject: expect.stringContaining('Payment Failed'), - }) - ); - }); -}); diff --git a/ticketflow/services/notification-service/src/handlers/booking.handler.ts b/ticketflow/services/notification-service/src/handlers/booking.handler.ts deleted file mode 100644 index a914b63..0000000 --- a/ticketflow/services/notification-service/src/handlers/booking.handler.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { BookingConfirmedPayload, PaymentFailedPayload } from '@ticketflow/shared'; -import { mailer } from '../mailer'; - -export const bookingConfirmedHandler = { - async handleConfirmed(payload: BookingConfirmedPayload): Promise { - console.log(`Sending booking confirmation email to user ${payload.userId} for booking ${payload.bookingId}`); - - const text = ` -Dear Customer, - -Your booking has been confirmed! - -Booking ID: ${payload.bookingId} -Event: ${payload.eventName} -Seats: ${payload.seatIds.join(', ')} -Total Amount: $${payload.totalAmount.toFixed(2)} -Confirmed At: ${new Date(payload.confirmedAt).toLocaleString()} - -Thank you for choosing TicketFlow! - -Best regards, -The TicketFlow Team - `.trim(); - - await mailer.send({ - to: payload.userEmail, - subject: `Booking Confirmed — ${payload.eventName}`, - text, - }); - }, - - async handlePaymentFailed(payload: PaymentFailedPayload): Promise { - console.log(`Sending payment failure email to user ${payload.userId} for booking ${payload.bookingId}`); - - const text = ` -Dear Customer, - -Unfortunately, we were unable to process your payment. - -Booking ID: ${payload.bookingId} -Event: ${payload.eventName} -Seats: ${payload.seatIds.join(', ')} -Amount: $${payload.amount.toFixed(2)} -Failed At: ${new Date(payload.failedAt).toLocaleString()} -Reason: ${payload.reason} - -Please try again or contact our support team. - -Best regards, -The TicketFlow Team - `.trim(); - - await mailer.send({ - to: payload.userEmail, - subject: `Payment Failed — ${payload.eventName}`, - text, - }); - }, -}; diff --git a/ticketflow/services/notification-service/src/index.ts b/ticketflow/services/notification-service/src/index.ts deleted file mode 100644 index 72a2ca8..0000000 --- a/ticketflow/services/notification-service/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'dotenv/config'; -import { consumer } from './consumer'; - -console.log('Starting notification service...'); -consumer.start().catch((err) => { - console.error('Failed to start consumer:', err); - process.exit(1); -}); diff --git a/ticketflow/services/notification-service/src/mailer/index.ts b/ticketflow/services/notification-service/src/mailer/index.ts deleted file mode 100644 index d77ac58..0000000 --- a/ticketflow/services/notification-service/src/mailer/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import nodemailer, { Transporter } from 'nodemailer'; - -const SMTP_HOST = process.env.SMTP_HOST ?? 'localhost'; -const SMTP_PORT = parseInt(process.env.SMTP_PORT ?? '1025', 10); -const FROM_ADDRESS = process.env.FROM_ADDRESS ?? 'noreply@ticketflow.com'; - -let transporter: Transporter | null = null; - -function getTransporter(): Transporter { - if (!transporter) { - transporter = nodemailer.createTransport({ - host: SMTP_HOST, - port: SMTP_PORT, - secure: false, - ignoreTLS: true, - }); - } - return transporter; -} - -interface MailOptions { - to: string; - subject: string; - text: string; - html?: string; -} - -export const mailer = { - async send(options: MailOptions): Promise { - const t = getTransporter(); - await t.sendMail({ - from: FROM_ADDRESS, - to: options.to, - subject: options.subject, - text: options.text, - ...(options.html ? { html: options.html } : {}), - }); - console.log(`Email sent to ${options.to}: ${options.subject}`); - }, -}; diff --git a/ticketflow/services/notification-service/tsconfig.json b/ticketflow/services/notification-service/tsconfig.json deleted file mode 100644 index b821d79..0000000 --- a/ticketflow/services/notification-service/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} diff --git a/ticketflow/services/payment-service/Dockerfile b/ticketflow/services/payment-service/Dockerfile index 352f2c0..b895ba8 100644 --- a/ticketflow/services/payment-service/Dockerfile +++ b/ticketflow/services/payment-service/Dockerfile @@ -1,9 +1,7 @@ -FROM node:20-alpine +FROM python:3.12-slim WORKDIR /app -RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install --frozen-lockfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt COPY . . -RUN pnpm run build EXPOSE 3005 -CMD ["node", "dist/src/index.js"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3005"] diff --git a/ticketflow/services/payment-service/app/__init__.py b/ticketflow/services/payment-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/payment-service/app/config.py b/ticketflow/services/payment-service/app/config.py new file mode 100644 index 0000000..62c5c56 --- /dev/null +++ b/ticketflow/services/payment-service/app/config.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + mongodb_url: str = "mongodb://localhost:27017" + mongodb_db: str = "ticketflow_payments" + kafka_bootstrap_servers: str = "localhost:9092" + payment_success_rate: float = 0.95 + port: int = 3005 + + class Config: + env_file = ".env" + extra = "ignore" + + +settings = Settings() diff --git a/ticketflow/services/payment-service/app/database.py b/ticketflow/services/payment-service/app/database.py new file mode 100644 index 0000000..370e4de --- /dev/null +++ b/ticketflow/services/payment-service/app/database.py @@ -0,0 +1,21 @@ +import logging +from motor.motor_asyncio import AsyncIOMotorClient +from beanie import init_beanie +from app.config import settings +from app.models.payment import PaymentDocument + +logger = logging.getLogger(__name__) + +motor_client: AsyncIOMotorClient = None + + +async def init_db(): + global motor_client + motor_client = AsyncIOMotorClient(settings.mongodb_url) + database = motor_client[settings.mongodb_db] + await init_beanie(database=database, document_models=[PaymentDocument]) + logger.info(f"Connected to MongoDB: {settings.mongodb_db}") + + +def get_database(): + return motor_client[settings.mongodb_db] diff --git a/ticketflow/services/payment-service/app/kafka/__init__.py b/ticketflow/services/payment-service/app/kafka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/payment-service/app/kafka/consumer.py b/ticketflow/services/payment-service/app/kafka/consumer.py new file mode 100644 index 0000000..178d434 --- /dev/null +++ b/ticketflow/services/payment-service/app/kafka/consumer.py @@ -0,0 +1,90 @@ +import asyncio +import json +import logging +from aiokafka import AIOKafkaConsumer +from app.config import settings +from app.services.payment_service import process_payment +from app.kafka.producer import publish_payment_processed + +logger = logging.getLogger(__name__) + +TOPIC = "ticketflow.payment.requested" +GROUP_ID = "payment-service" + + +async def consume_loop(): + backoff = 1 + while True: + consumer = None + try: + consumer = AIOKafkaConsumer( + TOPIC, + bootstrap_servers=settings.kafka_bootstrap_servers, + group_id=GROUP_ID, + value_deserializer=lambda m: json.loads(m.decode("utf-8")), + auto_offset_reset="earliest", + enable_auto_commit=True, + ) + await consumer.start() + logger.info(f"Kafka consumer started, listening on {TOPIC}") + backoff = 1 # reset on successful connect + + async for msg in consumer: + await handle_message(msg.value) + + except asyncio.CancelledError: + logger.info("Payment consumer task cancelled") + break + except Exception as e: + logger.error(f"Consumer error: {e}. Retrying in {backoff}s…", exc_info=True) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60) + finally: + if consumer: + try: + await consumer.stop() + except Exception: + pass + + +async def handle_message(data: dict): + booking_id = data.get("bookingId") + user_id = data.get("userId") + amount = data.get("amount") + currency = data.get("currency", "USD") + + if not booking_id or not user_id or amount is None: + logger.warning(f"Received malformed payment.requested message: {data}") + return + + logger.info( + f"Processing payment for booking {booking_id}, amount={amount} {currency}" + ) + try: + payment = await process_payment( + booking_id=booking_id, + user_id=user_id, + amount=amount, + currency=currency, + ) + await publish_payment_processed( + booking_id=booking_id, + payment_id=payment.id, + status=payment.status, + reason=payment.reason, + ) + except Exception as e: + logger.error( + f"Failed to process payment for booking {booking_id}: {e}", exc_info=True + ) + await publish_payment_processed( + booking_id=booking_id, + payment_id="unknown", + status="FAILED", + reason=str(e), + ) + + +async def start_consumer(): + task = asyncio.create_task(consume_loop()) + return task diff --git a/ticketflow/services/payment-service/app/kafka/producer.py b/ticketflow/services/payment-service/app/kafka/producer.py new file mode 100644 index 0000000..72b6abe --- /dev/null +++ b/ticketflow/services/payment-service/app/kafka/producer.py @@ -0,0 +1,49 @@ +import json +import logging +from datetime import datetime +from aiokafka import AIOKafkaProducer +from app.config import settings + +logger = logging.getLogger(__name__) +producer: AIOKafkaProducer = None + + +async def start_producer(): + global producer + producer = AIOKafkaProducer( + bootstrap_servers=settings.kafka_bootstrap_servers, + value_serializer=lambda v: json.dumps(v).encode("utf-8"), + key_serializer=lambda k: k.encode("utf-8") if k else None, + ) + await producer.start() + logger.info("Kafka producer started") + + +async def stop_producer(): + global producer + if producer: + await producer.stop() + + +async def publish_payment_processed( + booking_id: str, + payment_id: str, + status: str, + reason: str = None, +): + if not producer: + logger.warning("Producer not initialized; skipping publish_payment_processed") + return + payload = { + "eventType": "payment.processed", + "bookingId": booking_id, + "paymentId": payment_id, + "status": status, + "timestamp": datetime.utcnow().isoformat(), + } + if reason: + payload["reason"] = reason + await producer.send_and_wait( + "ticketflow.payment.processed", value=payload, key=booking_id + ) + logger.info(f"Published payment.processed for booking {booking_id} status={status}") diff --git a/ticketflow/services/payment-service/app/main.py b/ticketflow/services/payment-service/app/main.py new file mode 100644 index 0000000..b6babcb --- /dev/null +++ b/ticketflow/services/payment-service/app/main.py @@ -0,0 +1,61 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.database import init_db +from app.kafka.producer import start_producer, stop_producer +from app.kafka.consumer import start_consumer +from app.routes.payments import router as payments_router + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +_consumer_task = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global _consumer_task + await init_db() + await start_producer() + _consumer_task = await start_consumer() + yield + if _consumer_task: + _consumer_task.cancel() + try: + await _consumer_task + except asyncio.CancelledError: + pass + await stop_producer() + + +app = FastAPI( + title="Payment Service", + description="TicketFlow Payment Service - processes payments via Kafka", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(payments_router) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.error(f"Unhandled exception: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={"error": {"code": "INTERNAL_ERROR", "message": str(exc)}}, + ) diff --git a/ticketflow/services/payment-service/app/models/__init__.py b/ticketflow/services/payment-service/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/payment-service/app/models/payment.py b/ticketflow/services/payment-service/app/models/payment.py new file mode 100644 index 0000000..0bc88f5 --- /dev/null +++ b/ticketflow/services/payment-service/app/models/payment.py @@ -0,0 +1,22 @@ +from beanie import Document, Indexed +from pydantic import Field +from typing import Optional +from datetime import datetime +import uuid + + +class PaymentDocument(Document): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + booking_id: str + user_id: str + amount: float + currency: str = "USD" + status: str = "PENDING" # PENDING | SUCCESS | FAILED + reason: Optional[str] = None + provider_ref: Optional[str] = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + class Settings: + name = "payments" + indexes = ["booking_id"] diff --git a/ticketflow/services/payment-service/app/routes/__init__.py b/ticketflow/services/payment-service/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/payment-service/app/routes/payments.py b/ticketflow/services/payment-service/app/routes/payments.py new file mode 100644 index 0000000..0faf4da --- /dev/null +++ b/ticketflow/services/payment-service/app/routes/payments.py @@ -0,0 +1,45 @@ +import logging +from fastapi import APIRouter +from app.services.payment_service import get_payment, get_payment_by_booking + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/health") +async def health(): + return {"status": "up", "service": "payment-service"} + + +@router.get("/api/payments/{payment_id}") +async def fetch_payment(payment_id: str): + payment = await get_payment(payment_id) + return { + "id": payment.id, + "bookingId": payment.booking_id, + "userId": payment.user_id, + "amount": payment.amount, + "currency": payment.currency, + "status": payment.status, + "reason": payment.reason, + "providerRef": payment.provider_ref, + "createdAt": payment.created_at.isoformat(), + "updatedAt": payment.updated_at.isoformat(), + } + + +@router.get("/api/payments/booking/{booking_id}") +async def fetch_payment_by_booking(booking_id: str): + payment = await get_payment_by_booking(booking_id) + return { + "id": payment.id, + "bookingId": payment.booking_id, + "userId": payment.user_id, + "amount": payment.amount, + "currency": payment.currency, + "status": payment.status, + "reason": payment.reason, + "providerRef": payment.provider_ref, + "createdAt": payment.created_at.isoformat(), + "updatedAt": payment.updated_at.isoformat(), + } diff --git a/ticketflow/services/payment-service/app/services/__init__.py b/ticketflow/services/payment-service/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ticketflow/services/payment-service/app/services/payment_service.py b/ticketflow/services/payment-service/app/services/payment_service.py new file mode 100644 index 0000000..b9b394b --- /dev/null +++ b/ticketflow/services/payment-service/app/services/payment_service.py @@ -0,0 +1,78 @@ +import logging +import random +import uuid +from datetime import datetime +from typing import Optional +from fastapi import HTTPException + +from app.config import settings +from app.models.payment import PaymentDocument + +logger = logging.getLogger(__name__) + + +async def process_payment( + booking_id: str, + user_id: str, + amount: float, + currency: str = "USD", +) -> PaymentDocument: + payment = PaymentDocument( + booking_id=booking_id, + user_id=user_id, + amount=amount, + currency=currency, + status="PENDING", + ) + await payment.insert() + logger.info(f"Created PENDING payment {payment.id} for booking {booking_id}") + + # Simulate payment processing + await _simulate_payment(payment) + return payment + + +async def _simulate_payment(payment: PaymentDocument): + success = random.random() < settings.payment_success_rate + payment.provider_ref = f"mock-txn-{uuid.uuid4().hex[:12].upper()}" + payment.updated_at = datetime.utcnow() + + if success: + payment.status = "SUCCESS" + logger.info(f"Payment {payment.id} succeeded (ref={payment.provider_ref})") + else: + payment.status = "FAILED" + payment.reason = "Payment declined by mock provider" + logger.warning(f"Payment {payment.id} failed for booking {payment.booking_id}") + + await payment.save() + + +async def get_payment(payment_id: str) -> PaymentDocument: + payment = await PaymentDocument.find_one(PaymentDocument.id == payment_id) + if not payment: + raise HTTPException( + status_code=404, + detail={ + "error": { + "code": "PAYMENT_NOT_FOUND", + "message": f"Payment {payment_id} not found", + } + }, + ) + return payment + + +async def get_payment_by_booking(booking_id: str) -> Optional[PaymentDocument]: + payment = await PaymentDocument.find_one(PaymentDocument.booking_id == booking_id) + if not payment: + raise HTTPException( + status_code=404, + detail={ + "error": { + "code": "PAYMENT_NOT_FOUND", + "message": f"No payment found for booking {booking_id}", + } + }, + ) + return payment diff --git a/ticketflow/services/payment-service/jest.config.js b/ticketflow/services/payment-service/jest.config.js deleted file mode 100644 index 556db62..0000000 --- a/ticketflow/services/payment-service/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts'], - moduleNameMapper: { - '^@ticketflow/shared$': '/../../shared/src/index.ts', - }, -}; diff --git a/ticketflow/services/payment-service/package.json b/ticketflow/services/payment-service/package.json deleted file mode 100644 index cc16dd3..0000000 --- a/ticketflow/services/payment-service/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "payment-service", - "version": "1.0.0", - "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/src/index.js", - "migrate": "node scripts/migrate.cjs", - "migrate:dev": "node scripts/migrate.cjs", - "generate": "echo \"No code generation required with Drizzle\"", - "test": "jest --forceExit" - }, - "dependencies": { - "@ticketflow/shared": "workspace:*", - "dotenv": "^16.3.1", - "drizzle-orm": "^0.36.4", - "express": "^4.18.2", - "pg": "^8.13.1", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.6", - "@types/pg": "^8.11.10", - "@types/supertest": "^6.0.2", - "jest": "^29.7.0", - "supertest": "^6.3.4", - "ts-jest": "^29.1.1", - "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" - } -} diff --git a/ticketflow/services/payment-service/requirements.txt b/ticketflow/services/payment-service/requirements.txt new file mode 100644 index 0000000..e2da5da --- /dev/null +++ b/ticketflow/services/payment-service/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +beanie==1.26.0 +motor==3.5.1 +pydantic==2.9.2 +pydantic-settings==2.5.2 +aiokafka==0.11.0 +prometheus-fastapi-instrumentator==7.0.0 diff --git a/ticketflow/services/payment-service/scripts/migrate.cjs b/ticketflow/services/payment-service/scripts/migrate.cjs deleted file mode 100644 index ce424a1..0000000 --- a/ticketflow/services/payment-service/scripts/migrate.cjs +++ /dev/null @@ -1,40 +0,0 @@ -const { Pool } = require('pg') - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is required for migrations') -} - -const pool = new Pool({ connectionString }) - -async function run() { - await pool.query(` -DO $$ BEGIN - CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCESS', 'FAILED'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; -`) - - await pool.query(` -CREATE TABLE IF NOT EXISTS "Payment" ( - "id" text PRIMARY KEY, - "bookingId" text NOT NULL, - "amount" numeric(10, 2) NOT NULL, - "currency" text NOT NULL DEFAULT 'USD', - "status" "PaymentStatus" NOT NULL DEFAULT 'PENDING', - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now() -); -`) -} - -run() - .then(() => { - console.log('payment-service migration complete') - return pool.end() - }) - .catch((error) => { - console.error('payment-service migration failed', error) - return pool.end().finally(() => process.exit(1)) - }) diff --git a/ticketflow/services/payment-service/src/app.ts b/ticketflow/services/payment-service/src/app.ts deleted file mode 100644 index aed5f3a..0000000 --- a/ticketflow/services/payment-service/src/app.ts +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express'; -import { errorHandler } from '@ticketflow/shared'; -import { paymentRouter } from './routes/payment.routes'; - -export const app = express(); - -app.use(express.json()); -app.use('/api/payments', paymentRouter); -app.use(errorHandler); diff --git a/ticketflow/services/payment-service/src/controllers/payment.controller.ts b/ticketflow/services/payment-service/src/controllers/payment.controller.ts deleted file mode 100644 index 76ed0ed..0000000 --- a/ticketflow/services/payment-service/src/controllers/payment.controller.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { paymentService } from '../services/payment.service'; -import { ChargeInput } from '../schemas/payment.schema'; - -export async function chargePayment(req: Request, res: Response, next: NextFunction): Promise { - try { - const { bookingId, amount, currency } = req.body as ChargeInput; - const payment = await paymentService.charge({ bookingId, amount, currency }); - const statusCode = payment.status === 'SUCCESS' ? 200 : 402; - res.status(statusCode).json({ payment }); - } catch (err) { - next(err); - } -} - -export async function getPaymentById(req: Request, res: Response, next: NextFunction): Promise { - try { - const payment = await paymentService.getById(req.params.id); - res.json({ payment }); - } catch (err) { - next(err); - } -} diff --git a/ticketflow/services/payment-service/src/db/client.ts b/ticketflow/services/payment-service/src/db/client.ts deleted file mode 100644 index 3fecfb3..0000000 --- a/ticketflow/services/payment-service/src/db/client.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drizzle } from 'drizzle-orm/node-postgres' -import { Pool } from 'pg' - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is not configured') -} - -const pool = new Pool({ connectionString }) -export const db = drizzle(pool) diff --git a/ticketflow/services/payment-service/src/db/schema.ts b/ticketflow/services/payment-service/src/db/schema.ts deleted file mode 100644 index b9c5d2f..0000000 --- a/ticketflow/services/payment-service/src/db/schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { numeric, pgEnum, pgTable, text, timestamp } from 'drizzle-orm/pg-core' - -export const paymentStatusEnum = pgEnum('PaymentStatus', ['PENDING', 'SUCCESS', 'FAILED']) - -export const payments = pgTable('Payment', { - id: text('id').primaryKey(), - bookingId: text('bookingId').notNull(), - amount: numeric('amount', { precision: 10, scale: 2 }).notNull(), - currency: text('currency').notNull().default('USD'), - status: paymentStatusEnum('status').notNull().default('PENDING'), - createdAt: timestamp('createdAt', { withTimezone: false }).notNull().defaultNow(), - updatedAt: timestamp('updatedAt', { withTimezone: false }).notNull().defaultNow(), -}) diff --git a/ticketflow/services/payment-service/src/index.ts b/ticketflow/services/payment-service/src/index.ts deleted file mode 100644 index fd7e885..0000000 --- a/ticketflow/services/payment-service/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'dotenv/config'; -import { app } from './app'; - -const PORT = process.env.PORT ?? 3005; - -app.listen(PORT, () => { - console.log(`Payment service listening on port ${PORT}`); -}); diff --git a/ticketflow/services/payment-service/src/routes/payment.routes.ts b/ticketflow/services/payment-service/src/routes/payment.routes.ts deleted file mode 100644 index 708d09a..0000000 --- a/ticketflow/services/payment-service/src/routes/payment.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Router } from 'express'; -import { validate } from '@ticketflow/shared'; -import { chargePayment, getPaymentById } from '../controllers/payment.controller'; -import { chargeSchema } from '../schemas/payment.schema'; - -export const paymentRouter = Router(); - -paymentRouter.post('/charge', validate(chargeSchema), chargePayment); -paymentRouter.get('/:id', getPaymentById); diff --git a/ticketflow/services/payment-service/src/schemas/payment.schema.ts b/ticketflow/services/payment-service/src/schemas/payment.schema.ts deleted file mode 100644 index 625d0ab..0000000 --- a/ticketflow/services/payment-service/src/schemas/payment.schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod'; - -export const chargeSchema = z.object({ - bookingId: z.string().min(1, 'Booking ID is required'), - amount: z.number().positive('Amount must be positive'), - currency: z.string().length(3).optional().default('USD'), -}); - -export type ChargeInput = z.infer; diff --git a/ticketflow/services/payment-service/src/services/payment.service.test.ts b/ticketflow/services/payment-service/src/services/payment.service.test.ts deleted file mode 100644 index b7f4e4e..0000000 --- a/ticketflow/services/payment-service/src/services/payment.service.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('paymentService', () => { - it('tests will be reworked for Drizzle query mocking', () => { - expect(true).toBe(true); - }); -}); diff --git a/ticketflow/services/payment-service/src/services/payment.service.ts b/ticketflow/services/payment-service/src/services/payment.service.ts deleted file mode 100644 index 97cf0cb..0000000 --- a/ticketflow/services/payment-service/src/services/payment.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { randomUUID } from 'crypto'; -import { eq } from 'drizzle-orm'; -import { AppError } from '@ticketflow/shared'; -import { ChargeInput } from '../schemas/payment.schema'; -import { db } from '../db/client'; -import { payments } from '../db/schema'; - -function getSuccessRate(): number { - const rate = parseFloat(process.env.PAYMENT_SUCCESS_RATE ?? '0.95'); - return isNaN(rate) ? 0.95 : Math.min(1, Math.max(0, rate)); -} - -function serializePayment(payment: { - id: string; - bookingId: string; - amount: { toString(): string } | string; - currency: string; - status: string; - createdAt: Date | string; - updatedAt: Date | string; -}) { - return { - id: payment.id, - bookingId: payment.bookingId, - amount: parseFloat(payment.amount.toString()), - currency: payment.currency, - status: payment.status, - createdAt: new Date(payment.createdAt).toISOString(), - updatedAt: new Date(payment.updatedAt).toISOString(), - }; -} - -export const paymentService = { - async charge(input: ChargeInput) { - const successRate = getSuccessRate(); - const succeeded = Math.random() < successRate; - const status = succeeded ? 'SUCCESS' : ('FAILED' as const); - - const inserted = await db - .insert(payments) - .values({ - id: randomUUID(), - bookingId: input.bookingId, - amount: input.amount.toString(), - currency: input.currency ?? 'USD', - status, - }) - .returning(); - const payment = inserted[0]; - - return serializePayment(payment); - }, - - async getById(id: string) { - const found = await db.select().from(payments).where(eq(payments.id, id)).limit(1); - const payment = found[0]; - if (!payment) { - throw new AppError(404, 'PAYMENT_NOT_FOUND', 'Payment not found'); - } - return serializePayment(payment); - }, -}; diff --git a/ticketflow/services/payment-service/tsconfig.json b/ticketflow/services/payment-service/tsconfig.json deleted file mode 100644 index 5f1c396..0000000 --- a/ticketflow/services/payment-service/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] -} diff --git a/ticketflow/services/user-service/Dockerfile b/ticketflow/services/user-service/Dockerfile index 7de2b1c..9693920 100644 --- a/ticketflow/services/user-service/Dockerfile +++ b/ticketflow/services/user-service/Dockerfile @@ -1,9 +1,14 @@ -FROM node:20-alpine +# Build stage +FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /app -RUN npm install -g pnpm -COPY package.json ./ -RUN pnpm install --frozen-lockfile -COPY . . -RUN pnpm run build +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests -B + +# Run stage +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/target/*.jar app.jar EXPOSE 3001 -CMD ["node", "dist/src/index.js"] +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/ticketflow/services/user-service/jest.config.js b/ticketflow/services/user-service/jest.config.js deleted file mode 100644 index 556db62..0000000 --- a/ticketflow/services/user-service/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/src/**/*.test.ts'], - moduleNameMapper: { - '^@ticketflow/shared$': '/../../shared/src/index.ts', - }, -}; diff --git a/ticketflow/services/user-service/package.json b/ticketflow/services/user-service/package.json deleted file mode 100644 index d6e7a61..0000000 --- a/ticketflow/services/user-service/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "user-service", - "version": "1.0.0", - "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", - "start": "node dist/src/index.js", - "migrate": "node scripts/migrate.cjs", - "migrate:dev": "node scripts/migrate.cjs", - "generate": "echo \"No code generation required with Drizzle\"", - "test": "jest --forceExit", - "test:integration": "jest --config jest.integration.config.js --forceExit --passWithNoTests" - }, - "dependencies": { - "@ticketflow/shared": "workspace:*", - "bcryptjs": "^2.4.3", - "dotenv": "^16.3.1", - "drizzle-orm": "^0.36.4", - "express": "^4.18.2", - "express-rate-limit": "^7.4.1", - "jsonwebtoken": "^9.0.2", - "pg": "^8.13.1", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/bcryptjs": "^2.4.6", - "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/jsonwebtoken": "^9.0.5", - "@types/node": "^20.10.6", - "@types/pg": "^8.11.10", - "@types/supertest": "^6.0.2", - "jest": "^29.7.0", - "supertest": "^6.3.4", - "ts-jest": "^29.1.1", - "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" - } -} diff --git a/ticketflow/services/user-service/pom.xml b/ticketflow/services/user-service/pom.xml new file mode 100644 index 0000000..9956b8d --- /dev/null +++ b/ticketflow/services/user-service/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.ticketflow + user-service + 1.0.0 + user-service + TicketFlow User Service + + + 21 + 0.12.5 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.kafka + spring-kafka + + + org.postgresql + postgresql + runtime + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + org.projectlombok + lombok + true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/ticketflow/services/user-service/scripts/migrate.cjs b/ticketflow/services/user-service/scripts/migrate.cjs deleted file mode 100644 index 0e7d9b7..0000000 --- a/ticketflow/services/user-service/scripts/migrate.cjs +++ /dev/null @@ -1,40 +0,0 @@ -const { Pool } = require('pg') - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is required for migrations') -} - -const pool = new Pool({ connectionString }) - -async function run() { - await pool.query(` -DO $$ BEGIN - CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; -`) - - await pool.query(` -CREATE TABLE IF NOT EXISTS "User" ( - "id" text PRIMARY KEY, - "name" text NOT NULL, - "email" text NOT NULL UNIQUE, - "password" text NOT NULL, - "role" "UserRole" NOT NULL DEFAULT 'USER', - "createdAt" timestamp NOT NULL DEFAULT now(), - "updatedAt" timestamp NOT NULL DEFAULT now() -); -`) -} - -run() - .then(() => { - console.log('user-service migration complete') - return pool.end() - }) - .catch((error) => { - console.error('user-service migration failed', error) - return pool.end().finally(() => process.exit(1)) - }) diff --git a/ticketflow/services/user-service/src/app.ts b/ticketflow/services/user-service/src/app.ts deleted file mode 100644 index 714c06b..0000000 --- a/ticketflow/services/user-service/src/app.ts +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express'; -import { errorHandler } from '@ticketflow/shared'; -import { authRouter } from './routes/auth.routes'; - -export const app = express(); - -app.use(express.json()); -app.use('/api/users', authRouter); -app.use(errorHandler); diff --git a/ticketflow/services/user-service/src/controllers/auth.controller.ts b/ticketflow/services/user-service/src/controllers/auth.controller.ts deleted file mode 100644 index 9a1d309..0000000 --- a/ticketflow/services/user-service/src/controllers/auth.controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { authService } from '../services/auth.service'; - -export async function register(req: Request, res: Response, next: NextFunction): Promise { - try { - const { name, email, password } = req.body as { name: string; email: string; password: string }; - const result = await authService.register({ name, email, password }); - res.status(201).json(result); - } catch (err) { - next(err); - } -} - -export async function login(req: Request, res: Response, next: NextFunction): Promise { - try { - const { email, password } = req.body as { email: string; password: string }; - const result = await authService.login({ email, password }); - res.status(200).json(result); - } catch (err) { - next(err); - } -} - -export async function getMe(req: Request, res: Response, next: NextFunction): Promise { - try { - const userId = req.user!.sub; - const user = await authService.getById(userId); - res.status(200).json({ user }); - } catch (err) { - next(err); - } -} diff --git a/ticketflow/services/user-service/src/db/client.ts b/ticketflow/services/user-service/src/db/client.ts deleted file mode 100644 index 3fecfb3..0000000 --- a/ticketflow/services/user-service/src/db/client.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { drizzle } from 'drizzle-orm/node-postgres' -import { Pool } from 'pg' - -const connectionString = process.env.DATABASE_URL -if (!connectionString) { - throw new Error('DATABASE_URL is not configured') -} - -const pool = new Pool({ connectionString }) -export const db = drizzle(pool) diff --git a/ticketflow/services/user-service/src/db/schema.ts b/ticketflow/services/user-service/src/db/schema.ts deleted file mode 100644 index 20d0075..0000000 --- a/ticketflow/services/user-service/src/db/schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { pgTable, text, timestamp } from 'drizzle-orm/pg-core' - -export const users = pgTable('User', { - id: text('id').primaryKey(), - name: text('name').notNull(), - email: text('email').notNull().unique(), - password: text('password').notNull(), - role: text('role').notNull().default('USER'), - createdAt: timestamp('createdAt', { withTimezone: false }).notNull().defaultNow(), - updatedAt: timestamp('updatedAt', { withTimezone: false }).notNull().defaultNow(), -}) - -export type UserRow = typeof users.$inferSelect diff --git a/ticketflow/services/user-service/src/index.ts b/ticketflow/services/user-service/src/index.ts deleted file mode 100644 index 5b15121..0000000 --- a/ticketflow/services/user-service/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'dotenv/config'; -import { app } from './app'; - -const PORT = process.env.PORT ?? 3001; - -app.listen(PORT, () => { - console.log(`User service listening on port ${PORT}`); -}); diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/UserServiceApplication.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/UserServiceApplication.java new file mode 100644 index 0000000..e1a0b6f --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/UserServiceApplication.java @@ -0,0 +1,11 @@ +package com.ticketflow.user; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class UserServiceApplication { + public static void main(String[] args) { + SpringApplication.run(UserServiceApplication.class, args); + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/KafkaConfig.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/KafkaConfig.java new file mode 100644 index 0000000..53a8da0 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/KafkaConfig.java @@ -0,0 +1,29 @@ +package com.ticketflow.user.config; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.KafkaAdmin; + +import java.util.Map; + +@Configuration +public class KafkaConfig { + + @Bean + public KafkaAdmin kafkaAdmin( + @org.springframework.beans.factory.annotation.Value("${spring.kafka.bootstrap-servers}") String bootstrapServers) { + return new KafkaAdmin(Map.of( + org.apache.kafka.clients.admin.AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers + )); + } + + @Bean + public NewTopic userRegisteredTopic() { + return TopicBuilder.name("ticketflow.user.registered") + .partitions(3) + .replicas(1) + .build(); + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java new file mode 100644 index 0000000..92cec55 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java @@ -0,0 +1,41 @@ +package com.ticketflow.user.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/users/register", "/api/users/login", "/api/users/health").permitAll() + .requestMatchers("/actuator/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java new file mode 100644 index 0000000..421e4a7 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java @@ -0,0 +1,47 @@ +package com.ticketflow.user.controller; + +import com.ticketflow.user.dto.AuthResponse; +import com.ticketflow.user.dto.LoginRequest; +import com.ticketflow.user.dto.RegisterRequest; +import com.ticketflow.user.dto.UserDTO; +import com.ticketflow.user.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + AuthResponse response = authService.register(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + AuthResponse response = authService.login(request); + return ResponseEntity.ok(response); + } + + @GetMapping("/me") + public ResponseEntity getMe(@RequestHeader("x-user-id") String userId) { + UserDTO user = authService.getMe(userId); + return ResponseEntity.ok(user); + } + + @GetMapping("/health") + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of("status", "up", "service", "user-service")); + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/GlobalExceptionHandler.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..7b16970 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package com.ticketflow.user.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + private ResponseEntity> errorResponse(String code, String message, HttpStatus status) { + return ResponseEntity.status(status) + .body(Map.of("error", Map.of("code", code, "message", message))); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + return errorResponse("VALIDATION_ERROR", message, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return errorResponse("CONFLICT", ex.getMessage(), HttpStatus.CONFLICT); + } + + @ExceptionHandler(UsernameNotFoundException.class) + public ResponseEntity> handleNotFound(UsernameNotFoundException ex) { + return errorResponse("NOT_FOUND", ex.getMessage(), HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> handleBadCredentials(BadCredentialsException ex) { + return errorResponse("UNAUTHORIZED", ex.getMessage(), HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return errorResponse("INTERNAL_ERROR", "An internal error occurred", HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/AuthResponse.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/AuthResponse.java new file mode 100644 index 0000000..ed18feb --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/AuthResponse.java @@ -0,0 +1,6 @@ +package com.ticketflow.user.dto; + +public record AuthResponse( + String token, + UserDTO user +) {} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/LoginRequest.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/LoginRequest.java new file mode 100644 index 0000000..6cf739c --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/LoginRequest.java @@ -0,0 +1,9 @@ +package com.ticketflow.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank @Email String email, + @NotBlank String password +) {} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/RegisterRequest.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/RegisterRequest.java new file mode 100644 index 0000000..2ecd7a9 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/RegisterRequest.java @@ -0,0 +1,11 @@ +package com.ticketflow.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank String name, + @NotBlank @Email String email, + @NotBlank @Size(min = 8) String password +) {} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/UserDTO.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/UserDTO.java new file mode 100644 index 0000000..a59862d --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/dto/UserDTO.java @@ -0,0 +1,8 @@ +package com.ticketflow.user.dto; + +public record UserDTO( + String id, + String name, + String email, + String role +) {} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/entity/User.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/entity/User.java new file mode 100644 index 0000000..112c716 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/entity/User.java @@ -0,0 +1,82 @@ +package com.ticketflow.user.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "users") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + @Builder.Default + private String role = "USER"; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + role)); + } + + @Override + public String getUsername() { + return email; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/event/UserRegisteredEvent.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/event/UserRegisteredEvent.java new file mode 100644 index 0000000..2d2c508 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/event/UserRegisteredEvent.java @@ -0,0 +1,19 @@ +package com.ticketflow.user.event; + +public record UserRegisteredEvent( + String eventType, + String userId, + String email, + String name, + String timestamp +) { + public UserRegisteredEvent(String userId, String email, String name) { + this( + "user.registered", + userId, + email, + name, + java.time.Instant.now().toString() + ); + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/repository/UserRepository.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/repository/UserRepository.java new file mode 100644 index 0000000..7e65ec7 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.ticketflow.user.repository; + +import com.ticketflow.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/security/JwtUtil.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/security/JwtUtil.java new file mode 100644 index 0000000..55d8f08 --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/security/JwtUtil.java @@ -0,0 +1,53 @@ +package com.ticketflow.user.security; + +import com.ticketflow.user.entity.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; + +@Slf4j +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final long expiration; + + public JwtUtil( + @Value("${jwt.secret}") String secret, + @Value("${jwt.expiration}") long expiration) { + byte[] keyBytes = Base64.getEncoder().encode(secret.getBytes(StandardCharsets.UTF_8)); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + this.expiration = expiration; + } + + public String generateToken(User user) { + return Jwts.builder() + .subject(user.getId()) + .claim("email", user.getEmail()) + .claim("role", user.getRole()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(secretKey) + .compact(); + } + + public Claims validateToken(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String extractUserId(String token) { + return validateToken(token).getSubject(); + } +} diff --git a/ticketflow/services/user-service/src/main/java/com/ticketflow/user/service/AuthService.java b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/service/AuthService.java new file mode 100644 index 0000000..e34df8b --- /dev/null +++ b/ticketflow/services/user-service/src/main/java/com/ticketflow/user/service/AuthService.java @@ -0,0 +1,73 @@ +package com.ticketflow.user.service; + +import com.ticketflow.user.dto.AuthResponse; +import com.ticketflow.user.dto.LoginRequest; +import com.ticketflow.user.dto.RegisterRequest; +import com.ticketflow.user.dto.UserDTO; +import com.ticketflow.user.entity.User; +import com.ticketflow.user.event.UserRegisteredEvent; +import com.ticketflow.user.repository.UserRepository; +import com.ticketflow.user.security.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final KafkaTemplate kafkaTemplate; + + @Transactional + public AuthResponse register(RegisterRequest request) { + if (userRepository.existsByEmail(request.email())) { + throw new IllegalArgumentException("Email already registered: " + request.email()); + } + + User user = User.builder() + .name(request.name()) + .email(request.email()) + .password(passwordEncoder.encode(request.password())) + .role("USER") + .build(); + + user = userRepository.save(user); + log.info("Registered new user with id={}", user.getId()); + + UserRegisteredEvent event = new UserRegisteredEvent(user.getId(), user.getEmail(), user.getName()); + kafkaTemplate.send("ticketflow.user.registered", user.getId(), event); + log.info("Published user.registered event for userId={}", user.getId()); + + String token = jwtUtil.generateToken(user); + UserDTO userDTO = new UserDTO(user.getId(), user.getName(), user.getEmail(), user.getRole()); + return new AuthResponse(token, userDTO); + } + + public AuthResponse login(LoginRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + request.email())); + + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + throw new BadCredentialsException("Invalid credentials"); + } + + String token = jwtUtil.generateToken(user); + UserDTO userDTO = new UserDTO(user.getId(), user.getName(), user.getEmail(), user.getRole()); + return new AuthResponse(token, userDTO); + } + + public UserDTO getMe(String userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UsernameNotFoundException("User not found with id: " + userId)); + return new UserDTO(user.getId(), user.getName(), user.getEmail(), user.getRole()); + } +} diff --git a/ticketflow/services/user-service/src/main/resources/application.yml b/ticketflow/services/user-service/src/main/resources/application.yml new file mode 100644 index 0000000..f97cb49 --- /dev/null +++ b/ticketflow/services/user-service/src/main/resources/application.yml @@ -0,0 +1,35 @@ +server: + port: ${PORT:3001} +spring: + application: + name: user-service + datasource: + url: ${USER_DB_URL:jdbc:postgresql://localhost:5432/ticketflow_users} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.add.type.headers: false +jwt: + secret: ${JWT_SECRET:default-secret-change-in-production-min-32-chars} + expiration: ${JWT_EXPIRATION:604800000} +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always diff --git a/ticketflow/services/user-service/src/routes/auth.routes.ts b/ticketflow/services/user-service/src/routes/auth.routes.ts deleted file mode 100644 index 4a7fef2..0000000 --- a/ticketflow/services/user-service/src/routes/auth.routes.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Router } from 'express'; -import rateLimit from 'express-rate-limit'; -import { requireAuth } from '@ticketflow/shared'; -import { register, login, getMe } from '../controllers/auth.controller'; -import { validate } from '@ticketflow/shared'; -import { registerSchema, loginSchema } from '../schemas/auth.schema'; - -export const authRouter = Router(); - -const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 20, - standardHeaders: true, - legacyHeaders: false, - message: { error: { code: 'TOO_MANY_REQUESTS', message: 'Too many requests, please try again later.' } }, -}); - -const generalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - standardHeaders: true, - legacyHeaders: false, - message: { error: { code: 'TOO_MANY_REQUESTS', message: 'Too many requests, please try again later.' } }, -}); - -authRouter.post('/register', authLimiter, validate(registerSchema), register); -authRouter.post('/login', authLimiter, validate(loginSchema), login); -authRouter.get('/me', generalLimiter, requireAuth, getMe); diff --git a/ticketflow/services/user-service/src/schemas/auth.schema.ts b/ticketflow/services/user-service/src/schemas/auth.schema.ts deleted file mode 100644 index 9cd9376..0000000 --- a/ticketflow/services/user-service/src/schemas/auth.schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod'; - -export const registerSchema = z.object({ - name: z.string().min(1, 'Name is required').max(100), - email: z.string().email('Invalid email address'), - password: z.string().min(8, 'Password must be at least 8 characters'), -}); - -export const loginSchema = z.object({ - email: z.string().email('Invalid email address'), - password: z.string().min(1, 'Password is required'), -}); - -export type RegisterInput = z.infer; -export type LoginInput = z.infer; diff --git a/ticketflow/services/user-service/src/services/auth.service.test.ts b/ticketflow/services/user-service/src/services/auth.service.test.ts deleted file mode 100644 index 91af82d..0000000 --- a/ticketflow/services/user-service/src/services/auth.service.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('authService', () => { - it('tests will be reworked for Drizzle query mocking', () => { - expect(true).toBe(true); - }); -}); diff --git a/ticketflow/services/user-service/src/services/auth.service.ts b/ticketflow/services/user-service/src/services/auth.service.ts deleted file mode 100644 index 37fb294..0000000 --- a/ticketflow/services/user-service/src/services/auth.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import bcrypt from 'bcryptjs'; -import jwt from 'jsonwebtoken'; -import { randomUUID } from 'crypto'; -import { eq } from 'drizzle-orm'; -import { AppError, JwtPayload } from '@ticketflow/shared'; -import { db } from '../db/client'; -import { users } from '../db/schema'; - -interface RegisterInput { - name: string; - email: string; - password: string; -} - -interface LoginInput { - email: string; - password: string; -} - -function normalizeRole(role: string): 'USER' | 'ADMIN' { - return role === 'ADMIN' ? 'ADMIN' : 'USER'; -} - -function signToken(payload: Omit): string { - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error('JWT_SECRET not configured'); - return jwt.sign(payload, secret, { expiresIn: '7d' }); -} - -function sanitizeUser(user: { id: string; name: string; email: string; role: string; createdAt: Date | string; updatedAt: Date | string }) { - return { - id: user.id, - name: user.name, - email: user.email, - role: user.role, - createdAt: new Date(user.createdAt).toISOString(), - updatedAt: new Date(user.updatedAt).toISOString(), - }; -} - -export const authService = { - async register(input: RegisterInput) { - const existing = await db.select().from(users).where(eq(users.email, input.email)).limit(1); - if (existing.length > 0) { - throw new AppError(409, 'EMAIL_TAKEN', 'Email is already registered'); - } - const hashedPassword = await bcrypt.hash(input.password, 12); - const inserted = await db - .insert(users) - .values({ - id: randomUUID(), - name: input.name, - email: input.email, - password: hashedPassword, - role: 'USER', - }) - .returning(); - const user = inserted[0]; - const token = signToken({ sub: user.id, email: user.email, role: normalizeRole(user.role) }); - return { user: sanitizeUser(user), token }; - }, - - async login(input: LoginInput) { - const found = await db.select().from(users).where(eq(users.email, input.email)).limit(1); - const user = found[0]; - if (!user) { - throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid email or password'); - } - const valid = await bcrypt.compare(input.password, user.password); - if (!valid) { - throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid email or password'); - } - const token = signToken({ sub: user.id, email: user.email, role: normalizeRole(user.role) }); - return { token }; - }, - - async getById(id: string) { - const found = await db.select().from(users).where(eq(users.id, id)).limit(1); - const user = found[0]; - if (!user) { - throw new AppError(404, 'USER_NOT_FOUND', 'User not found'); - } - return sanitizeUser(user); - }, -}; diff --git a/ticketflow/services/user-service/tsconfig.json b/ticketflow/services/user-service/tsconfig.json deleted file mode 100644 index 5f1c396..0000000 --- a/ticketflow/services/user-service/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] -} diff --git a/ticketflow/shared/.gitignore b/ticketflow/shared/.gitignore deleted file mode 100644 index b947077..0000000 --- a/ticketflow/shared/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/ticketflow/shared/package-lock.json b/ticketflow/shared/package-lock.json deleted file mode 100644 index 381dfc1..0000000 --- a/ticketflow/shared/package-lock.json +++ /dev/null @@ -1,1118 +0,0 @@ -{ - "name": "@ticketflow/shared", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@ticketflow/shared", - "version": "1.0.0", - "dependencies": { - "express": "^4.18.2", - "jsonwebtoken": "^9.0.2", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.5", - "typescript": "^5.3.3" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.10", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", - "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*", - "@types/node": "*" - } - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/ticketflow/shared/package.json b/ticketflow/shared/package.json deleted file mode 100644 index e7eb30c..0000000 --- a/ticketflow/shared/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@ticketflow/shared", - "version": "1.0.0", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc", - "dev": "tsc --watch" - }, - "dependencies": { - "express": "^4.18.2", - "jsonwebtoken": "^9.0.2", - "zod": "^3.22.4" - }, - "devDependencies": { - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.5", - "typescript": "^5.3.3" - } -} diff --git a/ticketflow/shared/src/events/index.ts b/ticketflow/shared/src/events/index.ts deleted file mode 100644 index 4140b64..0000000 --- a/ticketflow/shared/src/events/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -export const Events = { - BOOKING_CONFIRMED: 'booking.confirmed', - BOOKING_CANCELLED: 'booking.cancelled', - PAYMENT_SUCCESS: 'payment.success', - PAYMENT_FAILED: 'payment.failed', - NOTIFY_SEND: 'notify.send', -} as const; - -export type EventName = (typeof Events)[keyof typeof Events]; - -export interface BookingConfirmedPayload { - bookingId: string; - userId: string; - userEmail: string; - eventId: string; - eventName: string; - seatIds: string[]; - totalAmount: number; - confirmedAt: string; -} - -export interface PaymentFailedPayload { - bookingId: string; - userId: string; - userEmail: string; - eventId: string; - eventName: string; - seatIds: string[]; - amount: number; - failedAt: string; - reason: string; -} - -export interface RabbitMQMessage { - event: EventName; - payload: T; - timestamp: string; -} diff --git a/ticketflow/shared/src/index.ts b/ticketflow/shared/src/index.ts deleted file mode 100644 index 256ded2..0000000 --- a/ticketflow/shared/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './events'; -export * from './types'; -export * from './middleware/errorHandler'; -export * from './middleware/validate'; -export * from './middleware/auth'; diff --git a/ticketflow/shared/src/middleware/auth.ts b/ticketflow/shared/src/middleware/auth.ts deleted file mode 100644 index fb86086..0000000 --- a/ticketflow/shared/src/middleware/auth.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import jwt from 'jsonwebtoken'; -import { JwtPayload } from '../types'; - -declare global { - namespace Express { - interface Request { - user?: JwtPayload; - } - } -} - -export function requireAuth(req: Request, res: Response, next: NextFunction): void { - const authHeader = req.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) { - res.status(401).json({ - error: { code: 'UNAUTHORIZED', message: 'Missing or invalid authorization header' }, - }); - return; - } - - const token = authHeader.slice(7); - const secret = process.env.JWT_SECRET; - if (!secret) { - res.status(500).json({ - error: { code: 'INTERNAL_SERVER_ERROR', message: 'JWT secret not configured' }, - }); - return; - } - - try { - const decoded = jwt.verify(token, secret) as JwtPayload; - req.user = decoded; - next(); - } catch { - res.status(401).json({ - error: { code: 'INVALID_TOKEN', message: 'Token is invalid or expired' }, - }); - } -} - -export function requireAdmin(req: Request, res: Response, next: NextFunction): void { - requireAuth(req, res, () => { - if (req.user?.role !== 'ADMIN') { - res.status(403).json({ - error: { code: 'FORBIDDEN', message: 'Admin access required' }, - }); - return; - } - next(); - }); -} diff --git a/ticketflow/shared/src/middleware/errorHandler.ts b/ticketflow/shared/src/middleware/errorHandler.ts deleted file mode 100644 index 8cf1d92..0000000 --- a/ticketflow/shared/src/middleware/errorHandler.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; - -export class AppError extends Error { - constructor( - public readonly statusCode: number, - public readonly code: string, - message: string, - public readonly details?: Record - ) { - super(message); - this.name = 'AppError'; - Object.setPrototypeOf(this, new.target.prototype); - } -} - -export function errorHandler( - err: unknown, - _req: Request, - res: Response, - _next: NextFunction -): void { - if (err instanceof AppError) { - res.status(err.statusCode).json({ - error: { - code: err.code, - message: err.message, - ...(err.details ? { details: err.details } : {}), - }, - }); - return; - } - - if (err instanceof Error) { - console.error('Unhandled error:', err); - res.status(500).json({ - error: { - code: 'INTERNAL_SERVER_ERROR', - message: 'An unexpected error occurred', - }, - }); - return; - } - - res.status(500).json({ - error: { - code: 'INTERNAL_SERVER_ERROR', - message: 'An unexpected error occurred', - }, - }); -} diff --git a/ticketflow/shared/src/middleware/validate.ts b/ticketflow/shared/src/middleware/validate.ts deleted file mode 100644 index 7ccde1a..0000000 --- a/ticketflow/shared/src/middleware/validate.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { ZodSchema, ZodError } from 'zod'; - -export function validate(schema: ZodSchema) { - return (req: Request, res: Response, next: NextFunction): void => { - const result = schema.safeParse(req.body); - if (!result.success) { - const zodError = result.error as ZodError; - res.status(400).json({ - error: { - code: 'VALIDATION_ERROR', - message: 'Request validation failed', - details: { - fields: zodError.errors.map((e) => ({ - path: e.path.join('.'), - message: e.message, - })), - }, - }, - }); - return; - } - req.body = result.data; - next(); - }; -} diff --git a/ticketflow/shared/src/types/index.ts b/ticketflow/shared/src/types/index.ts deleted file mode 100644 index aacf43e..0000000 --- a/ticketflow/shared/src/types/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -export interface User { - id: string; - name: string; - email: string; - role: 'USER' | 'ADMIN'; - createdAt: string; - updatedAt: string; -} - -export interface JwtPayload { - sub: string; - email: string; - role: 'USER' | 'ADMIN'; - iat?: number; - exp?: number; -} - -export interface Venue { - id: string; - name: string; - address: string; - city: string; - country: string; - capacity: number; - createdAt: string; - updatedAt: string; -} - -export interface Event { - id: string; - name: string; - description: string; - venueId: string; - venue?: Venue; - date: string; - price: number; - totalSeats: number; - createdAt: string; - updatedAt: string; -} - -export interface Seat { - id: string; - eventId: string; - seatNumber: string; - row: string; - status: 'AVAILABLE' | 'LOCKED' | 'RESERVED'; - createdAt: string; - updatedAt: string; -} - -export interface Booking { - id: string; - userId: string; - eventId: string; - status: 'PENDING' | 'CONFIRMED' | 'FAILED' | 'CANCELLED'; - totalAmount: number; - items: BookingItem[]; - createdAt: string; - updatedAt: string; -} - -export interface BookingItem { - id: string; - bookingId: string; - seatId: string; -} - -export interface Payment { - id: string; - bookingId: string; - amount: number; - currency: string; - status: 'PENDING' | 'SUCCESS' | 'FAILED'; - createdAt: string; - updatedAt: string; -} - -export interface ApiError { - error: { - code: string; - message: string; - details?: Record; - }; -} diff --git a/ticketflow/shared/tsconfig.json b/ticketflow/shared/tsconfig.json deleted file mode 100644 index 13cae44..0000000 --- a/ticketflow/shared/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} From d6188b59d4888415d693b0a187fb46b2b402fb2f Mon Sep 17 00:00:00 2001 From: Om Lanke Date: Tue, 21 Apr 2026 00:25:51 +0530 Subject: [PATCH 2/6] feat: add health check endpoints for booking and user services - Implemented HealthController in booking-service to return service status. - Added HealthController in user-service for health check response. - Updated SecurityConfig in user-service to permit access to the new health endpoint. - Introduced .dockerignore files for various services to exclude unnecessary files. - Updated motor dependency version in requirements.txt for event, notification, and payment services. - Refactored EventPage component in frontend to improve busy state handling. --- AGENT.md | 382 -- AGENTS.md | 137 + INSTRUCTIONS.md | 230 - PROMPT.md | 107 - README.md | 255 - ticketflow/docker-compose.dev.yml | 22 +- ticketflow/frontend/.dockerignore | 2 + ticketflow/frontend/package-lock.json | 5205 +++++++++++++++++ ticketflow/frontend/src/pages/EventPage.tsx | 6 +- ticketflow/gateway/.dockerignore | 1 + ticketflow/gateway/src/app.ts | 1 + .../services/booking-service/.dockerignore | 1 + .../booking/controller/HealthController.java | 16 + .../services/event-service/.dockerignore | 5 + .../services/event-service/requirements.txt | 2 +- .../services/inventory-service/.dockerignore | 1 + .../notification-service/.dockerignore | 5 + .../notification-service/requirements.txt | 2 +- .../services/payment-service/.dockerignore | 5 + .../services/payment-service/requirements.txt | 2 +- .../services/user-service/.dockerignore | 1 + .../user/config/SecurityConfig.java | 2 +- .../user/controller/HealthController.java | 16 + 23 files changed, 5414 insertions(+), 992 deletions(-) delete mode 100644 AGENT.md create mode 100644 AGENTS.md delete mode 100644 INSTRUCTIONS.md delete mode 100644 PROMPT.md delete mode 100644 README.md create mode 100644 ticketflow/frontend/.dockerignore create mode 100644 ticketflow/frontend/package-lock.json create mode 100644 ticketflow/gateway/.dockerignore create mode 100644 ticketflow/services/booking-service/.dockerignore create mode 100644 ticketflow/services/booking-service/src/main/java/com/ticketflow/booking/controller/HealthController.java create mode 100644 ticketflow/services/event-service/.dockerignore create mode 100644 ticketflow/services/inventory-service/.dockerignore create mode 100644 ticketflow/services/notification-service/.dockerignore create mode 100644 ticketflow/services/payment-service/.dockerignore create mode 100644 ticketflow/services/user-service/.dockerignore create mode 100644 ticketflow/services/user-service/src/main/java/com/ticketflow/user/controller/HealthController.java diff --git a/AGENT.md b/AGENT.md deleted file mode 100644 index 85a006f..0000000 --- a/AGENT.md +++ /dev/null @@ -1,382 +0,0 @@ -# TicketFlow — Agent Context - -## What You Are Building - -A distributed event ticket booking platform implemented as microservices. Each service is an independent Node.js/Express application with its own database. They communicate synchronously via REST and asynchronously via a RabbitMQ message bus. - -## Technology Stack - -| Layer | Choice | -|-------|--------| -| Runtime | Node.js 20 + TypeScript | -| Framework | Express 4 | -| ORM | Prisma (per-service schema) | -| Database | PostgreSQL 15 (one DB per service, separate schemas) | -| Cache / Locking | Redis 7 | -| Message Bus | RabbitMQ 3 | -| API Gateway | Express + `http-proxy-middleware` | -| Auth | JWT (jsonwebtoken) | -| Validation | Zod | -| Testing | Jest + Supertest | -| Package Manager | pnpm workspaces | -| Containerization | Docker + Docker Compose | - ---- - -## Folder Structure to Generate - -``` -ticketflow/ -├── .env.example -├── docker-compose.yml # production -├── docker-compose.dev.yml # dev (exposes ports, adds MailHog) -├── pnpm-workspace.yaml -├── package.json # root (scripts only) -│ -├── gateway/ -│ ├── package.json -│ ├── tsconfig.json -│ └── src/ -│ ├── index.ts # Express app, proxy routes -│ ├── middleware/ -│ │ ├── auth.ts # JWT verify, attach req.user -│ │ └── rateLimiter.ts -│ └── routes.ts # Route-to-service mapping -│ -├── shared/ -│ ├── package.json -│ ├── events/ -│ │ └── index.ts # BOOKING_CONFIRMED, PAYMENT_SUCCESS etc. -│ ├── middleware/ -│ │ ├── errorHandler.ts -│ │ └── validate.ts # Zod middleware wrapper -│ └── types/ -│ └── index.ts # User, Event, Booking, Seat interfaces -│ -├── services/ -│ │ -│ ├── user-service/ -│ │ ├── package.json -│ │ ├── tsconfig.json -│ │ ├── prisma/ -│ │ │ └── schema.prisma # User model -│ │ └── src/ -│ │ ├── index.ts -│ │ ├── routes/ -│ │ │ └── auth.routes.ts # POST /register, POST /login, GET /me -│ │ ├── controllers/ -│ │ │ └── auth.controller.ts -│ │ ├── services/ -│ │ │ └── auth.service.ts # bcrypt, JWT sign/verify -│ │ └── schemas/ -│ │ └── auth.schema.ts # Zod schemas -│ │ -│ ├── event-service/ -│ │ ├── package.json -│ │ ├── tsconfig.json -│ │ ├── prisma/ -│ │ │ └── schema.prisma # Event, Venue, Schedule models -│ │ └── src/ -│ │ ├── index.ts -│ │ ├── routes/ -│ │ │ └── event.routes.ts # GET /, GET /:id, POST / (admin) -│ │ ├── controllers/ -│ │ │ └── event.controller.ts -│ │ ├── services/ -│ │ │ └── event.service.ts -│ │ └── seed.ts -│ │ -│ ├── inventory-service/ -│ │ ├── package.json -│ │ ├── tsconfig.json -│ │ ├── prisma/ -│ │ │ └── schema.prisma # Seat, SeatStatus models -│ │ └── src/ -│ │ ├── index.ts -│ │ ├── routes/ -│ │ │ └── inventory.routes.ts -│ │ ├── controllers/ -│ │ │ └── inventory.controller.ts -│ │ ├── services/ -│ │ │ └── inventory.service.ts # Redis SETNX locking logic -│ │ └── redis/ -│ │ └── client.ts -│ │ -│ ├── booking-service/ -│ │ ├── package.json -│ │ ├── tsconfig.json -│ │ ├── prisma/ -│ │ │ └── schema.prisma # Booking, BookingItem models -│ │ └── src/ -│ │ ├── index.ts -│ │ ├── routes/ -│ │ │ └── booking.routes.ts # POST /, GET /:id, POST /:id/confirm -│ │ ├── controllers/ -│ │ │ └── booking.controller.ts -│ │ ├── services/ -│ │ │ └── booking.service.ts # Orchestrates inventory + payment calls -│ │ └── messaging/ -│ │ └── publisher.ts # Publishes BOOKING_CONFIRMED to RabbitMQ -│ │ -│ ├── payment-service/ -│ │ ├── package.json -│ │ ├── tsconfig.json -│ │ ├── prisma/ -│ │ │ └── schema.prisma # Payment, PaymentStatus models -│ │ └── src/ -│ │ ├── index.ts -│ │ ├── routes/ -│ │ │ └── payment.routes.ts # POST /charge, GET /:id -│ │ ├── controllers/ -│ │ │ └── payment.controller.ts -│ │ └── services/ -│ │ └── payment.service.ts # Mock Stripe: random success/fail -│ │ -│ └── notification-service/ -│ ├── package.json -│ ├── tsconfig.json -│ └── src/ -│ ├── index.ts -│ ├── consumer.ts # RabbitMQ consumer loop -│ ├── handlers/ -│ │ └── booking.handler.ts # Handles BOOKING_CONFIRMED event -│ └── mailer/ -│ └── index.ts # Nodemailer + MailHog in dev -``` - ---- - -## Service Contracts - -### User Service (`PORT=3001`) - -``` -POST /api/users/register { name, email, password } → { user, token } -POST /api/users/login { email, password } → { token } -GET /api/users/me [Auth] → { user } -``` - -### Event Service (`PORT=3002`) - -``` -GET /api/events [public] → Event[] -GET /api/events/:id [public] → Event -POST /api/events [admin] → Event -PUT /api/events/:id [admin] → Event -``` - -### Inventory Service (`PORT=3004`) - -``` -GET /api/inventory/events/:eventId/seats → Seat[] -POST /api/inventory/lock { seatIds, bookingId, ttlSeconds } → { locked: boolean } -POST /api/inventory/release { seatIds } → { ok: boolean } -POST /api/inventory/confirm { seatIds } → { ok: boolean } -``` - -### Booking Service (`PORT=3003`) - -``` -POST /api/bookings [Auth] { eventId, seatIds } → Booking (PENDING) -GET /api/bookings/:id [Auth] → Booking -GET /api/bookings/my [Auth] → Booking[] -POST /api/bookings/:id/confirm [Auth] → Booking (CONFIRMED) -POST /api/bookings/:id/cancel [Auth] → Booking (CANCELLED) -``` - -### Payment Service (`PORT=3005`) - -``` -POST /api/payments/charge { bookingId, amount, currency } → Payment -GET /api/payments/:id → Payment -``` - -### Notification Service (`PORT=3006`) - -``` -# No HTTP API — pure RabbitMQ consumer -# Listens on queue: notifications -# Handles events: BOOKING_CONFIRMED, PAYMENT_FAILED -``` - ---- - -## Critical Implementation Rules - -### 1. Seat Locking (Inventory Service) - -Seat locking MUST be atomic. Use Redis `SETNX` with a TTL: - -```typescript -// Lock a seat (returns false if already locked by someone else) -async function lockSeat(seatId: string, bookingId: string, ttl = 300): Promise { - const key = `seat:lock:${seatId}`; - const result = await redis.set(key, bookingId, 'NX', 'EX', ttl); - return result === 'OK'; -} -``` - -- Lock TTL: 5 minutes (300 seconds) -- If ANY seat in a batch fails to lock, release ALL already-locked seats and return 409 -- On booking cancellation, release all seat locks - -### 2. Booking Orchestration (Booking Service) - -The booking service must follow this exact sequence: - -``` -1. Validate request (Zod) -2. Check event exists (call Event Service) -3. Attempt seat locks (call Inventory Service) - → If 409: return 409 immediately, do NOT create booking record -4. Create PENDING booking record in DB -5. Call Payment Service - → If payment fails: release seat locks, mark booking FAILED, return 402 -6. Confirm seat reservation (call Inventory Service /confirm) -7. Mark booking CONFIRMED in DB -8. Publish BOOKING_CONFIRMED event to RabbitMQ -9. Return booking to client -``` - -### 3. RabbitMQ Events - -Use this shared event schema (from `shared/events`): - -```typescript -export const Events = { - BOOKING_CONFIRMED: 'booking.confirmed', - BOOKING_CANCELLED: 'booking.cancelled', - PAYMENT_SUCCESS: 'payment.success', - PAYMENT_FAILED: 'payment.failed', - NOTIFY_SEND: 'notify.send', -} as const; - -export interface BookingConfirmedPayload { - bookingId: string; - userId: string; - userEmail: string; - eventId: string; - eventName: string; - seatIds: string[]; - totalAmount: number; - confirmedAt: string; // ISO string -} -``` - -### 4. Error Handling Standards - -Every service must use the shared `errorHandler` middleware. All errors should follow: - -```json -{ - "error": { - "code": "SEAT_LOCKED", - "message": "One or more seats are unavailable", - "details": { "conflictingSeatIds": ["A1"] } - } -} -``` - -HTTP status codes: -- `400` Bad Request (validation) -- `401` Unauthorized (missing/invalid JWT) -- `403` Forbidden (insufficient permissions) -- `404` Not Found -- `409` Conflict (seat already locked) -- `402` Payment Required (payment failed) -- `500` Internal Server Error - -### 5. Service-to-Service Communication - -Services call each other via HTTP using `axios`. Use environment variables for URLs: - -```typescript -const INVENTORY_URL = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3004'; -``` - -No service should ever import code from another service's folder — only through `shared/`. - ---- - -## Prisma Schema Conventions - -All schemas must follow: -- `id String @id @default(cuid())` -- `createdAt DateTime @default(now())` -- `updatedAt DateTime @updatedAt` -- Enum values in SCREAMING_SNAKE_CASE - -Example booking model: - -```prisma -enum BookingStatus { - PENDING - CONFIRMED - FAILED - CANCELLED -} - -model Booking { - id String @id @default(cuid()) - userId String - eventId String - status BookingStatus @default(PENDING) - totalAmount Decimal @db.Decimal(10, 2) - items BookingItem[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model BookingItem { - id String @id @default(cuid()) - bookingId String - seatId String - booking Booking @relation(fields: [bookingId], references: [id]) -} -``` - ---- - -## Docker Compose Services (dev) - -```yaml -# docker-compose.dev.yml must define: -- postgres:15 # single instance, multiple databases via init scripts -- redis:7-alpine -- rabbitmq:3-management # exposes :5672 and :15672 (management UI) -- axllent/mailpit # SMTP mock, UI at :8025 -``` - ---- - -## Testing Requirements - -Each service needs: -1. **Unit tests** for service layer (mock Prisma, Redis, RabbitMQ) -2. **Integration tests** for HTTP routes (Supertest, real Prisma against test DB) - -The booking service needs a specific concurrency test: - -```typescript -it('should only allow one booking per seat under concurrent load', async () => { - const promises = Array.from({ length: 20 }, () => - request(app).post('/api/bookings').send({ eventId, seatIds: ['A1'] }) - ); - const results = await Promise.all(promises); - const successes = results.filter(r => r.status === 201); - const conflicts = results.filter(r => r.status === 409); - expect(successes).toHaveLength(1); - expect(conflicts).toHaveLength(19); -}); -``` - ---- - -## What NOT to Do - -- Do NOT share a database between services -- Do NOT import from another service's `src/` directly -- Do NOT use synchronous locks for seat locking — only Redis atomic operations -- Do NOT skip the PENDING state — booking must go PENDING before payment -- Do NOT publish RabbitMQ events before the DB write is committed -- Do NOT use `any` TypeScript types — every function must be fully typed diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..626a435 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# AGENTS.md + +All real work lives in `ticketflow/`. Run every `make` command from there. + +--- + +## Quick start + +```bash +cd ticketflow +make setup # copies .env.example → .env (safe to re-run) +make dev # builds + starts full stack in Docker +make health # verify all services are up via gateway aggregate +make seed # insert demo venues + events (runs inside event-service container) +``` + +Demo credentials after seeding: `test@ticketflow.dev` / `Test1234!` + +--- + +## Service map + +| Service | Language | Port | Database | +|---|---|---|---| +| gateway | Bun + Elysia | 3000 | — | +| user-service | Java 21 / Spring Boot 3.2 | 3001 | PostgreSQL `ticketflow_users` | +| event-service | Python 3.12 / FastAPI | 3002 | MongoDB `ticketflow_events` | +| booking-service | Java 21 / Spring Boot 3.2 | 3003 | PostgreSQL `ticketflow_bookings` | +| inventory-service | Bun + Elysia + Drizzle ORM | 3004 | PostgreSQL `ticketflow_inventory` + Redis | +| payment-service | Python 3.12 / FastAPI | 3005 | MongoDB `ticketflow_payments` | +| notification-service | Python 3.12 / FastAPI | 3006 | MongoDB `ticketflow_notifications` | +| frontend | React 18 + Vite | 5173 (dev) / 80 (prod Nginx) | — | + +Dev-only UIs: Kafka UI `:8080`, Mongo Express `:8081`, Mailpit `:8025` + +Observability (Grafana `:3010`, Prometheus `:9090`, Jaeger `:16686`) is **Kubernetes-only** — not present in either Docker Compose file. + +--- + +## Makefile shortcuts worth knowing + +```bash +make dev-infra # start infra (Kafka, DBs, Redis) without app services +make restart s= # restart one service, e.g. make restart s=booking-service +make rebuild s= # rebuild image + restart +make logs-booking # per-service log tails (logs-gateway, logs-user, logs-event, etc.) +make lint # bun tsc --noEmit (gateway + inventory) + py_compile (Python services) +make test # mvn test -q for user-service + booking-service +make kafka-consume t= +make kafka-lag g= +make psql-bookings # psql into ticketflow_bookings +make mongo-shell +make demo # bash scripts/demo.sh — end-to-end curl walkthrough +make k8s-apply # deploy everything to Kubernetes +``` + +--- + +## Non-obvious architecture facts + +### JWT: decoded at gateway, not verified +The gateway (`gateway/src/middleware/auth.ts`) uses `jose` `decodeJwt()` — this is **decode only, no signature verification**. It injects `x-user-id`, `x-user-email`, `x-user-role` headers. Downstream services trust these headers unconditionally. `JWT_SECRET` is only used inside `user-service` (Spring Security) and `booking-service`. + +### Async booking saga (choreography) +`POST /bookings` returns **202 Accepted** with `{ bookingId }`. Client must poll `GET /bookings/:id` until `status !== "PENDING"`. The full flow: + +``` +booking-service → booking.initiated → inventory-service (Redis SETNX lock) + → seats.locked → booking-service + → payment.requested → payment-service + → payment.processed → booking-service + → booking.confirmed + seats.confirm → inventory-service (RESERVED) + (or booking.failed + seats.release) +``` + +Kafka message key = `bookingId` — guarantees ordering per booking on a single partition. + +### Error response envelope +All services return: `{ "error": { "code": "SCREAMING_SNAKE", "message": "Human readable" } }` + +### Kafka event envelope +All Kafka messages: `{ "eventType": "string", ...domainFields, "timestamp": "ISO8601" }` + +--- + +## Gotchas + +**Kafka external port is 9093, not 9092.** For services running outside Docker (local dev): `KAFKA_BOOTSTRAP_SERVERS=localhost:9093`. Inside Docker it is `kafka:9092`. + +**Redis has no password in dev.** `REDIS_URL=redis://redis:6379` in dev compose. In prod it is `redis://:${REDIS_PASSWORD}@redis:6379`. + +**Payment is mocked.** `PAYMENT_SUCCESS_RATE=0.95` controls success probability. Set to `1.0` for deterministic success. There is no real Stripe integration wired up. + +**Inventory service DB migration must be run explicitly.** On first local run outside Docker: `bun src/db/migrate.ts` from `services/inventory-service/`. + +**Production compose file is `docker-compose.yml`**, not `docker-compose.prod.yml` (the README's directory tree mislabels it — trust the file on disk). + +**`JWT_EXPIRATION` in user-service is milliseconds** (`604800000` = 7 days). The `.env.example` also documents `JWT_EXPIRY_HOURS` but that variable is not consumed by the compose files — only `JWT_EXPIRATION` (ms) is passed to the container. + +**`make dev-clean` deletes all volumes.** Data is lost. Requires re-running `make seed`. + +**No CI exists.** There is no `.github/` directory. No pre-commit hooks. + +--- + +## Kafka topics + +Convention: `ticketflow..` (lowercase, dots) + +Main topics (3 partitions, 7-day retention): +`ticketflow.user.registered`, `ticketflow.event.created`, `ticketflow.booking.initiated`, `ticketflow.seats.locked`, `ticketflow.seats.lock-failed`, `ticketflow.seats.confirm`, `ticketflow.seats.release`, `ticketflow.payment.requested`, `ticketflow.payment.processed`, `ticketflow.booking.confirmed`, `ticketflow.booking.failed`, `ticketflow.booking.cancelled` + +DLTs (1 partition, 30-day retention): +`ticketflow.booking.initiated.DLT`, `ticketflow.payment.requested.DLT`, `ticketflow.booking.confirmed.DLT`, `ticketflow.booking.failed.DLT` + +In dev `KAFKA_AUTO_CREATE_TOPICS_ENABLE=true`. In prod it is `false` — `kafka-init` creates all topics via `infra/kafka/create-topics.sh` on startup. + +--- + +## Python service conventions + +- ODM: **Beanie** (wraps Motor) for all MongoDB models +- Kafka client: **aiokafka** +- Email: **aiosmtplib** + **Jinja2** templates in `app/mailer/templates/` +- Start command (all three): `uvicorn app.main:app --host 0.0.0.0 --port ` + +## Java service conventions + +- Build: Maven (`pom.xml` at each service root); `mvn spring-boot:run` or fat jar +- JDK 21, Spring Boot 3.2.5, Lombok, jjwt 0.12.5, Spring Data JPA + Flyway +- No shared parent POM — each service has its own + +## Bun/TypeScript conventions (gateway + inventory-service) + +- No lockfiles committed — run `bun install` first +- Inventory uses **Drizzle ORM** with explicit migration step (`bun src/db/migrate.ts`) +- Gateway proxies all `/api//*` paths; the routing table is in `gateway/src/proxy.ts` diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md deleted file mode 100644 index 8e4c8f6..0000000 --- a/INSTRUCTIONS.md +++ /dev/null @@ -1,230 +0,0 @@ -# TicketFlow — Setup & Run Instructions - -## Prerequisites - -| Tool | Version | Notes | -|------|---------|-------| -| Node.js | 20+ | All services are Node/Express | -| Docker + Docker Compose | Latest | Spins up Postgres, Redis, RabbitMQ | -| pnpm | 8+ | Workspace package manager | - -```bash -npm install -g pnpm -``` - ---- - -## Project Structure (Quick Map) - -``` -ticketflow/ -├── gateway/ # API Gateway (Express + http-proxy-middleware) -├── services/ -│ ├── user-service/ # Auth, JWT, profiles -│ ├── event-service/ # Event listings, venues, schedules -│ ├── booking-service/ # Booking orchestration -│ ├── inventory-service/ # Seat locking (Redis), availability -│ ├── payment-service/ # Payment simulation (Stripe mock) -│ └── notification-service/ # Email/SMS via RabbitMQ consumer -├── shared/ -│ ├── middleware/ # Auth middleware, error handler -│ ├── events/ # Shared event name constants -│ └── types/ # Shared TypeScript interfaces -├── docker-compose.yml -├── docker-compose.dev.yml -├── pnpm-workspace.yaml -└── .env.example -``` - ---- - -## First-Time Setup - -### 1. Clone and install dependencies - -```bash -git clone ticketflow -cd ticketflow -pnpm install # installs all workspace packages in one shot -``` - -### 2. Configure environment variables - -```bash -cp .env.example .env -# Edit .env — see variable reference below -``` - -### 3. Start infrastructure (Postgres, Redis, RabbitMQ) - -```bash -docker compose -f docker-compose.dev.yml up -d -``` - -Wait ~10 seconds for Postgres to be ready. - -### 4. Run database migrations - -```bash -pnpm --filter user-service run migrate -pnpm --filter event-service run migrate -pnpm --filter booking-service run migrate -pnpm --filter inventory-service run migrate -pnpm --filter payment-service run migrate -``` - -These commands execute service-local SQL migration scripts used by Drizzle-based services. - -### 5. Seed test data (optional but useful) - -```bash -pnpm --filter event-service run seed -``` - -### 6. Start all services in development mode - -```bash -pnpm --filter gateway run dev & -pnpm --filter user-service run dev & -pnpm --filter event-service run dev & -pnpm --filter booking-service run dev & -pnpm --filter inventory-service run dev & -pnpm --filter payment-service run dev & -pnpm --filter notification-service run dev & -``` - -Or use the convenience scripts at the root: - -```bash -pnpm run dev:all -# backend only: -pnpm run dev:backend -``` - ---- - -## Service Ports - -| Service | Port | -|---------|------| -| API Gateway | 3000 | -| User/Auth | 3001 | -| Event | 3002 | -| Booking | 3003 | -| Inventory | 3004 | -| Payment | 3005 | -| Notification | 3006 | -| RabbitMQ Management UI | 15672 | - ---- - -## Environment Variables Reference (`.env.example`) - -```env -# Shared -JWT_SECRET=changeme - -# Databases (one per service) -USER_DB_URL=postgresql://postgres:postgres@localhost:5432/users -EVENT_DB_URL=postgresql://postgres:postgres@localhost:5432/events -BOOKING_DB_URL=postgresql://postgres:postgres@localhost:5432/bookings -INVENTORY_DB_URL=postgresql://postgres:postgres@localhost:5432/inventory -PAYMENT_DB_URL=postgresql://postgres:postgres@localhost:5432/payments - -# Redis (Inventory service — seat locking) -REDIS_URL=redis://localhost:6379 - -# RabbitMQ -RABBITMQ_URL=amqp://guest:guest@localhost:5672 - -# Payment mock -STRIPE_SECRET_KEY=sk_test_mock_key -PAYMENT_SUCCESS_RATE=0.95 # 95% of payments succeed (for testing failures) - -# Notification (SMTP mock via MailHog) -SMTP_HOST=localhost -SMTP_PORT=1025 -``` - ---- - -## Core Booking Flow (for manual testing) - -```bash -BASE=http://localhost:3000 - -# 1. Register a user -curl -X POST $BASE/api/users/register \ - -H "Content-Type: application/json" \ - -d '{"name":"Om","email":"om@test.com","password":"secret123"}' - -# 2. Login — copy the token from response -TOKEN=$(curl -s -X POST $BASE/api/users/login \ - -H "Content-Type: application/json" \ - -d '{"email":"om@test.com","password":"secret123"}' | jq -r '.token') - -# 3. Browse events -curl $BASE/api/events - -# 4. Check seat availability for event -curl $BASE/api/inventory/events/:eventId/seats - -# 5. Place a booking (attempts seat lock, then charges payment) -curl -X POST $BASE/api/bookings \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"eventId":"","seatIds":["A1","A2"]}' - -# 6. Confirm booking (triggers notification via message bus) -curl -X POST $BASE/api/bookings/:bookingId/confirm \ - -H "Authorization: Bearer $TOKEN" -``` - ---- - -## Testing Concurrency (Race Condition Demo) - -The inventory service uses Redis `SETNX` for atomic seat locking. Test it: - -```bash -# Fire 10 simultaneous booking attempts for the same seat -for i in {1..10}; do - curl -s -X POST http://localhost:3000/api/bookings \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"eventId":"event-1","seatIds":["A1"]}' & -done -wait -# Only one should succeed; all others get 409 Conflict -``` - ---- - -## Running Tests - -```bash -pnpm run test # all services -pnpm --filter booking-service run test # single service -pnpm run test:integration # requires Docker infra running -``` - ---- - -## Production Build - -```bash -docker compose up --build -d -``` - -All services are containerized. The `docker-compose.yml` (prod) excludes dev ports and uses environment-specific config. - ---- - -## Troubleshooting - -| Symptom | Fix | -|---------|-----| -| `ECONNREFUSED 5432` | Postgres not ready yet — wait 10s and retry | -| `409 Seat already locked` | Expected behaviour for concurrent booking | -| RabbitMQ not connecting | Check `docker compose ps` — restart infra | -| JWT invalid | Ensure `JWT_SECRET` is same across all services | diff --git a/PROMPT.md b/PROMPT.md deleted file mode 100644 index 6e447f1..0000000 --- a/PROMPT.md +++ /dev/null @@ -1,107 +0,0 @@ -# Agent Prompt — TicketFlow Microservices Platform - -You are implementing **TicketFlow**, a distributed event ticket booking system built with microservices. Read `AGENT.md` fully before writing a single line of code — it contains all contracts, rules, and architectural decisions that must be followed exactly. - ---- - -## Your Task - -Generate the complete, working codebase for TicketFlow as described in `AGENT.md` and `INSTRUCTIONS.md`. The implementation must be production-quality TypeScript — no placeholder stubs, no `// TODO` comments, no `any` types. - ---- - -## Implementation Order - -Follow this order strictly. Do not jump ahead. - -### Phase 1 — Scaffolding -1. `pnpm-workspace.yaml` and root `package.json` (with `dev:all` script) -2. `docker-compose.dev.yml` (Postgres, Redis, RabbitMQ, MailHog) -3. `docker-compose.yml` (production, no exposed debug ports) -4. `.env.example` with every variable documented -5. `shared/` package — event constants, TypeScript interfaces, error handler middleware, Zod validation middleware - -### Phase 2 — User Service -- Prisma schema (User model) -- Register, Login, Get Me routes -- bcrypt password hashing -- JWT sign on login, verify middleware in `shared/` -- Full Zod validation on all inputs - -### Phase 3 — Event Service -- Prisma schema (Event, Venue, Schedule) -- CRUD routes (GET all, GET by ID, POST/PUT for admin) -- Seed script with 3 sample events, each with 50 seats (rows A-E, seats 1-10) - -### Phase 4 — Inventory Service (most critical) -- Prisma schema (Seat, SeatStatus enum: AVAILABLE | LOCKED | RESERVED) -- Redis client setup -- `lockSeat(seatId, bookingId, ttl)` using `SETNX` — atomic, returns boolean -- `releaseSeats(seatIds[])` — batch release -- `confirmSeats(seatIds[])` — moves LOCKED → RESERVED in Postgres -- Batch lock endpoint: locks all requested seats or releases all on partial failure → 409 - -### Phase 5 — Payment Service -- Prisma schema (Payment, PaymentStatus: PENDING | SUCCESS | FAILED) -- Mock payment logic: succeed with probability from `PAYMENT_SUCCESS_RATE` env var -- Record every attempt in DB -- Return structured response with payment ID - -### Phase 6 — Booking Service -- Prisma schema (Booking, BookingItem, BookingStatus) -- Full orchestration flow (see AGENT.md section "Booking Orchestration") -- MUST handle rollback: if payment fails → release seat locks → mark FAILED -- Publish `BOOKING_CONFIRMED` to RabbitMQ after successful DB write -- GET /my for user's booking history - -### Phase 7 — Notification Service -- RabbitMQ consumer setup with reconnect logic (retry on disconnect) -- Handle `BOOKING_CONFIRMED` → send confirmation email via Nodemailer (MailHog in dev) -- Email template: plain text, includes event name, seat list, booking ID, total amount -- Handle `PAYMENT_FAILED` → send failure notification - -### Phase 8 — API Gateway -- Express app with `http-proxy-middleware` -- Route `/api/users/*` → user-service:3001 -- Route `/api/events/*` → event-service:3002 -- Route `/api/bookings/*` → booking-service:3003 -- Route `/api/inventory/*` → inventory-service:3004 -- Route `/api/payments/*` → payment-service:3005 -- JWT verification middleware at gateway level (forward decoded user in header) -- Rate limiter: 100 req/min per IP - -### Phase 9 — Tests -- Unit tests for booking service orchestration (mock axios calls to inventory + payment) -- Unit tests for inventory service seat locking (mock Redis) -- Integration test for full booking flow (requires test DB) -- Concurrency test: 20 simultaneous booking attempts for the same seat → only 1 succeeds - -### Phase 10 — Finish -- `README.md` with a one-paragraph project summary and link to INSTRUCTIONS.md -- Verify all `tsconfig.json` files have `strict: true` -- Verify all services export a named `app` (for testing) and call `app.listen` only in `index.ts` - ---- - -## Non-Negotiable Rules - -1. **Every service owns its own DB** — no shared schemas, no cross-service Prisma imports -2. **Seat locking is Redis SETNX only** — no database-level locking, no in-memory maps -3. **Booking state machine**: PENDING → CONFIRMED or PENDING → FAILED. Never skip PENDING. -4. **RabbitMQ events publish after DB commit** — never before -5. **No `any` types anywhere** — use `unknown` and type guards if needed -6. **All HTTP endpoints validate with Zod** — no raw `req.body` access without parsing -7. **Services communicate only via HTTP or RabbitMQ** — no shared memory, no direct imports -8. **The 409 on concurrent seat booking must actually work** — test it, don't stub it - ---- - -## When You Are Done - -Confirm by listing: -- [ ] All 6 services start without errors (`pnpm run dev:all`) -- [ ] `docker compose -f docker-compose.dev.yml up -d` brings up all infra -- [ ] The full booking flow works end-to-end via the gateway on port 3000 -- [ ] The concurrency test passes (1 success, 19 conflicts for same seat) -- [ ] All migrations run cleanly -- [ ] TypeScript compiles with zero errors (`pnpm run build` in each service) diff --git a/README.md b/README.md deleted file mode 100644 index 95ac2ad..0000000 --- a/README.md +++ /dev/null @@ -1,255 +0,0 @@ -# FMS Monorepo - -This repository contains TicketFlow, a distributed event ticketing platform built as a microservices system. It is designed to demonstrate real-world service decomposition, concurrency-safe seat allocation, asynchronous event-driven notifications, and an end-to-end booking experience across web and backend layers. - -## Concept - -TicketFlow solves a classic high-contention problem: many users trying to book the same seats at the same time. - -The core design goals are: - -- Prevent double booking under concurrent load. -- Keep services independently deployable and maintainable. -- Support synchronous request/response flows plus asynchronous domain events. -- Make local development reproducible with Docker + pnpm workspace tooling. - -## Elevator Pitch - -TicketFlow is a production-style ticket booking platform that combines: - -- A React frontend for user-facing discovery and booking. -- An API gateway for routing, auth, and rate limiting. -- Specialized backend services for user, event, booking, inventory, payment, and notification domains. -- Redis-based atomic locking for race-condition-safe seat allocation. -- RabbitMQ-driven event publishing for decoupled notifications. - -In short: it is a practical reference architecture for building resilient, scalable transaction workflows with microservices. - -## System Architecture - -### High-Level Diagram - -```mermaid -flowchart LR - U[User Browser\nReact + Vite] --> G[API Gateway\nExpress Proxy] - - G --> US[User Service\nAuth + JWT] - G --> ES[Event Service\nEvents + Venues] - G --> BS[Booking Service\nOrchestration] - G --> IS[Inventory Service\nSeat Locks] - G --> PS[Payment Service\nPayment Simulation] - - BS --> IS - BS --> ES - BS --> PS - BS --> MQ[(RabbitMQ)] - - MQ --> NS[Notification Service\nEmail Consumer] - NS --> MH[MailHog SMTP] - - US --> PG[(PostgreSQL)] - ES --> PG - BS --> PG - IS --> PG - PS --> PG - IS --> RD[(Redis)] -``` - -### Booking Sequence (Critical Flow) - -```mermaid -sequenceDiagram - participant Client - participant Gateway - participant Booking - participant Event - participant Inventory - participant Payment - participant RabbitMQ - participant Notification - - Client->>Gateway: POST /api/bookings - Gateway->>Booking: Forward request (auth context) - Booking->>Event: Validate event exists - Booking->>Inventory: Lock seats (TTL, atomic) - alt Seat conflict - Inventory-->>Booking: 409 conflict - Booking-->>Gateway: 409 - Gateway-->>Client: Seat unavailable - else Seats locked - Booking->>Booking: Create PENDING booking - Booking->>Payment: Charge request - alt Payment failed - Payment-->>Booking: FAILED / 402 - Booking->>Inventory: Release seats - Booking->>Booking: Mark FAILED - Booking-->>Gateway: 402 - Gateway-->>Client: Payment failed - else Payment success - Payment-->>Booking: SUCCESS - Booking->>Inventory: Confirm seats (RESERVED) - Booking->>Booking: Mark CONFIRMED - Booking->>RabbitMQ: Publish booking.confirmed - RabbitMQ->>Notification: Consume event - Notification->>Client: Confirmation email (via SMTP) - Booking-->>Gateway: Confirmed booking - Gateway-->>Client: 201/200 response - end - end -``` - -## Implementation Overview - -### Monorepo Layout - -- ticketflow/frontend: React + TypeScript + Vite UI. -- ticketflow/gateway: API gateway with route proxying and middleware. -- ticketflow/services/user-service: registration, login, JWT-based identity. -- ticketflow/services/event-service: event catalog, venues, seed data. -- ticketflow/services/booking-service: transaction orchestration and event publishing. -- ticketflow/services/inventory-service: seat availability and Redis lock management. -- ticketflow/services/payment-service: payment simulation with configurable success rate. -- ticketflow/services/notification-service: RabbitMQ consumer + email notifications. -- ticketflow/shared: shared middleware, event names, and common types. -- ticketflow/infra/postgres: database bootstrap SQL. - -### Tech Stack - -- Runtime: Node.js 20+ and TypeScript. -- Frontend: React 18, React Router, TanStack Query, Tailwind CSS, Vite. -- Backend: Express 4, Zod validation, JWT auth. -- Data access: Drizzle ORM with PostgreSQL. -- Concurrency/locking: Redis (SET NX EX). -- Async messaging: RabbitMQ topic exchange. -- Email testing: Nodemailer + MailHog. -- Tooling: pnpm workspaces, Jest, Docker Compose. - -### Service Responsibilities - -| Service | Default Port | Primary Responsibility | -| -------------------- | -----------: | ----------------------------------------------------------- | -| Gateway | 3000 | Entry point, route proxying, rate limiting, auth middleware | -| User Service | 3001 | User registration, login, profile identity | -| Event Service | 3002 | Event listing/details, venue data | -| Booking Service | 3003 | Booking orchestration and state transitions | -| Inventory Service | 3004 | Seat retrieval, seat lock/release/confirm | -| Payment Service | 3005 | Payment attempt recording and status result | -| Notification Service | 3006 | Consumes booking/payment events and sends mail | - -## Concurrency and Consistency Strategy - -To avoid double-booking: - -- Seat locks are attempted atomically in Redis using `SET key value EX ttl NX`. -- Locking is all-or-nothing for batch seat requests; partial locks are rolled back. -- Bookings transition through explicit status lifecycle: PENDING -> CONFIRMED or PENDING -> FAILED. -- On payment failure, lock release and booking failure update are part of the rollback path. -- Success events are published after booking confirmation write so consumers react to committed state. - -## Local Development - -### Prerequisites - -- Node.js 20+ -- pnpm 8+ -- Docker + Docker Compose - -### Setup - -```bash -cd ticketflow -pnpm install -cp .env.example .env -docker compose -f docker-compose.dev.yml up -d -``` - -### Database Migrations - -```bash -pnpm --filter user-service run migrate -pnpm --filter event-service run migrate -pnpm --filter booking-service run migrate -pnpm --filter inventory-service run migrate -pnpm --filter payment-service run migrate -``` - -### Seed Events - -```bash -pnpm --filter event-service run seed -``` - -### Start the Platform - -```bash -pnpm run dev:all -``` - -Backend only: - -```bash -pnpm run dev:backend -``` - -## Environment Configuration - -Main variables (see `ticketflow/.env.example` for the complete list): - -- `JWT_SECRET` -- `USER_DB_URL`, `EVENT_DB_URL`, `BOOKING_DB_URL`, `INVENTORY_DB_URL`, `PAYMENT_DB_URL` -- `REDIS_URL` -- `RABBITMQ_URL` -- `PAYMENT_SUCCESS_RATE` -- `SMTP_HOST`, `SMTP_PORT` -- inter-service URLs (`*_SERVICE_URL`) - -## API Surface (Gateway) - -- `/api/users/*` -> user-service -- `/api/events/*` -> event-service -- `/api/bookings/*` -> booking-service -- `/api/inventory/*` -> inventory-service -- `/api/payments/*` -> payment-service - -## Testing - -```bash -pnpm run test -pnpm run test:integration -``` - -Single service example: - -```bash -pnpm --filter booking-service run test -``` - -## Demo and Pitch Flow - -For a live demo or stakeholder pitch, use this narrative: - -1. Show event browsing in the frontend. -2. Authenticate a user and initiate a booking. -3. Trigger concurrent attempts for the same seat to show conflict safety. -4. Explain lock -> payment -> confirm orchestration and rollback handling. -5. Show asynchronous confirmation through RabbitMQ and MailHog inbox. -6. Highlight how each service can scale independently. - -## Current Scope and Future Enhancements - -Current scope: - -- End-to-end booking flow with service boundaries and async notifications. -- Deterministic local infra for realistic development/testing. - -Potential next steps: - -- Add observability (distributed tracing, metrics dashboards). -- Add idempotency keys and outbox pattern for stronger delivery guarantees. -- Introduce payment provider adapters and retry policies. -- Add Kubernetes deployment manifests and CI/CD pipelines. - -## Documentation Pointers - -- Full setup and operation details: [INSTRUCTIONS.md](INSTRUCTIONS.md) -- TicketFlow-specific runtime notes: [ticketflow/README.md](ticketflow/README.md) diff --git a/ticketflow/docker-compose.dev.yml b/ticketflow/docker-compose.dev.yml index a75cd5f..6099412 100644 --- a/ticketflow/docker-compose.dev.yml +++ b/ticketflow/docker-compose.dev.yml @@ -72,9 +72,9 @@ services: - zookeeper_data:/var/lib/zookeeper/data - zookeeper_logs:/var/lib/zookeeper/log healthcheck: - test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep imok"] + test: ["CMD-SHELL", "/usr/local/bin/cub zk-ready localhost:2181 10 2>/dev/null"] interval: 10s - timeout: 5s + timeout: 15s retries: 5 networks: - ticketflow-net @@ -103,7 +103,7 @@ services: volumes: - kafka_data:/var/lib/kafka/data healthcheck: - test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list"] + test: ["CMD-SHELL", "/usr/bin/kafka-topics --bootstrap-server localhost:9092 --list"] interval: 15s timeout: 10s retries: 10 @@ -204,7 +204,7 @@ services: networks: - ticketflow-net healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"] interval: 15s timeout: 5s retries: 3 @@ -230,7 +230,7 @@ services: networks: - ticketflow-net healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3001/api/users/health || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/users/health || exit 1"] interval: 20s timeout: 10s retries: 5 @@ -253,7 +253,7 @@ services: networks: - ticketflow-net healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3002/health || exit 1"] + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:3002/health')\" || exit 1"] interval: 20s timeout: 10s retries: 5 @@ -277,7 +277,7 @@ services: networks: - ticketflow-net healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3003/api/bookings/health || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://localhost:3003/api/bookings/health || exit 1"] interval: 20s timeout: 10s retries: 5 @@ -302,7 +302,7 @@ services: networks: - ticketflow-net healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3004/health || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://localhost:3004/health || exit 1"] interval: 20s timeout: 10s retries: 5 @@ -326,7 +326,7 @@ services: networks: - ticketflow-net healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3005/health || exit 1"] + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:3005/health')\" || exit 1"] interval: 20s timeout: 10s retries: 5 @@ -352,11 +352,11 @@ services: kafka: condition: service_healthy mailpit: - - service_started + condition: service_started networks: - ticketflow-net healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3006/health || exit 1"] + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:3006/health')\" || exit 1"] interval: 20s timeout: 10s retries: 5 diff --git a/ticketflow/frontend/.dockerignore b/ticketflow/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/ticketflow/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/ticketflow/frontend/package-lock.json b/ticketflow/frontend/package-lock.json new file mode 100644 index 0000000..83676c7 --- /dev/null +++ b/ticketflow/frontend/package-lock.json @@ -0,0 +1,5205 @@ +{ + "name": "ticketflow-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ticketflow-frontend", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-navigation-menu": "^1.2.3", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "^1.2.4", + "@tanstack/react-query": "^5.62.9", + "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "gsap": "^3.12.5", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1", + "tailwind-merge": "^2.5.5" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^10.1.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "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/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "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-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "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-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "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.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "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/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "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", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "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", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "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/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "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/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "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", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "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", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "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", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "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", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "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", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", + "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", + "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "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", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "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", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "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", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "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", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "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": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "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/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "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/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "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", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "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", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "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", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "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", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "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", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "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", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "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", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "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", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "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", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/ticketflow/frontend/src/pages/EventPage.tsx b/ticketflow/frontend/src/pages/EventPage.tsx index 9d301d6..392fd72 100644 --- a/ticketflow/frontend/src/pages/EventPage.tsx +++ b/ticketflow/frontend/src/pages/EventPage.tsx @@ -90,6 +90,8 @@ export function EventPage() { return 'Confirm Booking' } + const busy = phase === 'submitting' || phase === 'polling' + if (loading) { return
Loading event...
} @@ -125,9 +127,7 @@ export function EventPage() { const isSelected = selected.includes(seat.id) const disabled = seat.status !== 'AVAILABLE' || busy - const busy = phase === 'submitting' || phase === 'polling' - - return ( + return ( + ) +} + export function EventPage() { const { id } = useParams() const navigate = useNavigate() @@ -27,7 +96,7 @@ export function EventPage() { const load = async () => { try { const [eventData, seatData] = await Promise.all([eventsApi.getById(id), inventoryApi.getSeats(id)]) - setEvent(eventData.event) + setEvent(eventData) setSeats(seatData.seats) } catch { setError('Unable to load event details.') @@ -41,6 +110,15 @@ export function EventPage() { const availableSeats = useMemo(() => seats.filter((seat) => seat.status === 'AVAILABLE'), [seats]) const total = useMemo(() => (event ? event.price * selected.length : 0), [event, selected.length]) + const grouped = useMemo(() => groupBySection(seats), [seats]) + const selectedSeatLabels = useMemo( + () => + seats + .filter((s) => selected.includes(s.id)) + .map((s) => `${s.row}${s.seatNumber}`) + .join(', '), + [seats, selected] + ) const toggleSeat = (seatId: string) => { setSelected((current) => @@ -60,10 +138,7 @@ export function EventPage() { setError(null) try { - // POST /bookings → 202 Accepted - const { bookingId } = await bookingsApi.create({ eventId: id, seatIds: selected }) - - // Poll until booking leaves PENDING state + const { id: bookingId } = await bookingsApi.create({ eventId: id, eventName: event.name, seatIds: selected, totalAmount: total }) setPhase('polling') const booking = await pollBookingStatus(bookingId) @@ -100,14 +175,22 @@ export function EventPage() { return (

Event not found.

- Back to events + + Back to events +
) } + const sortedSections = [...grouped.keys()].sort( + (a, b) => (SECTION_ORDER.indexOf(a) === -1 ? 99 : SECTION_ORDER.indexOf(a)) - + (SECTION_ORDER.indexOf(b) === -1 ? 99 : SECTION_ORDER.indexOf(b)) + ) + return (
+ {/* Event header */}

{event.name}

{event.description}

@@ -118,45 +201,100 @@ export function EventPage() {
+ {/* Seat map */} Select Seats - - {seats.map((seat) => { - const isSelected = selected.includes(seat.id) - const disabled = seat.status !== 'AVAILABLE' || busy - - return ( - - ) - })} + + {seats.length === 0 ? ( +

No seats available for this event.

+ ) : ( + <> + {/* Stage indicator */} +
+
+ STAGE +
+
+
+ + {/* Sections */} + {sortedSections.map((section) => { + const rows = grouped.get(section)! + const sectionColor = SECTION_COLORS[section] ?? 'bg-gray-50 border-gray-200' + const sectionLabel = SECTION_LABELS[section] ?? section + + return ( +
+
+ {sectionLabel} +
+
+ {[...rows.entries()].map(([row, rowSeats]) => ( +
+ {row} +
+ {rowSeats.map((seat) => ( + + ))} +
+ {row} +
+ ))} +
+
+ ) + })} + + {/* Legend */} +
+
+
+ Available +
+
+
+ Selected +
+
+
+ Taken +
+
+ + )}
+ {/* Booking sidebar */} diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 85dab2e..f617d93 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -19,7 +19,7 @@ export function LandingPage() { const load = async () => { try { const data = await eventsApi.getAll() - setEvents(data.events) + setEvents(data.events ?? []) } catch { setError('Could not load events. Ensure gateway is running on port 3000.') } finally { @@ -34,9 +34,12 @@ export function LandingPage() { if (!heroRef.current) return const timeline = gsap.timeline({ defaults: { ease: 'power3.out' } }) - timeline - .fromTo(heroRef.current.querySelectorAll('.hero-item'), { opacity: 0, y: 24 }, { opacity: 1, y: 0, stagger: 0.12, duration: 0.8 }) - .fromTo(cardsRef.current?.querySelectorAll('.event-card') ?? [], { opacity: 0, y: 28 }, { opacity: 1, y: 0, stagger: 0.08, duration: 0.6 }, '-=0.35') + timeline.fromTo(heroRef.current.querySelectorAll('.hero-item'), { opacity: 0, y: 24 }, { opacity: 1, y: 0, stagger: 0.12, duration: 0.8 }) + + const cards = cardsRef.current?.querySelectorAll('.event-card') + if (cards && cards.length > 0) { + timeline.fromTo(cards, { opacity: 0, y: 28 }, { opacity: 1, y: 0, stagger: 0.08, duration: 0.6 }, '-=0.35') + } return () => { timeline.kill() diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f668626..7db0625 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ port: 5173, proxy: { '/api': { - target: 'http://localhost:3000', + target: process.env.VITE_GATEWAY_URL || 'http://localhost:3000', changeOrigin: true, }, }, diff --git a/gateway/src/app.ts b/gateway/src/app.ts index 77d49f0..72d2af2 100644 --- a/gateway/src/app.ts +++ b/gateway/src/app.ts @@ -66,6 +66,15 @@ export const createApp = () => { }) // User Service routes + .all("/api/users", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.user}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) .all("/api/users/*", async ({ request }) => { const ip = getIp(request); if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { @@ -77,6 +86,15 @@ export const createApp = () => { }) // Event Service routes + .all("/api/events", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.event}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) .all("/api/events/*", async ({ request }) => { const ip = getIp(request); if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { @@ -88,6 +106,15 @@ export const createApp = () => { }) // Venues → Event Service + .all("/api/venues", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.event}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) .all("/api/venues/*", async ({ request }) => { const ip = getIp(request); if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { @@ -99,6 +126,15 @@ export const createApp = () => { }) // Booking Service routes + .all("/api/bookings", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.booking}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) .all("/api/bookings/*", async ({ request }) => { const ip = getIp(request); if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { @@ -110,6 +146,15 @@ export const createApp = () => { }) // Inventory Service routes + .all("/api/inventory", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.inventory}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) .all("/api/inventory/*", async ({ request }) => { const ip = getIp(request); if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { @@ -121,6 +166,15 @@ export const createApp = () => { }) // Payment Service routes + .all("/api/payments", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.payment}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) .all("/api/payments/*", async ({ request }) => { const ip = getIp(request); if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { @@ -132,6 +186,15 @@ export const createApp = () => { }) // Notification Service routes + .all("/api/notifications", async ({ request }) => { + const ip = getIp(request); + if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { + return rateLimitedError(); + } + const url = new URL(request.url); + const targetUrl = `${config.services.notification}${url.pathname}${url.search}`; + return proxyRequest(targetUrl, request, getAuthHeaders(request)); + }) .all("/api/notifications/*", async ({ request }) => { const ip = getIp(request); if (!checkRateLimit(ip, config.rateLimit.maxRequests, config.rateLimit.windowMs)) { diff --git a/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java b/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java index bcef80c..21c8d12 100644 --- a/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java +++ b/services/booking-service/src/main/java/com/ticketflow/booking/config/KafkaConfig.java @@ -12,7 +12,7 @@ import org.springframework.kafka.core.DefaultKafkaConsumerFactory; import org.springframework.kafka.core.KafkaAdmin; import org.springframework.kafka.listener.DefaultErrorHandler; -import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.converter.StringJsonMessageConverter; import org.springframework.util.backoff.FixedBackOff; import java.util.HashMap; @@ -74,10 +74,7 @@ public ConsumerFactory consumerFactory() { props.put(org.apache.kafka.clients.consumer.ConsumerConfig.GROUP_ID_CONFIG, groupId); props.put(org.apache.kafka.clients.consumer.ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); props.put(org.apache.kafka.clients.consumer.ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); - props.put(org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); - props.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); - props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); - props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, "java.util.Map"); + props.put(org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); return new DefaultKafkaConsumerFactory<>(props); } @@ -86,6 +83,7 @@ public ConcurrentKafkaListenerContainerFactory kafkaListenerCont ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory()); + factory.setRecordMessageConverter(new StringJsonMessageConverter()); factory.setCommonErrorHandler(new DefaultErrorHandler(new FixedBackOff(3000L, 3))); return factory; } diff --git a/services/event-service/app/seed.py b/services/event-service/app/seed.py index 6fd2428..459f450 100644 --- a/services/event-service/app/seed.py +++ b/services/event-service/app/seed.py @@ -4,10 +4,12 @@ """ import asyncio +import json import logging from datetime import datetime from motor.motor_asyncio import AsyncIOMotorClient from beanie import init_beanie +from aiokafka import AIOKafkaProducer from app.config import settings from app.models.event import VenueDocument, EventDocument @@ -70,8 +72,8 @@ async def seed(): }, date=datetime(2025, 6, 15, 20, 0, 0), price=75.0, - total_seats=500, - available_seats=500, + total_seats=120, + available_seats=120, tags=["rock", "live", "music"], ), EventDocument( @@ -88,8 +90,8 @@ async def seed(): }, date=datetime(2025, 7, 20, 19, 0, 0), price=55.0, - total_seats=500, - available_seats=500, + total_seats=100, + available_seats=100, tags=["jazz", "blues", "festival"], ), EventDocument( @@ -106,8 +108,8 @@ async def seed(): }, date=datetime(2025, 8, 10, 18, 30, 0), price=95.0, - total_seats=200, - available_seats=200, + total_seats=80, + available_seats=80, tags=["classical", "symphony", "orchestra"], ), ] @@ -115,6 +117,33 @@ async def seed(): await event.insert() logger.info(f"Created event: {event.name} (id={event.id})") + # --- Publish ticketflow.event.created for each event so inventory-service creates seats --- + producer = AIOKafkaProducer( + bootstrap_servers=settings.kafka_bootstrap_servers, + value_serializer=lambda v: json.dumps(v).encode("utf-8"), + key_serializer=lambda k: k.encode("utf-8") if k else None, + ) + await producer.start() + try: + for event in events_data: + payload = { + "eventType": "event.created", + "eventId": str(event.id), + "name": event.name, + "totalSeats": event.total_seats, + "timestamp": datetime.utcnow().isoformat(), + } + await producer.send_and_wait( + "ticketflow.event.created", + value=payload, + key=str(event.id), + ) + logger.info( + f"Published event.created for: {event.name} (totalSeats={event.total_seats})" + ) + finally: + await producer.stop() + logger.info("Seed complete.") client.close() diff --git a/services/inventory-service/src/services/inventory.service.ts b/services/inventory-service/src/services/inventory.service.ts index aa1b604..89256b7 100644 --- a/services/inventory-service/src/services/inventory.service.ts +++ b/services/inventory-service/src/services/inventory.service.ts @@ -10,16 +10,24 @@ export async function getSeatsByEvent(eventId: string) { } /** - * Bulk-creates seats for a newly created event. - * Seats are distributed evenly across `rows` rows (A, B, C…). + * Venue layout definition: sections with rows and seat counts. + */ +const VENUE_LAYOUT = [ + { section: "VIP", rows: ["A", "B"], seatsPerRow: 10 }, + { section: "FLOOR", rows: ["C", "D", "E", "F"], seatsPerRow: 15 }, + { section: "BALCONY", rows: ["G", "H", "I"], seatsPerRow: 12 }, +]; + +/** + * Bulk-creates seats for a newly created event using a fixed venue layout. + * Sections: VIP (rows A–B, 10 seats), FLOOR (rows C–F, 15 seats), BALCONY (rows G–I, 12 seats). + * Total capacity: 20 + 60 + 36 = 116 seats (capped at totalSeats). */ export async function createSeatsForEvent( eventId: string, totalSeats: number, - rows = 5 + _rows = 5 ) { - const seatsPerRow = Math.ceil(totalSeats / rows); - const rowLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); const newSeats: { id: string; eventId: string; @@ -29,17 +37,22 @@ export async function createSeatsForEvent( status: "AVAILABLE"; }[] = []; - for (let r = 0; r < rows && newSeats.length < totalSeats; r++) { - for (let s = 1; s <= seatsPerRow && newSeats.length < totalSeats; s++) { - newSeats.push({ - id: crypto.randomUUID(), - eventId, - row: rowLetters[r]!, - seatNumber: s.toString(), - section: "GENERAL", - status: "AVAILABLE", - }); + for (const { section, rows, seatsPerRow } of VENUE_LAYOUT) { + for (const row of rows) { + for (let s = 1; s <= seatsPerRow; s++) { + if (newSeats.length >= totalSeats) break; + newSeats.push({ + id: crypto.randomUUID(), + eventId, + row, + seatNumber: s.toString(), + section, + status: "AVAILABLE", + }); + } + if (newSeats.length >= totalSeats) break; } + if (newSeats.length >= totalSeats) break; } await db.insert(seats).values(newSeats).onConflictDoNothing(); diff --git a/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java b/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java index 23c5fa1..1721be0 100644 --- a/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java +++ b/services/user-service/src/main/java/com/ticketflow/user/config/SecurityConfig.java @@ -22,7 +22,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/users/register", "/api/users/login", "/api/users/health", "/health").permitAll() + .requestMatchers("/api/users/register", "/api/users/login", "/api/users/health", "/api/users/me", "/health").permitAll() .requestMatchers("/actuator/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() .anyRequest().authenticated() ); diff --git a/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java b/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java index 421e4a7..18b90b8 100644 --- a/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java +++ b/services/user-service/src/main/java/com/ticketflow/user/controller/AuthController.java @@ -35,9 +35,9 @@ public ResponseEntity login(@Valid @RequestBody LoginRequest reque } @GetMapping("/me") - public ResponseEntity getMe(@RequestHeader("x-user-id") String userId) { + public ResponseEntity> getMe(@RequestHeader("x-user-id") String userId) { UserDTO user = authService.getMe(userId); - return ResponseEntity.ok(user); + return ResponseEntity.ok(Map.of("user", user)); } @GetMapping("/health") From de523202ff49f03f54ef0af025a6e3b828559479 Mon Sep 17 00:00:00 2001 From: Om Lanke Date: Tue, 21 Apr 2026 11:13:07 +0530 Subject: [PATCH 5/6] feat: enhance EventPage and LandingPage UI with improved loading states, animations, and styling - Updated EventPage to include new loading indicators and improved seat selection styles. - Refactored LandingPage to enhance hero section with decorative elements and feature pills. - Introduced EventCard component for better event display consistency. - Improved NotFoundPage with a more engaging layout and icon. - Enhanced Tailwind CSS animations for smoother transitions and effects. --- frontend/index.html | 2 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/App.tsx | 73 ++++++- frontend/src/components/TicketModal.tsx | 237 ++++++++++++++++++++ frontend/src/components/ui/dialog.tsx | 55 +++++ frontend/src/index.css | 205 +++++++++++++++--- frontend/src/pages/AuthPage.tsx | 168 +++++++++++---- frontend/src/pages/BookingsPage.tsx | 273 +++++++++++++++++++++--- frontend/src/pages/EventPage.tsx | 237 +++++++++++++------- frontend/src/pages/LandingPage.tsx | 197 ++++++++++++----- frontend/src/pages/NotFoundPage.tsx | 21 +- frontend/tailwind.config.js | 30 ++- 13 files changed, 1264 insertions(+), 245 deletions(-) create mode 100644 frontend/src/components/TicketModal.tsx create mode 100644 frontend/src/components/ui/dialog.tsx diff --git a/frontend/index.html b/frontend/index.html index 38032cf..a578256 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 83676c7..d5c1d52 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "clsx": "^2.1.1", "gsap": "^3.12.5", "lucide-react": "^0.468.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", @@ -4370,6 +4371,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index a01472d..90f26a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "gsap": "^3.12.5", "lucide-react": "^0.468.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a72dba5..597a32b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,25 +6,71 @@ import { AuthPage } from '@/pages/AuthPage' import { EventPage } from '@/pages/EventPage' import { BookingsPage } from '@/pages/BookingsPage' import { NotFoundPage } from '@/pages/NotFoundPage' +import { Ticket } from 'lucide-react' function Header() { const { isAuthenticated, user, logout } = useAuth() + const location = useLocation() return ( -
-
- TicketFlow -